meteocat 4.0.0 → 4.0.2

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.
Files changed (49) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +45 -45
  2. package/.github/ISSUE_TEMPLATE/config.yml +8 -8
  3. package/.github/ISSUE_TEMPLATE/improvement.md +39 -39
  4. package/.github/ISSUE_TEMPLATE/new_function.md +41 -41
  5. package/.github/labels.yml +63 -63
  6. package/.github/workflows/autocloser.yaml +27 -27
  7. package/.github/workflows/close-on-label.yml +48 -48
  8. package/.github/workflows/force-sync-labels.yml +18 -18
  9. package/.github/workflows/hassfest.yaml +13 -13
  10. package/.github/workflows/publish-zip.yml +67 -67
  11. package/.github/workflows/release.yml +41 -41
  12. package/.github/workflows/stale.yml +63 -63
  13. package/.github/workflows/sync-gitlab.yml +107 -107
  14. package/.github/workflows/sync-labels.yml +21 -21
  15. package/.github/workflows/validate.yaml +16 -16
  16. package/.pre-commit-config.yaml +37 -37
  17. package/.releaserc +37 -37
  18. package/AUTHORS.md +13 -13
  19. package/CHANGELOG.md +971 -954
  20. package/README.md +207 -207
  21. package/conftest.py +11 -11
  22. package/custom_components/meteocat/__init__.py +298 -298
  23. package/custom_components/meteocat/condition.py +63 -63
  24. package/custom_components/meteocat/config_flow.py +613 -613
  25. package/custom_components/meteocat/const.py +132 -132
  26. package/custom_components/meteocat/coordinator.py +248 -68
  27. package/custom_components/meteocat/helpers.py +58 -58
  28. package/custom_components/meteocat/manifest.json +25 -25
  29. package/custom_components/meteocat/options_flow.py +287 -287
  30. package/custom_components/meteocat/sensor.py +4 -2
  31. package/custom_components/meteocat/strings.json +1060 -1058
  32. package/custom_components/meteocat/translations/ca.json +1060 -1058
  33. package/custom_components/meteocat/translations/en.json +1060 -1058
  34. package/custom_components/meteocat/translations/es.json +1060 -1058
  35. package/custom_components/meteocat/version.py +1 -1
  36. package/custom_components/meteocat/weather.py +218 -218
  37. package/filetree.py +48 -48
  38. package/filetree.txt +80 -79
  39. package/hacs.json +8 -8
  40. package/info.md +11 -11
  41. package/package.json +22 -22
  42. package/poetry.lock +3222 -3222
  43. package/pyproject.toml +68 -68
  44. package/requirements.test.txt +3 -3
  45. package/setup.cfg +64 -64
  46. package/setup.py +10 -10
  47. package/tests/bandit.yaml +17 -17
  48. package/tests/conftest.py +19 -19
  49. package/tests/test_init.py +9 -9
