meteocat 3.0.0 → 3.2.0

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 (40) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +8 -2
  2. package/.github/ISSUE_TEMPLATE/config.yml +7 -0
  3. package/.github/ISSUE_TEMPLATE/improvement.md +39 -0
  4. package/.github/ISSUE_TEMPLATE/new_function.md +41 -0
  5. package/.github/labels.yml +63 -0
  6. package/.github/workflows/autocloser.yaml +11 -9
  7. package/.github/workflows/close-on-label.yml +48 -0
  8. package/.github/workflows/force-sync-labels.yml +18 -0
  9. package/.github/workflows/sync-gitlab.yml +15 -4
  10. package/.github/workflows/sync-labels.yml +21 -0
  11. package/CHANGELOG.md +80 -11
  12. package/README.md +16 -4
  13. package/custom_components/meteocat/__init__.py +57 -42
  14. package/custom_components/meteocat/condition.py +6 -2
  15. package/custom_components/meteocat/config_flow.py +231 -4
  16. package/custom_components/meteocat/const.py +17 -2
  17. package/custom_components/meteocat/coordinator.py +1122 -101
  18. package/custom_components/meteocat/helpers.py +31 -36
  19. package/custom_components/meteocat/manifest.json +3 -2
  20. package/custom_components/meteocat/options_flow.py +71 -3
  21. package/custom_components/meteocat/sensor.py +660 -247
  22. package/custom_components/meteocat/strings.json +252 -15
  23. package/custom_components/meteocat/translations/ca.json +249 -13
  24. package/custom_components/meteocat/translations/en.json +252 -15
  25. package/custom_components/meteocat/translations/es.json +252 -15
  26. package/custom_components/meteocat/version.py +1 -1
  27. package/filetree.txt +12 -3
  28. package/hacs.json +1 -1
  29. package/images/daily_forecast_2_alerts.png +0 -0
  30. package/images/daily_forecast_no_alerts.png +0 -0
  31. package/images/diagnostic_sensors.png +0 -0
  32. package/images/dynamic_sensors.png +0 -0
  33. package/images/options.png +0 -0
  34. package/images/regenerate_assets.png +0 -0
  35. package/images/setup_options.png +0 -0
  36. package/images/system_options.png +0 -0
  37. package/package.json +1 -1
  38. package/pyproject.toml +1 -1
  39. package/scripts/update_version.sh +6 -0
  40. package/.github/workflows/close-duplicates.yml +0 -57
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from datetime import datetime
4
+ from typing import Any
4
5
  from .const import CONDITION_MAPPING
5
6
  from .helpers import is_night
6
7
  import logging
@@ -8,7 +9,10 @@ import logging
8
9
  _LOGGER = logging.getLogger(__name__)
9
10
 
10
11
  def get_condition_from_statcel(
11
- codi_estatcel, current_time: datetime, hass, is_hourly: bool = True
12
+ codi_estatcel: Any,
13
+ current_time: datetime,
14
+ location,
15
+ is_hourly: bool = True
12
16
  ) -> dict:
13
17
  """
14
18
  Convierte el código 'estatCel' en condición de Home Assistant.
@@ -33,7 +37,7 @@ def get_condition_from_statcel(
33
37
  codi_estatcel = [codi_estatcel]
34
38
 
35
39
  # Determinar si es de noche
36
- is_night_flag = is_night(current_time, hass)
40
+ is_night_flag = is_night(current_time, location)
37
41
 
38
42
  # Identificar la condición basada en el código
39
43
  for condition, codes in CONDITION_MAPPING.items():
@@ -5,13 +5,25 @@ import json
5
5
  import logging
6
6
  from pathlib import Path
7
7
  from typing import Any
8
- from datetime import datetime, timezone
8
+ from datetime import date, datetime, timezone, timedelta
9
9
  from zoneinfo import ZoneInfo
10
10
 
11
11
  import voluptuous as vol
12
12
  import aiofiles
13
13
  import unicodedata
14
14
 
15
+ from solarmoonpy.location import Location, LocationInfo
16
+ from solarmoonpy.moon import (
17
+ moon_phase,
18
+ moon_day,
19
+ moon_rise_set,
20
+ illuminated_percentage,
21
+ moon_distance,
22
+ moon_angular_diameter,
23
+ lunation_number,
24
+ get_moon_phase_name,
25
+ get_lunation_duration
26
+ )
15
27
  from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
16
28
  from homeassistant.core import callback
17
29
  from homeassistant.exceptions import HomeAssistantError
@@ -39,8 +51,8 @@ from .const import (
39
51
  LIMIT_XEMA,
40
52
  LIMIT_PREDICCIO,
41
53
  LIMIT_XDDE,
42
- LIMIT_BASIC,
43
54
  LIMIT_QUOTA,
55
+ LIMIT_BASIC,
44
56
  )
45
57
 
46
58
  from .options_flow import MeteocatOptionsFlowHandler
@@ -88,6 +100,8 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
88
100
  self.longitude: float | None = None
89
101
  self.altitude: float | None = None
90
102
  self.station_status: str | None = None
103
+ self.location: Location | None = None
104
+ self.timezone_str: str | None = None
91
105
 
92
106
  async def fetch_and_save_quotes(self, api_key: str):
93
107
  """Obtiene las cuotas de la API de Meteocat y las guarda en quotes.json."""
@@ -172,7 +186,7 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
172
186
  "Archivo regional %s creado con plantilla inicial", alerts_region_file
173
187
  )
174
188
 
175
- # Archivo lightning regional
189
+ # Archivo lightning regional
176
190
  lightning_file = alerts_dir / f"lightning_{self.region_id}.json"
177
191
  if not lightning_file.exists():
178
192
  async with aiofiles.open(lightning_file, "w", encoding="utf-8") as file:
@@ -183,6 +197,216 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
183
197
  "Archivo lightning %s creado con plantilla inicial", lightning_file
184
198
  )
185
199
 
200
+ async def create_sun_file(self):
201
+ """Crea el archivo sun_{town_id}_data.json con eventos solares + posición inicial del sol."""
202
+ if not self.selected_municipi or self.latitude is None or self.longitude is None:
203
+ _LOGGER.warning("No se puede crear sun_{town_id}_data.json: faltan municipio o coordenadas")
204
+ return
205
+
206
+ town_id = self.selected_municipi["codi"]
207
+ files_dir = get_storage_dir(self.hass, "files")
208
+ sun_file = files_dir / f"sun_{town_id.lower()}_data.json"
209
+
210
+ if sun_file.exists():
211
+ _LOGGER.debug("El archivo %s ya existe, no se crea de nuevo.", sun_file)
212
+ return
213
+
214
+ try:
215
+ # ZONA HORARIA DEL HASS
216
+ self.timezone_str = self.hass.config.time_zone or "Europe/Madrid"
217
+ tz = ZoneInfo(self.timezone_str)
218
+
219
+ # CREAR UBICACIÓN
220
+ self.location = Location(LocationInfo(
221
+ name=self.selected_municipi.get("nom", "Municipio"),
222
+ region="Spain",
223
+ timezone=self.timezone_str,
224
+ latitude=self.latitude,
225
+ longitude=self.longitude,
226
+ elevation=self.altitude or 0.0,
227
+ ))
228
+
229
+ now = datetime.now(tz)
230
+ today = now.date()
231
+ tomorrow = today + timedelta(days=1)
232
+
233
+ # EVENTOS HOY Y MAÑANA
234
+ events_today = self.location.sun_events(date=today, local=True)
235
+ events_tomorrow = self.location.sun_events(date=tomorrow, local=True)
236
+
237
+ # LÓGICA DE EVENTOS (igual que en el coordinador)
238
+ expected = {}
239
+ events_list = [
240
+ "dawn_astronomical", "dawn_nautical", "dawn_civil",
241
+ "sunrise", "noon", "sunset",
242
+ "dusk_civil", "dusk_nautical", "dusk_astronomical",
243
+ "midnight",
244
+ ]
245
+
246
+ for event in events_list:
247
+ event_time = events_today.get(event)
248
+ if event_time and now >= event_time:
249
+ expected[event] = events_tomorrow.get(event)
250
+ else:
251
+ expected[event] = event_time
252
+
253
+ # daylight_duration según sunrise
254
+ sunrise = expected["sunrise"]
255
+ expected["daylight_duration"] = (
256
+ events_tomorrow["daylight_duration"]
257
+ if sunrise == events_tomorrow["sunrise"]
258
+ else events_today["daylight_duration"]
259
+ )
260
+
261
+ # POSICIÓN ACTUAL DEL SOL
262
+ sun_pos = self.location.sun_position(dt=now, local=True)
263
+
264
+ # CONSTRUIR DADES
265
+ dades_dict = {
266
+ "dawn_civil": expected["dawn_civil"].isoformat() if expected["dawn_civil"] else None,
267
+ "dawn_nautical": expected["dawn_nautical"].isoformat() if expected["dawn_nautical"] else None,
268
+ "dawn_astronomical": expected["dawn_astronomical"].isoformat() if expected["dawn_astronomical"] else None,
269
+ "sunrise": expected["sunrise"].isoformat() if expected["sunrise"] else None,
270
+ "noon": expected["noon"].isoformat() if expected["noon"] else None,
271
+ "sunset": expected["sunset"].isoformat() if expected["sunset"] else None,
272
+ "dusk_civil": expected["dusk_civil"].isoformat() if expected["dusk_civil"] else None,
273
+ "dusk_nautical": expected["dusk_nautical"].isoformat() if expected["dusk_nautical"] else None,
274
+ "dusk_astronomical": expected["dusk_astronomical"].isoformat() if expected["dusk_astronomical"] else None,
275
+ "midnight": expected["midnight"].isoformat() if expected["midnight"] else None,
276
+ "daylight_duration": expected["daylight_duration"],
277
+
278
+ # CAMPOS DE POSICIÓN SOLAR
279
+ "sun_elevation": round(sun_pos["elevation"], 2),
280
+ "sun_azimuth": round(sun_pos["azimuth"], 2),
281
+ "sun_horizon_position": sun_pos["horizon_position"],
282
+ "sun_rising": sun_pos["rising"],
283
+ "sun_position_updated": now.isoformat(),
284
+ }
285
+
286
+ # JSON FINAL
287
+ data_with_timestamp = {
288
+ "actualitzat": {"dataUpdate": now.isoformat()},
289
+ "dades": [dades_dict],
290
+ }
291
+
292
+ # GUARDAR
293
+ sun_file.parent.mkdir(parents=True, exist_ok=True)
294
+ async with aiofiles.open(sun_file, "w", encoding="utf-8") as file:
295
+ await file.write(json.dumps(data_with_timestamp, ensure_ascii=False, indent=4))
296
+
297
+ _LOGGER.info(
298
+ "Archivo sun_%s_data.json creado con eventos + posición solar inicial (elev=%.2f°, az=%.2f°)",
299
+ town_id, sun_pos["elevation"], sun_pos["azimuth"]
300
+ )
301
+
302
+ except Exception as ex:
303
+ _LOGGER.error("Error al crear sun_%s_data.json: %s", town_id, ex)
304
+
305
+ async def create_moon_file(self):
306
+ """Crea el archivo moon_{town_id}_data.json con datos iniciales de la fase lunar, moonrise y moonset."""
307
+ if not self.selected_municipi or not self.latitude or not self.longitude:
308
+ _LOGGER.warning("No se puede crear moon_{town_id}_data.json: faltan municipio o coordenadas")
309
+ return
310
+
311
+ town_id = self.selected_municipi["codi"]
312
+ files_dir = get_storage_dir(self.hass, "files")
313
+ moon_file = files_dir / f"moon_{town_id}_data.json"
314
+
315
+ if not moon_file.exists():
316
+ try:
317
+ # Fecha actual en UTC
318
+ current_time = datetime.now(timezone.utc).astimezone(TIMEZONE)
319
+ today = current_time.date()
320
+
321
+ # Inicializar parámetros con valores por defecto
322
+ phase = None
323
+ moon_day_today = None
324
+ lunation = None
325
+ illuminated = None
326
+ distance = None
327
+ angular_diameter = None
328
+ moon_phase_name = None
329
+ lunation_duration = None
330
+
331
+ # Calcular parámetros con manejo de errores individual
332
+ try:
333
+ phase = round(moon_phase(today), 2)
334
+ except Exception as ex:
335
+ _LOGGER.error("Error al calcular moon_phase: %s", ex)
336
+
337
+ try:
338
+ moon_day_today = moon_day(today)
339
+ except Exception as ex:
340
+ _LOGGER.error("Error al calcular moon_day: %s", ex)
341
+
342
+ try:
343
+ lunation = lunation_number(today)
344
+ except Exception as ex:
345
+ _LOGGER.error("Error al calcular lunation_number: %s", ex)
346
+
347
+ try:
348
+ illuminated = round(illuminated_percentage(today), 2)
349
+ except Exception as ex:
350
+ _LOGGER.error("Error al calcular illuminated_percentage: %s", ex)
351
+
352
+ try:
353
+ distance = round(moon_distance(today), 0)
354
+ except Exception as ex:
355
+ _LOGGER.error("Error al calcular moon_distance: %s", ex)
356
+
357
+ try:
358
+ angular_diameter = round(moon_angular_diameter(today), 2)
359
+ except Exception as ex:
360
+ _LOGGER.error("Error al calcular moon_angular_diameter: %s", ex)
361
+
362
+ try:
363
+ moon_phase_name = get_moon_phase_name(today)
364
+ except Exception as ex:
365
+ _LOGGER.error("Error al calcular moon_phase_name: %s", ex)
366
+
367
+ try:
368
+ lunation_duration = get_lunation_duration(today)
369
+ except Exception as ex:
370
+ _LOGGER.error("Error al calcular lunation_duration: %s", ex)
371
+
372
+ # Moonrise y moonset aproximados (UTC)
373
+ try:
374
+ rise_utc, set_utc = moon_rise_set(self.latitude, self.longitude, today)
375
+ rise_local = rise_utc.astimezone(TIMEZONE).isoformat() if rise_utc else None
376
+ set_local = set_utc.astimezone(TIMEZONE).isoformat() if set_utc else None
377
+ except Exception as ex:
378
+ _LOGGER.error("Error al calcular moon_rise_set: %s", ex)
379
+ rise_local = None
380
+ set_local = None
381
+
382
+ # Formatear datos para guardar
383
+ moon_data_formatted = {
384
+ "actualitzat": {"dataUpdate": current_time.isoformat()},
385
+ "last_lunar_update_date": today.isoformat(),
386
+ "dades": [
387
+ {
388
+ "moon_day": moon_day_today,
389
+ "moon_phase": phase,
390
+ "moon_phase_name": moon_phase_name,
391
+ "illuminated_percentage": illuminated,
392
+ "moon_distance": distance,
393
+ "moon_angular_diameter": angular_diameter,
394
+ "lunation": lunation,
395
+ "lunation_duration": lunation_duration,
396
+ "moonrise": rise_local,
397
+ "moonset": set_local
398
+ }
399
+ ]
400
+ }
401
+
402
+ # Guardar el archivo
403
+ async with aiofiles.open(moon_file, "w", encoding="utf-8") as file:
404
+ await file.write(json.dumps(moon_data_formatted, ensure_ascii=False, indent=4))
405
+ _LOGGER.info("Archivo moon_%s_data.json creado con datos iniciales", town_id)
406
+
407
+ except Exception as ex:
408
+ _LOGGER.error("Error general al crear moon_%s_data.json: %s", town_id, ex)
409
+
186
410
  async def async_step_user(
187
411
  self, user_input: dict[str, Any] | None = None
188
412
  ) -> ConfigFlowResult:
@@ -319,7 +543,10 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
319
543
  self.province_name = station_metadata.get("provincia", {}).get("nom", "")
320
544
  self.station_status = station_metadata.get("estats", [{}])[0].get("codi", "")
321
545
 
546
+ # Crear archivos de alertas, sol y luna
322
547
  await self.create_alerts_file()
548
+ await self.create_sun_file()
549
+ await self.create_moon_file()
323
550
  return await self.async_step_set_api_limits()
324
551
  except Exception as ex:
325
552
  _LOGGER.error("Error al obtener los metadatos de la estación: %s", ex)
@@ -384,4 +611,4 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
384
611
  @callback
385
612
  def async_get_options_flow(config_entry: ConfigEntry) -> MeteocatOptionsFlowHandler:
386
613
  """Devuelve el flujo de opciones para esta configuración."""
387
- return MeteocatOptionsFlowHandler(config_entry)
614
+ return MeteocatOptionsFlowHandler(config_entry)
@@ -42,17 +42,29 @@ QUOTA_BASIC = "quota_basic"
42
42
  QUOTA_XEMA = "quota_xema"
43
43
  QUOTA_QUERIES = "quota_queries"
44
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"
45
53
 
46
54
  from homeassistant.const import Platform
47
55
 
48
- ATTRIBUTION = "Powered by Meteocatpy"
56
+ ATTRIBUTION = "Powered by Meteocatpy & Solarmoonpy"
49
57
  PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
50
58
  DEFAULT_NAME = "METEOCAT"
51
59
 
52
60
  # Tiempos para validación de API
53
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
54
- DEFAULT_VALIDITY_HOURS = 5 # Hora a partir de la cual la API tiene la información actualizada de predicciones disponible para descarga
62
+ DEFAULT_VALIDITY_HOURS = 6 # Hora a partir de la cual la API tiene la información actualizada de predicciones disponible para descarga
55
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
56
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
57
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
58
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
@@ -65,6 +77,9 @@ ALERT_VALIDITY_MULTIPLIER_200 = 6 # para 100 < limit_prediccio <= 200
65
77
  ALERT_VALIDITY_MULTIPLIER_500 = 3 # para 200 < limit_prediccio <= 500
66
78
  ALERT_VALIDITY_MULTIPLIER_DEFAULT = 1 # para limit_prediccio > 500
67
79
 
80
+ # CUOTA ALTA PARA FAVORECER ACTUALIZACIONES DIARIAS DE LAS PREDICCIONES
81
+ PREDICCIO_HIGH_QUOTA_LIMIT = 550
82
+
68
83
  # Códigos de sensores de la API
69
84
  WIND_SPEED = "wind_speed" # Velocidad del viento
70
85
  WIND_DIRECTION = "wind_direction" # Dirección del viento