@@ -1,132 +1,132 @@
1
- # Constantes generales
2
- DOMAIN = "meteocat"
3
- BASE_URL = "https://api.meteo.cat"
4
- CONF_API_KEY = "api_key"
5
- TOWN_NAME = "town_name"
6
- TOWN_ID = "town_id"
7
- VARIABLE_NAME = "variable_name"
8
- VARIABLE_ID = "variable_id"
9
- STATION_NAME = "station_name"
10
- STATION_ID = "station_id"
11
- STATION_TYPE = "station_type"
12
- LATITUDE = "latitude"
13
- LONGITUDE = "longitude"
14
- ALTITUDE = "altitude"
15
- REGION_ID = "region_id"
16
- REGION_NAME = "region_name"
17
- PROVINCE_ID = "province_id"
18
- PROVINCE_NAME = "province_name"
19
- LIMIT_XEMA = "limit_xema"
20
- LIMIT_PREDICCIO = "limit_prediccio"
21
- LIMIT_XDDE = "limit_xdde"
22
- LIMIT_BASIC = "limit_basic"
23
- LIMIT_QUOTA = "limit_quota"
24
- STATION_STATUS = "station_status"
25
- HOURLY_FORECAST_FILE_STATUS = "hourly_forecast_file_status"
26
- DAILY_FORECAST_FILE_STATUS = "daily_forecast_file_status"
27
- UVI_FILE_STATUS = "uvi_file_status"
28
- ALERTS = "alerts"
29
- ALERT_FILE_STATUS = "alert_file_status"
30
- ALERT_WIND = "alert_wind"
31
- ALERT_RAIN_INTENSITY = "alert_rain_intensity"
32
- ALERT_RAIN = "alert_rain"
33
- ALERT_SEA = "alert_sea"
34
- ALERT_COLD = "alert_cold"
35
- ALERT_WARM = "alert_warm"
36
- ALERT_WARM_NIGHT = "alert_warm_night"
37
- ALERT_SNOW = "alert_snow"
38
- QUOTA_FILE_STATUS = "quota_file_status"
39
- QUOTA_XDDE = "quota_xdde"
40
- QUOTA_PREDICCIO = "quota_prediccio"
41
- QUOTA_BASIC = "quota_basic"
42
- QUOTA_XEMA = "quota_xema"
43
- QUOTA_QUERIES = "quota_queries"
44
- LIGHTNING_FILE_STATUS = "lightning_file_status"
45
- SUN = "sun"
46
- SUNRISE = "sunrise"
47
- SUNSET = "sunset"
48
- SUN_FILE_STATUS = "sun_file_status"
49
- MOON_PHASE = "moon_phase"
50
- MOON_FILE_STATUS = "moon_file_status"
51
- MOONRISE = "moonrise"
52
- MOONSET = "moonset"
53
-
54
- from homeassistant.const import Platform
55
-
56
- ATTRIBUTION = "Powered by Meteocatpy & Solarmoonpy"
57
- PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
58
- DEFAULT_NAME = "METEOCAT"
59
-
60
- # Tiempos para validación de API
61
- DEFAULT_VALIDITY_DAYS = 1 # Número de días a partir de los cuales se considera que el archivo de información está obsoleto
62
- DEFAULT_VALIDITY_HOURS = 6 # Hora a partir de la cual la API tiene la información actualizada de predicciones disponible para descarga
63
- DEFAULT_VALIDITY_MINUTES = 0 # Minutos a partir de los cuales la API tiene la información actualizada de predicciones disponible para descarga
64
- DEFAULT_UVI_LOW_VALIDITY_HOURS = 5 # Hora a partir de la cual la API tiene la información actualizada de datos UVI disponible para descarga con límite bajo de cuota
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
- 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
- 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_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
- 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
- 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
71
- DEFAULT_LIGHTNING_VALIDITY_HOURS = 1 # Hora a partir de la cual la API tiene la información actualizada de rayos disponible para descarga
72
- DEFAULT_LIGHTNING_VALIDITY_MINUTES = 0 # Minutos a partir de los cuales la API tiene la información actualizada de rayos disponible para descarga
73
-
74
- # Multiplicadores para la duración de validez basada en limit_prediccio
75
- ALERT_VALIDITY_MULTIPLIER_100 = 12 # para limit_prediccio <= 100
76
- ALERT_VALIDITY_MULTIPLIER_200 = 6 # para 100 < limit_prediccio <= 200
77
- ALERT_VALIDITY_MULTIPLIER_500 = 3 # para 200 < limit_prediccio <= 500
78
- ALERT_VALIDITY_MULTIPLIER_DEFAULT = 1 # para limit_prediccio > 500
79
-
80
- # CUOTA ALTA PARA FAVORECER ACTUALIZACIONES DIARIAS DE LAS PREDICCIONES
81
- PREDICCIO_HIGH_QUOTA_LIMIT = 550
82
-
83
- # Códigos de sensores de la API
84
- WIND_SPEED = "wind_speed" # Velocidad del viento
85
- WIND_DIRECTION = "wind_direction" # Dirección del viento
86
- WIND_DIRECTION_CARDINAL = "wind_direction_cardinal" # Dirección del viento en cardinal
87
- TEMPERATURE = "temperature" # Temperatura
88
- HUMIDITY = "humidity" # Humedad relativa
89
- PRESSURE = "pressure" # Presión atmosférica
90
- PRECIPITATION = "precipitation" # Precipitación
91
- PRECIPITATION_ACCUMULATED = "precipitation_accumulated" #Precipitación acumulada
92
- PRECIPITATION_PROBABILITY = "precipitation_probability" #Precipitación probabilidad
93
- SOLAR_GLOBAL_IRRADIANCE = "solar_global_irradiance" # Irradiación solar global
94
- UV_INDEX = "uv_index" # UV
95
- MAX_TEMPERATURE = "max_temperature" # Temperatura máxima
96
- MIN_TEMPERATURE = "min_temperature" # Temperatura mínima
97
- FEELS_LIKE = "feels_like" # Sensación térmica
98
- WIND_GUST = "wind_gust" # Racha de viento
99
- STATION_TIMESTAMP = "station_timestamp" # Código de tiempo de la estación
100
- CONDITION = "condition" # Estado del cielo
101
- MAX_TEMPERATURE_FORECAST = "max_temperature_forecast" # Temperatura máxima prevista
102
- MIN_TEMPERATURE_FORECAST = "min_temperature_forecast" # Temperatura mínima prevista
103
- LIGHTNING_REGION = "lightning_region" # Rayos de la comarca
104
- LIGHTNING_TOWN = "lightning_town" # Rayos de la población
105
-
106
- # Definición de códigos para variables
107
- WIND_SPEED_CODE = 30
108
- WIND_DIRECTION_CODE = 31
109
- TEMPERATURE_CODE = 32
110
- HUMIDITY_CODE = 33
111
- PRESSURE_CODE = 34
112
- PRECIPITATION_CODE = 35
113
- SOLAR_GLOBAL_IRRADIANCE_CODE = 36
114
- UV_INDEX_CODE = 39
115
- MAX_TEMPERATURE_CODE = 40
116
- MIN_TEMPERATURE_CODE = 42
117
- WIND_GUST_CODE = 50
118
-
119
- # Mapeo de códigos 'estatCel' a condiciones de Home Assistant
120
- CONDITION_MAPPING = {
121
- "sunny": [1],
122
- # "clear-night": [1],
123
- "partlycloudy": [2, 3],
124
- "cloudy": [4, 20, 21, 22],
125
- "rainy": [5, 6, 23],
126
- "pouring": [7, 8, 25],
127
- "lightning-rainy": [8, 24],
128
- "hail": [9],
129
- "snowy": [10, 26, 27, 28],
130
- "fog": [11, 12],
131
- "snow-rainy": [27, 29, 30],
132
- }
1
+ # Constantes generales
2
+ DOMAIN = "meteocat"
3
+ BASE_URL = "https://api.meteo.cat"
4
+ CONF_API_KEY = "api_key"
5
+ TOWN_NAME = "town_name"
6
+ TOWN_ID = "town_id"
7
+ VARIABLE_NAME = "variable_name"
8
+ VARIABLE_ID = "variable_id"
9
+ STATION_NAME = "station_name"
10
+ STATION_ID = "station_id"
11
+ STATION_TYPE = "station_type"
12
+ LATITUDE = "latitude"
13
+ LONGITUDE = "longitude"
14
+ ALTITUDE = "altitude"
15
+ REGION_ID = "region_id"
16
+ REGION_NAME = "region_name"
17
+ PROVINCE_ID = "province_id"
18
+ PROVINCE_NAME = "province_name"
19
+ LIMIT_XEMA = "limit_xema"
20
+ LIMIT_PREDICCIO = "limit_prediccio"
21
+ LIMIT_XDDE = "limit_xdde"
22
+ LIMIT_BASIC = "limit_basic"
23
+ LIMIT_QUOTA = "limit_quota"
24
+ STATION_STATUS = "station_status"
25
+ HOURLY_FORECAST_FILE_STATUS = "hourly_forecast_file_status"
26
+ DAILY_FORECAST_FILE_STATUS = "daily_forecast_file_status"
27
+ UVI_FILE_STATUS = "uvi_file_status"
28
+ ALERTS = "alerts"
29
+ ALERT_FILE_STATUS = "alert_file_status"
30
+ ALERT_WIND = "alert_wind"
31
+ ALERT_RAIN_INTENSITY = "alert_rain_intensity"
32
+ ALERT_RAIN = "alert_rain"
33
+ ALERT_SEA = "alert_sea"
34
+ ALERT_COLD = "alert_cold"
35
+ ALERT_WARM = "alert_warm"
36
+ ALERT_WARM_NIGHT = "alert_warm_night"
37
+ ALERT_SNOW = "alert_snow"
38
+ QUOTA_FILE_STATUS = "quota_file_status"
39
+ QUOTA_XDDE = "quota_xdde"
40
+ QUOTA_PREDICCIO = "quota_prediccio"
41
+ QUOTA_BASIC = "quota_basic"
42
+ QUOTA_XEMA = "quota_xema"
43
+ QUOTA_QUERIES = "quota_queries"
44
+ LIGHTNING_FILE_STATUS = "lightning_file_status"
45
+ SUN = "sun"
46
+ SUNRISE = "sunrise"
47
+ SUNSET = "sunset"
48
+ SUN_FILE_STATUS = "sun_file_status"
49
+ MOON_PHASE = "moon_phase"
50
+ MOON_FILE_STATUS = "moon_file_status"
51
+ MOONRISE = "moonrise"
52
+ MOONSET = "moonset"
53
+
54
+ from homeassistant.const import Platform
55
+
56
+ ATTRIBUTION = "Powered by Meteocatpy & Solarmoonpy"
57
+ PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
58
+ DEFAULT_NAME = "METEOCAT"
59
+
60
+ # Tiempos para validación de API
61
+ DEFAULT_VALIDITY_DAYS = 1 # Número de días a partir de los cuales se considera que el archivo de información está obsoleto
62
+ DEFAULT_VALIDITY_HOURS = 6 # Hora a partir de la cual la API tiene la información actualizada de predicciones disponible para descarga
63
+ DEFAULT_VALIDITY_MINUTES = 0 # Minutos a partir de los cuales la API tiene la información actualizada de predicciones disponible para descarga
64
+ DEFAULT_UVI_LOW_VALIDITY_HOURS = 5 # Hora a partir de la cual la API tiene la información actualizada de datos UVI disponible para descarga con límite bajo de cuota
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
+ 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
+ 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_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
+ 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
+ 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
71
+ DEFAULT_LIGHTNING_VALIDITY_HOURS = 1 # Hora a partir de la cual la API tiene la información actualizada de rayos disponible para descarga
72
+ DEFAULT_LIGHTNING_VALIDITY_MINUTES = 0 # Minutos a partir de los cuales la API tiene la información actualizada de rayos disponible para descarga
73
+
74
+ # Multiplicadores para la duración de validez basada en limit_prediccio
75
+ ALERT_VALIDITY_MULTIPLIER_100 = 12 # para limit_prediccio <= 100
76
+ ALERT_VALIDITY_MULTIPLIER_200 = 6 # para 100 < limit_prediccio <= 200
77
+ ALERT_VALIDITY_MULTIPLIER_500 = 3 # para 200 < limit_prediccio <= 500
78
+ ALERT_VALIDITY_MULTIPLIER_DEFAULT = 1 # para limit_prediccio > 500
79
+
80
+ # CUOTA ALTA PARA FAVORECER ACTUALIZACIONES DIARIAS DE LAS PREDICCIONES
81
+ PREDICCIO_HIGH_QUOTA_LIMIT = 550
82
+
83
+ # Códigos de sensores de la API
84
+ WIND_SPEED = "wind_speed" # Velocidad del viento
85
+ WIND_DIRECTION = "wind_direction" # Dirección del viento
86
+ WIND_DIRECTION_CARDINAL = "wind_direction_cardinal" # Dirección del viento en cardinal
87
+ TEMPERATURE = "temperature" # Temperatura
88
+ HUMIDITY = "humidity" # Humedad relativa
89
+ PRESSURE = "pressure" # Presión atmosférica
90
+ PRECIPITATION = "precipitation" # Precipitación
91
+ PRECIPITATION_ACCUMULATED = "precipitation_accumulated" #Precipitación acumulada
92
+ PRECIPITATION_PROBABILITY = "precipitation_probability" #Precipitación probabilidad
93
+ SOLAR_GLOBAL_IRRADIANCE = "solar_global_irradiance" # Irradiación solar global
94
+ UV_INDEX = "uv_index" # UV
95
+ MAX_TEMPERATURE = "max_temperature" # Temperatura máxima
96
+ MIN_TEMPERATURE = "min_temperature" # Temperatura mínima
97
+ FEELS_LIKE = "feels_like" # Sensación térmica
98
+ WIND_GUST = "wind_gust" # Racha de viento
99
+ STATION_TIMESTAMP = "station_timestamp" # Código de tiempo de la estación
100
+ CONDITION = "condition" # Estado del cielo
101
+ MAX_TEMPERATURE_FORECAST = "max_temperature_forecast" # Temperatura máxima prevista
102
+ MIN_TEMPERATURE_FORECAST = "min_temperature_forecast" # Temperatura mínima prevista
103
+ LIGHTNING_REGION = "lightning_region" # Rayos de la comarca
104
+ LIGHTNING_TOWN = "lightning_town" # Rayos de la población
105
+
106
+ # Definición de códigos para variables
107
+ WIND_SPEED_CODE = 30
108
+ WIND_DIRECTION_CODE = 31
109
+ TEMPERATURE_CODE = 32
110
+ HUMIDITY_CODE = 33
111
+ PRESSURE_CODE = 34
112
+ PRECIPITATION_CODE = 35
113
+ SOLAR_GLOBAL_IRRADIANCE_CODE = 36
114
+ UV_INDEX_CODE = 39
115
+ MAX_TEMPERATURE_CODE = 40
116
+ MIN_TEMPERATURE_CODE = 42
117
+ WIND_GUST_CODE = 50
118
+
119
+ # Mapeo de códigos 'estatCel' a condiciones de Home Assistant
120
+ CONDITION_MAPPING = {
121
+ "sunny": [1],
122
+ # "clear-night": [1],
123
+ "partlycloudy": [2, 3],
124
+ "cloudy": [4, 20, 21, 22],
125
+ "rainy": [5, 6, 23],
126
+ "pouring": [7, 8, 25],
127
+ "lightning-rainy": [8, 24],
128
+ "hail": [9],
129
+ "snowy": [10, 26, 27, 28],
130
+ "fog": [11, 12],
131
+ "snow-rainy": [27, 29, 30],
132
+ }
@@ -290,27 +290,54 @@ class MeteocatSensorCoordinator(DataUpdateCoordinator):
290
290
  raise
291
291
  except Exception as err:
292
292
  if isinstance(err, ConfigEntryNotReady):
293
- _LOGGER.exception(
294
- "No se pudo inicializar el dispositivo (Station ID: %s): %s",
295
- self.station_id,
296
- err,
297
- )
293
+ _LOGGER.exception("No se pudo inicializar el dispositivo (Station ID: %s): %s", self.station_id, err)
298
294
  raise
299
295
  else:
300
- _LOGGER.exception(
301
- "Error inesperado al obtener datos de los sensores (Station ID: %s): %s",
302
- self.station_id,
303
- err,
304
- )
305
-
306
- # Cargar datos en caché si la API falla
296
+ _LOGGER.exception("Error inesperado al obtener datos de sensores (Station ID: %s): %s", self.station_id, err)
297
+
298
+ # === FALLBACK SEGURO ===
307
299
  cached_data = await load_json_from_file(self.station_file)
308
- if cached_data:
309
- _LOGGER.warning("Usando datos en caché para la estación %s.", self.station_id)
300
+ if cached_data and isinstance(cached_data, list) and cached_data:
301
+ # Buscar la última lectura (cualquier variable)
302
+ last_reading = None
303
+ last_time_str = "unknown"
304
+ for var_block in cached_data:
305
+ for variable in var_block.get("variables", []):
306
+ lectures = variable.get("lectures", [])
307
+ if lectures:
308
+ candidate = lectures[-1].get("data")
309
+ if candidate and (last_reading is None or candidate > last_time_str):
310
+ last_reading = candidate
311
+ last_time_str = candidate
312
+
313
+ # Formatear hora legible
314
+ try:
315
+ if last_time_str != "unknown":
316
+ dt = datetime.fromisoformat(last_time_str.replace("Z", "+00:00"))
317
+ local_dt = dt.astimezone(TIMEZONE)
318
+ display_time = local_dt.strftime("%d/%m/%Y %H:%M")
319
+ else:
320
+ display_time = "unknown"
321
+ except (ValueError, TypeError, AttributeError):
322
+ display_time = last_time_str.split("T")[0] if "T" in last_time_str else last_time_str
323
+
324
+ _LOGGER.warning(
325
+ "SENSOR: API falló → usando caché local:\n"
326
+ " • Estación: %s (%s)\n"
327
+ " • Archivo: %s\n"
328
+ " • Última lectura: %s",
329
+ self.station_name,
330
+ self.station_id,
331
+ self.station_file.name,
332
+ display_time
333
+ )
334
+
335
+ self.async_set_updated_data(cached_data)
310
336
  return cached_data
311
-
312
- _LOGGER.error("No se pudo obtener datos actualizados ni cargar datos en caché.")
313
- return None
337
+
338
+ _LOGGER.error("SENSOR: No hay caché disponible para los datos de la estación %s.", self.station_id)
339
+ self.async_set_updated_data([])
340
+ return []
314
341
 
315
342
  class MeteocatStaticSensorCoordinator(DataUpdateCoordinator):
316
343
  """Coordinator to manage and update static sensor data."""
@@ -490,13 +517,30 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
490
517
  raise
491
518
  except Exception as err:
492
519
  _LOGGER.exception("Error inesperado al obtener datos del índice UV para %s: %s", self.town_id, err)
493
-
494
- # Fallback a caché en disco
520
+
521
+ # === FALLBACK SEGURO ===
495
522
  cached_data = await load_json_from_file(self.uvi_file)
496
- if cached_data:
497
- _LOGGER.warning("Usando datos en caché para la ciudad %s.", self.town_id)
498
- return cached_data.get("uvi", [])
499
- _LOGGER.error("No se pudo obtener datos UVI ni cargar caché.")
523
+ if cached_data and "uvi" in cached_data and cached_data["uvi"]:
524
+ raw_date = cached_data["uvi"][0].get("date", "unknown")
525
+ # Formatear fecha para log
526
+ try:
527
+ first_date = datetime.strptime(raw_date, "%Y-%m-%d").strftime("%d/%m/%Y")
528
+ except (ValueError, TypeError):
529
+ first_date = raw_date
530
+
531
+ _LOGGER.warning(
532
+ "API UVI falló → usando caché local:\n"
533
+ " • Archivo: %s\n"
534
+ " • Datos desde: %s",
535
+ self.uvi_file.name,
536
+ first_date
537
+ )
538
+
539
+ self.async_set_updated_data(cached_data["uvi"])
540
+ return cached_data["uvi"]
541
+
542
+ _LOGGER.error("No hay datos UVI ni en caché para %s", self.town_id)
543
+ self.async_set_updated_data([])
500
544
  return []
501
545
 
502
546
  class MeteocatUviFileCoordinator(BaseFileCoordinator):
@@ -753,20 +797,36 @@ class MeteocatEntityCoordinator(DataUpdateCoordinator):
753
797
  raise
754
798
  except Exception as err:
755
799
  _LOGGER.exception("Error inesperado al obtener datos de predicción: %s", err)
756
-
757
- # -----------------------------------------------------------------
758
- # Fallback: usar caché local si todo falla
759
- # -----------------------------------------------------------------
800
+
801
+ # === FALLBACK SEGURO ===
760
802
  hourly_cache = await load_json_from_file(self.hourly_file) or {}
761
803
  daily_cache = await load_json_from_file(self.daily_file) or {}
762
-
804
+
805
+ # --- Fecha horaria ---
806
+ h_raw = hourly_cache.get("dies", [{}])[0].get("data", "")
807
+ try:
808
+ h_date = h_raw.replace("Z", "").split("T")[0]
809
+ h_display = datetime.strptime(h_date, "%Y-%m-%d").strftime("%d/%m/%Y")
810
+ except (ValueError, AttributeError, IndexError):
811
+ h_display = "unknown"
812
+
813
+ # --- Fecha diaria ---
814
+ d_raw = daily_cache.get("dies", [{}])[0].get("data", "")
815
+ try:
816
+ d_date = d_raw.replace("Z", "").split("T")[0]
817
+ d_display = datetime.strptime(d_date, "%Y-%m-%d").strftime("%d/%m/%Y")
818
+ except (ValueError, AttributeError, IndexError):
819
+ d_display = "unknown"
820
+
763
821
  _LOGGER.warning(
764
- "Cargando datos desde caché para %s. Datos horarios: %s, Datos diarios: %s",
765
- self.town_id,
766
- "Encontrados" if hourly_cache else "No encontrados",
767
- "Encontrados" if daily_cache else "No encontrados",
822
+ "API falló usando caché local:\n"
823
+ " • %s → %s\n"
824
+ " %s %s",
825
+ self.hourly_file.name, h_display,
826
+ self.daily_file.name, d_display
768
827
  )
769
-
828
+
829
+ self.async_set_updated_data({"hourly": hourly_cache, "daily": daily_cache})
770
830
  return {"hourly": hourly_cache, "daily": daily_cache}
771
831
 
772
832
  def get_condition_from_code(code: int) -> str:
@@ -1381,20 +1441,36 @@ class MeteocatAlertsCoordinator(DataUpdateCoordinator):
1381
1441
  _LOGGER.error("Error al obtener datos de alertas: %s", err)
1382
1442
  raise
1383
1443
  except Exception as err:
1384
- _LOGGER.exception("Error inesperado al obtener datos de alertas: %s", err)
1385
-
1386
- # Intentar cargar datos en caché si hay un error
1444
+ _LOGGER.exception("Error al obtener alertas: %s", err)
1445
+
1446
+ # === FALLBACK SEGURO ===
1387
1447
  cached_data = await load_json_from_file(self.alerts_file)
1388
1448
  if self._is_valid_alert_data(cached_data):
1449
+ update_str = cached_data["actualitzat"]["dataUpdate"]
1450
+ try:
1451
+ update_dt = datetime.fromisoformat(update_str)
1452
+ local_dt = update_dt.astimezone(TIMEZONE)
1453
+ display_time = local_dt.strftime("%d/%m/%Y %H:%M")
1454
+ except (ValueError, TypeError):
1455
+ display_time = update_str
1456
+
1389
1457
  _LOGGER.warning(
1390
- "Usando datos en caché para las alertas. Última actualización: %s",
1391
- cached_data["actualitzat"]["dataUpdate"],
1458
+ "ALERTAS: API falló usando caché local:\n"
1459
+ " • Archivo: %s\n"
1460
+ " • Última actualización: %s\n"
1461
+ " • Alertas activas: %d",
1462
+ self.alerts_file.name,
1463
+ display_time
1392
1464
  )
1393
- return {"actualizado": cached_data['actualitzat']['dataUpdate']}
1394
-
1395
- # Si no se puede actualizar ni cargar datos en caché, retornar None
1396
- _LOGGER.error("No se pudo obtener datos actualizados ni cargar datos en caché de alertas.")
1397
- return None
1465
+
1466
+ self.async_set_updated_data({
1467
+ "actualizado": cached_data["actualitzat"]["dataUpdate"]
1468
+ })
1469
+ return {"actualizado": cached_data["actualitzat"]["dataUpdate"]}
1470
+
1471
+ _LOGGER.error("ALERTAS: No hay caché disponible. Sin datos de alertas.")
1472
+ self.async_set_updated_data({})
1473
+ return {}
1398
1474
 
1399
1475
  @staticmethod
1400
1476
  def _is_valid_alert_data(data: dict) -> bool:
@@ -1822,16 +1898,40 @@ class MeteocatQuotesCoordinator(DataUpdateCoordinator):
1822
1898
  _LOGGER.error("Error al obtener cuotas de la API de Meteocat: %s", err)
1823
1899
  raise
1824
1900
  except Exception as err:
1825
- _LOGGER.exception("Error inesperado al obtener cuotas de la API de Meteocat: %s", err)
1826
-
1827
- # Intentar cargar datos en caché si hay un error
1901
+ _LOGGER.exception("Error al obtener cuotas: %s", err)
1902
+
1903
+ # === FALLBACK SEGURO ===
1828
1904
  cached_data = await load_json_from_file(self.quotes_file)
1829
- if cached_data:
1830
- _LOGGER.warning("Usando datos en caché para las cuotas de la API de Meteocat.")
1831
- return {"actualizado": cached_data['actualitzat']['dataUpdate']}
1832
-
1833
- _LOGGER.error("No se pudo obtener datos actualizados ni cargar datos en caché.")
1834
- return None
1905
+ if cached_data and "actualitzat" in cached_data and "dataUpdate" in cached_data["actualitzat"]:
1906
+ update_str = cached_data["actualitzat"]["dataUpdate"]
1907
+ try:
1908
+ update_dt = datetime.fromisoformat(update_str)
1909
+ local_dt = update_dt.astimezone(TIMEZONE)
1910
+ display_time = local_dt.strftime("%d/%m/%Y %H:%M")
1911
+ except (ValueError, TypeError):
1912
+ display_time = update_str.split("T")[0]
1913
+
1914
+ # Contar planes activos
1915
+ plans_count = len(cached_data.get("plans", []))
1916
+
1917
+ _LOGGER.warning(
1918
+ "CUOTAS: API falló → usando caché local:\n"
1919
+ " • Archivo: %s\n"
1920
+ " • Última actualización: %s\n"
1921
+ " • Planes registrados: %d",
1922
+ self.quotes_file.name,
1923
+ display_time,
1924
+ plans_count
1925
+ )
1926
+
1927
+ self.async_set_updated_data({
1928
+ "actualizado": cached_data["actualitzat"]["dataUpdate"]
1929
+ })
1930
+ return {"actualizado": cached_data["actualitzat"]["dataUpdate"]}
1931
+
1932
+ _LOGGER.error("CUOTAS: No hay caché disponible. Sin información de consumo.")
1933
+ self.async_set_updated_data({})
1934
+ return {}
1835
1935
 
1836
1936
  class MeteocatQuotesFileCoordinator(BaseFileCoordinator):
1837
1937
  """Coordinator para manejar la actualización de las cuotas desde quotes.json."""
@@ -1993,16 +2093,36 @@ class MeteocatLightningCoordinator(DataUpdateCoordinator):
1993
2093
  _LOGGER.warning("Tiempo de espera agotado al obtener los datos de rayos de la API de Meteocat.")
1994
2094
  raise ConfigEntryNotReady from err
1995
2095
  except Exception as err:
1996
- _LOGGER.exception("Error inesperado al obtener los datos de rayos de la API de Meteocat: %s", err)
1997
-
1998
- # Intentar cargar datos en caché si la API falla
2096
+ _LOGGER.exception("Error al obtener datos de rayos: %s", err)
2097
+
2098
+ # === FALLBACK SEGURO ===
1999
2099
  cached_data = await load_json_from_file(self.lightning_file)
2000
- if cached_data:
2001
- _LOGGER.warning("Usando datos en caché para los datos de rayos de la API de Meteocat.")
2002
- return {"actualizado": cached_data['actualitzat']['dataUpdate']}
2100
+ if cached_data and "actualitzat" in cached_data:
2101
+ update_str = cached_data["actualitzat"]["dataUpdate"]
2102
+ try:
2103
+ update_dt = datetime.fromisoformat(update_str)
2104
+ # Convertir a hora local para mostrar
2105
+ local_dt = update_dt.astimezone(TIMEZONE)
2106
+ display_time = local_dt.strftime("%d/%m/%Y %H:%M")
2107
+ except (ValueError, TypeError):
2108
+ display_time = update_str
2109
+
2110
+ _LOGGER.warning(
2111
+ "API rayos falló → usando caché local:\n"
2112
+ " • Archivo: %s\n"
2113
+ " • Última actualización: %s",
2114
+ self.lightning_file.name,
2115
+ display_time
2116
+ )
2003
2117
 
2004
- _LOGGER.error("No se pudo obtener datos actualizados ni cargar datos en caché.")
2005
- return None
2118
+ self.async_set_updated_data({
2119
+ "actualizado": cached_data["actualitzat"]["dataUpdate"]
2120
+ })
2121
+ return {"actualizado": cached_data["actualitzat"]["dataUpdate"]}
2122
+
2123
+ _LOGGER.error("No hay caché de rayos disponible.")
2124
+ self.async_set_updated_data({})
2125
+ return {}
2006
2126
 
2007
2127
  class MeteocatLightningFileCoordinator(BaseFileCoordinator):
2008
2128
  """Coordinator para manejar la actualización de los datos de rayos desde lightning_{region_id}.json."""
@@ -2440,11 +2560,39 @@ class MeteocatSunCoordinator(DataUpdateCoordinator):
2440
2560
 
2441
2561
  except Exception as err:
2442
2562
  _LOGGER.exception("Error al calcular/guardar los datos solares: %s", err)
2563
+
2564
+ # === FALLBACK SEGURO ===
2443
2565
  cached = await load_json_from_file(self.sun_file)
2444
- if cached:
2445
- _LOGGER.warning("Usando datos solares en caché por error.")
2566
+ if cached and "actualitzat" in cached and "dades" in cached and cached["dades"]:
2567
+ update_str = cached["actualitzat"]["dataUpdate"]
2568
+ try:
2569
+ update_dt = datetime.fromisoformat(update_str)
2570
+ local_dt = update_dt.astimezone(ZoneInfo(self.timezone_str))
2571
+ display_time = local_dt.strftime("%d/%m/%Y %H:%M")
2572
+ except (ValueError, TypeError):
2573
+ display_time = update_str.split("T")[0]
2574
+
2575
+ sunrise = cached["dades"][0].get("sunrise", "unknown")
2576
+ sunset = cached["dades"][0].get("sunset", "unknown")
2577
+
2578
+ _LOGGER.warning(
2579
+ "SOL: Cálculo falló → usando caché local:\n"
2580
+ " • Archivo: %s\n"
2581
+ " • Última actualización: %s\n"
2582
+ " • Amanecer: %s\n"
2583
+ " • Atardecer: %s",
2584
+ self.sun_file.name,
2585
+ display_time,
2586
+ sunrise.split("T")[1][:5] if "T" in sunrise else sunrise,
2587
+ sunset.split("T")[1][:5] if "T" in sunset else sunset
2588
+ )
2589
+
2590
+ self.async_set_updated_data(cached)
2446
2591
  return cached
2447
- return None
2592
+
2593
+ _LOGGER.error("SOL: No hay caché disponible. Sin datos solares.")
2594
+ self.async_set_updated_data({})
2595
+ return {}
2448
2596
 
2449
2597
  class MeteocatSunFileCoordinator(BaseFileCoordinator):
2450
2598
  """Coordinator para manejar la actualización de los datos de sol desde sun_{town_id}.json."""
@@ -2868,13 +3016,45 @@ class MeteocatMoonCoordinator(DataUpdateCoordinator):
2868
3016
  return {"actualizado": data_with_timestamp["actualitzat"]["dataUpdate"]}
2869
3017
 
2870
3018
  except Exception as err:
2871
- _LOGGER.exception("🌙 Error al calcular datos de la luna: %s", err)
3019
+ _LOGGER.exception("Error al calcular datos de la luna: %s", err)
3020
+
3021
+ # === FALLBACK SEGURO ===
2872
3022
  cached_data = await load_json_from_file(self.moon_file)
2873
- if cached_data:
2874
- _LOGGER.warning("🌙 Se usaron datos en caché por error de cálculo.")
3023
+ if cached_data and "actualitzat" in cached_data and "dades" in cached_data:
3024
+ update_str = cached_data["actualitzat"]["dataUpdate"]
3025
+ try:
3026
+ update_dt = datetime.fromisoformat(update_str)
3027
+ local_dt = update_dt.astimezone(ZoneInfo(self.timezone_str))
3028
+ display_time = local_dt.strftime("%d/%m/%Y %H:%M")
3029
+ except (ValueError, TypeError):
3030
+ display_time = update_str.split("T")[0]
3031
+
3032
+ moonrise = cached_data["dades"][0].get("moonrise", "unknown")
3033
+ moonset = cached_data["dades"][0].get("moonset", "unkwnown")
3034
+ phase = cached_data["dades"][0].get("moon_phase_name", "unknown")
3035
+
3036
+ _LOGGER.warning(
3037
+ "LUNA: Cálculo falló → usando caché local:\n"
3038
+ " • Archivo: %s\n"
3039
+ " • Última actualización: %s\n"
3040
+ " • Fase: %s\n"
3041
+ " • Salida: %s\n"
3042
+ " • Atardecer: %s",
3043
+ self.moon_file.name,
3044
+ display_time,
3045
+ phase.title().replace("_", " "),
3046
+ moonrise.split("T")[1][:5] if "T" in moonrise else "—",
3047
+ moonset.split("T")[1][:5] if "T" in moonset else "—"
3048
+ )
3049
+
3050
+ self.async_set_updated_data({
3051
+ "actualizado": cached_data["actualitzat"]["dataUpdate"]
3052
+ })
2875
3053
  return {"actualizado": cached_data["actualitzat"]["dataUpdate"]}
2876
- _LOGGER.error("🌙 No se pudo calcular ni cargar datos en caché de luna.")
2877
- return None
3054
+
3055
+ _LOGGER.error("LUNA: No hay caché disponible. Sin datos lunares.")
3056
+ self.async_set_updated_data({})
3057
+ return {}
2878
3058
 
2879
3059
  class MeteocatMoonFileCoordinator(BaseFileCoordinator):
2880
3060
  """Coordinator para manejar la actualización de los datos de la luna desde moon_{town_id}.json."""