meteocat 3.0.0 → 3.1.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.
@@ -12,6 +12,8 @@ import voluptuous as vol
12
12
  import aiofiles
13
13
  import unicodedata
14
14
 
15
+ from astral import LocationInfo
16
+ from astral.sun import sun
15
17
  from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
16
18
  from homeassistant.core import callback
17
19
  from homeassistant.exceptions import HomeAssistantError
@@ -39,8 +41,8 @@ from .const import (
39
41
  LIMIT_XEMA,
40
42
  LIMIT_PREDICCIO,
41
43
  LIMIT_XDDE,
42
- LIMIT_BASIC,
43
44
  LIMIT_QUOTA,
45
+ LIMIT_BASIC,
44
46
  )
45
47
 
46
48
  from .options_flow import MeteocatOptionsFlowHandler
@@ -172,7 +174,7 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
172
174
  "Archivo regional %s creado con plantilla inicial", alerts_region_file
173
175
  )
174
176
 
175
- # Archivo lightning regional
177
+ # Archivo lightning regional
176
178
  lightning_file = alerts_dir / f"lightning_{self.region_id}.json"
177
179
  if not lightning_file.exists():
178
180
  async with aiofiles.open(lightning_file, "w", encoding="utf-8") as file:
@@ -183,6 +185,51 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
183
185
  "Archivo lightning %s creado con plantilla inicial", lightning_file
184
186
  )
185
187
 
188
+ async def create_sun_file(self):
189
+ """Crea el archivo sun_{town_id}_data.json con datos iniciales de sunrise y sunset."""
190
+ if not self.selected_municipi or not self.latitude or not self.longitude:
191
+ _LOGGER.warning("No se puede crear sun_{town_id}_data.json: faltan municipio o coordenadas")
192
+ return
193
+
194
+ town_id = self.selected_municipi["codi"]
195
+ files_dir = get_storage_dir(self.hass, "files")
196
+ sun_file = files_dir / f"sun_{town_id}_data.json"
197
+
198
+ if not sun_file.exists():
199
+ try:
200
+ # Crear objeto LocationInfo con las coordenadas y zona horaria
201
+ location = LocationInfo(
202
+ name=self.selected_municipi["nom"],
203
+ region="Catalonia",
204
+ timezone="Europe/Madrid",
205
+ latitude=self.latitude,
206
+ longitude=self.longitude
207
+ )
208
+
209
+ # Calcular sunrise y sunset para el día actual
210
+ current_time = datetime.now(timezone.utc).astimezone(TIMEZONE)
211
+ sun_data = sun(location.observer, date=current_time.date(), tzinfo=TIMEZONE)
212
+
213
+ # Formatear los datos para el archivo
214
+ sun_data_formatted = {
215
+ "actualitzat": {"dataUpdate": current_time.isoformat()},
216
+ "dades": [
217
+ {
218
+ "sunrise": sun_data["sunrise"].isoformat(),
219
+ "sunset": sun_data["sunset"].isoformat(),
220
+ "date": current_time.date().isoformat()
221
+ }
222
+ ]
223
+ }
224
+
225
+ # Guardar el archivo
226
+ async with aiofiles.open(sun_file, "w", encoding="utf-8") as file:
227
+ await file.write(json.dumps(sun_data_formatted, ensure_ascii=False, indent=4))
228
+ _LOGGER.info("Archivo sun_%s_data.json creado con datos iniciales", town_id)
229
+
230
+ except Exception as ex:
231
+ _LOGGER.error("Error al crear sun_%s_data.json: %s", town_id, ex)
232
+
186
233
  async def async_step_user(
187
234
  self, user_input: dict[str, Any] | None = None
188
235
  ) -> ConfigFlowResult:
@@ -319,7 +366,9 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
319
366
  self.province_name = station_metadata.get("provincia", {}).get("nom", "")
320
367
  self.station_status = station_metadata.get("estats", [{}])[0].get("codi", "")
321
368
 
369
+ # Crear archivos de alertas y sun
322
370
  await self.create_alerts_file()
371
+ await self.create_sun_file()
323
372
  return await self.async_step_set_api_limits()
324
373
  except Exception as ex:
325
374
  _LOGGER.error("Error al obtener los metadatos de la estación: %s", ex)
@@ -384,4 +433,4 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
384
433
  @callback
385
434
  def async_get_options_flow(config_entry: ConfigEntry) -> MeteocatOptionsFlowHandler:
386
435
  """Devuelve el flujo de opciones para esta configuración."""
387
- return MeteocatOptionsFlowHandler(config_entry)
436
+ return MeteocatOptionsFlowHandler(config_entry)
@@ -42,6 +42,9 @@ 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
+ SUNRISE = "sunrise"
46
+ SUNSET = "sunset"
47
+ SUN_FILE_STATUS = "sun_file_status"
45
48
 
46
49
  from homeassistant.const import Platform
47
50
 
@@ -6,11 +6,13 @@ import logging
6
6
  import asyncio
7
7
  import unicodedata
8
8
  from pathlib import Path
9
- from datetime import datetime, timedelta, timezone, time
9
+ from astral.sun import sun
10
+ from astral import LocationInfo
11
+ from datetime import date, datetime, timedelta, timezone, time
10
12
  from zoneinfo import ZoneInfo
11
13
  from typing import Dict, Any
12
14
 
13
- from homeassistant.core import HomeAssistant
15
+ from homeassistant.core import HomeAssistant, EVENT_HOMEASSISTANT_START
14
16
  from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
15
17
  from homeassistant.exceptions import ConfigEntryNotReady
16
18
  from homeassistant.components.weather import Forecast
@@ -67,6 +69,8 @@ DEFAULT_QUOTES_UPDATE_INTERVAL = timedelta(minutes=10)
67
69
  DEFAULT_QUOTES_FILE_UPDATE_INTERVAL = timedelta(minutes=5)
68
70
  DEFAULT_LIGHTNING_UPDATE_INTERVAL = timedelta(minutes=10)
69
71
  DEFAULT_LIGHTNING_FILE_UPDATE_INTERVAL = timedelta(minutes=5)
72
+ DEFAULT_SUN_UPDATE_INTERVAL = timedelta(minutes=1)
73
+ DEFAULT_SUN_FILE_UPDATE_INTERVAL = timedelta(seconds=30)
70
74
 
71
75
  # Definir la zona horaria local
72
76
  TIMEZONE = ZoneInfo("Europe/Madrid")
@@ -1924,3 +1928,185 @@ class MeteocatLightningFileCoordinator(DataUpdateCoordinator):
1924
1928
  "cg+": 0,
1925
1929
  "total": 0
1926
1930
  }
1931
+
1932
+ class MeteocatSunCoordinator(DataUpdateCoordinator):
1933
+ """Coordinator para manejar la actualización de los datos de sol calculados con Astral."""
1934
+
1935
+ def __init__(
1936
+ self,
1937
+ hass: HomeAssistant,
1938
+ entry_data: dict,
1939
+ ):
1940
+ """
1941
+ Inicializa el coordinador de sol de Meteocat.
1942
+
1943
+ Args:
1944
+ hass (HomeAssistant): Instancia de Home Assistant.
1945
+ entry_data (dict): Datos de configuración obtenidos de core.config_entries.
1946
+ """
1947
+ self.latitude = entry_data.get("latitude")
1948
+ self.longitude = entry_data.get("longitude")
1949
+ self.timezone_str = hass.config.time_zone or "Europe/Madrid"
1950
+ self.town_id = entry_data.get("town_id")
1951
+
1952
+ self.location = LocationInfo(
1953
+ name=entry_data.get("town_name", "Municipio"),
1954
+ region="Spain",
1955
+ timezone=self.timezone_str,
1956
+ latitude=self.latitude,
1957
+ longitude=self.longitude,
1958
+ )
1959
+
1960
+ # Ruta persistente en /config/meteocat_files/files
1961
+ files_folder = get_storage_dir(hass, "files")
1962
+ self.sun_file = files_folder / f"sun_{self.town_id.lower()}_data.json"
1963
+
1964
+ super().__init__(
1965
+ hass,
1966
+ _LOGGER,
1967
+ name=f"{DOMAIN} Sun Coordinator",
1968
+ update_interval=DEFAULT_SUN_UPDATE_INTERVAL, # Ej. timedelta(minutes=1)
1969
+ )
1970
+
1971
+ async def _async_update_data(self) -> Dict:
1972
+ """Actualiza los datos de sol calculados o usa datos en caché según si los eventos han pasado."""
1973
+ existing_data = await load_json_from_file(self.sun_file) or {}
1974
+
1975
+ now = datetime.now(tz=ZoneInfo(self.timezone_str))
1976
+
1977
+ if not existing_data or "dades" not in existing_data or not existing_data["dades"]:
1978
+ return await self._calculate_and_save_new_data()
1979
+
1980
+ last_update_str = existing_data.get('actualitzat', {}).get('dataUpdate')
1981
+ if not last_update_str:
1982
+ return await self._calculate_and_save_new_data()
1983
+
1984
+ last_update = datetime.fromisoformat(last_update_str)
1985
+
1986
+ dades = existing_data["dades"][0]
1987
+ saved_sunrise = datetime.fromisoformat(dades["sunrise"])
1988
+ saved_sunset = datetime.fromisoformat(dades["sunset"])
1989
+
1990
+ # Verificar si los datos necesitan actualización
1991
+ if now > saved_sunrise or now > saved_sunset:
1992
+ return await self._calculate_and_save_new_data()
1993
+ else:
1994
+ _LOGGER.debug("Usando datos existentes de sol: %s", existing_data)
1995
+ return {"actualizado": existing_data['actualitzat']['dataUpdate']}
1996
+
1997
+ async def _calculate_and_save_new_data(self):
1998
+ """Calcula nuevos datos de sol y los guarda en el archivo JSON."""
1999
+ try:
2000
+ now = datetime.now(tz=ZoneInfo(self.timezone_str))
2001
+ today = now.date()
2002
+
2003
+ sun_data_today = sun(self.location.observer, date=today, tzinfo=ZoneInfo(self.timezone_str))
2004
+ sunrise = sun_data_today["sunrise"]
2005
+ sunset = sun_data_today["sunset"]
2006
+
2007
+ if now > sunset:
2008
+ next_day = today + timedelta(days=1)
2009
+ sun_data_next = sun(self.location.observer, date=next_day, tzinfo=ZoneInfo(self.timezone_str))
2010
+ sunrise = sun_data_next["sunrise"]
2011
+ sunset = sun_data_next["sunset"]
2012
+ elif now > sunrise:
2013
+ next_day = today + timedelta(days=1)
2014
+ sun_data_next = sun(self.location.observer, date=next_day, tzinfo=ZoneInfo(self.timezone_str))
2015
+ sunrise = sun_data_next["sunrise"]
2016
+ # sunset permanece como el de hoy
2017
+
2018
+ # Estructurar los datos en el formato correcto
2019
+ current_time = now.isoformat()
2020
+ data_with_timestamp = {
2021
+ "actualitzat": {
2022
+ "dataUpdate": current_time
2023
+ },
2024
+ "dades": [
2025
+ {
2026
+ "sunrise": sunrise.isoformat(),
2027
+ "sunset": sunset.isoformat()
2028
+ }
2029
+ ]
2030
+ }
2031
+
2032
+ # Guardar los datos en un archivo JSON
2033
+ await save_json_to_file(data_with_timestamp, self.sun_file)
2034
+
2035
+ _LOGGER.debug("Datos de sol actualizados exitosamente: %s", data_with_timestamp)
2036
+
2037
+ return {"actualizado": data_with_timestamp['actualitzat']['dataUpdate']}
2038
+
2039
+ except Exception as err:
2040
+ _LOGGER.exception("Error inesperado al calcular los datos de sol: %s", err)
2041
+
2042
+ # Intentar cargar datos en caché si falla el cálculo (aunque es improbable)
2043
+ cached_data = await load_json_from_file(self.sun_file)
2044
+ if cached_data:
2045
+ _LOGGER.warning("Usando datos en caché para los datos de sol.")
2046
+ return {"actualizado": cached_data['actualitzat']['dataUpdate']}
2047
+
2048
+ _LOGGER.error("No se pudo calcular datos actualizados ni cargar datos en caché.")
2049
+ return None
2050
+
2051
+ class MeteocatSunFileCoordinator(DataUpdateCoordinator):
2052
+ """Coordinator para manejar la actualización de los datos de sol desde sun_{town_id}.json."""
2053
+
2054
+ def __init__(
2055
+ self,
2056
+ hass: HomeAssistant,
2057
+ entry_data: dict,
2058
+ ):
2059
+ """
2060
+ Inicializa el coordinador de sol desde archivo.
2061
+
2062
+ Args:
2063
+ hass (HomeAssistant): Instancia de Home Assistant.
2064
+ entry_data (dict): Datos de configuración de la entrada.
2065
+ """
2066
+ self.town_id = entry_data["town_id"]
2067
+ self.timezone_str = hass.config.time_zone or "Europe/Madrid"
2068
+
2069
+ # Ruta persistente en /config/meteocat_files/files
2070
+ files_folder = get_storage_dir(hass, "files")
2071
+ self.sun_file = files_folder / f"sun_{self.town_id.lower()}_data.json"
2072
+
2073
+ super().__init__(
2074
+ hass,
2075
+ _LOGGER,
2076
+ name="Meteocat Sun File Coordinator",
2077
+ update_interval=DEFAULT_SUN_FILE_UPDATE_INTERVAL, # Ej. timedelta(seconds=30)
2078
+ )
2079
+
2080
+ async def _async_update_data(self) -> Dict[str, Any]:
2081
+ """Carga los datos de sol desde el archivo JSON y procesa la información."""
2082
+ existing_data = await load_json_from_file(self.sun_file)
2083
+
2084
+ if not existing_data or "dades" not in existing_data or not existing_data["dades"]:
2085
+ _LOGGER.warning("No se encontraron datos en %s.", self.sun_file)
2086
+ return self._reset_data()
2087
+
2088
+ update_date_str = existing_data.get("actualitzat", {}).get("dataUpdate", "")
2089
+ update_date = datetime.fromisoformat(update_date_str) if update_date_str else None
2090
+ now = datetime.now(ZoneInfo(self.timezone_str))
2091
+
2092
+ dades = existing_data["dades"][0]
2093
+ saved_sunrise = datetime.fromisoformat(dades["sunrise"])
2094
+ saved_sunset = datetime.fromisoformat(dades["sunset"])
2095
+
2096
+ if saved_sunrise < now and saved_sunset < now:
2097
+ _LOGGER.info("Los datos de sol están caducados. Reiniciando valores.")
2098
+ return self._reset_data()
2099
+ else:
2100
+ return {
2101
+ "actualizado": update_date.isoformat() if update_date else now.isoformat(),
2102
+ "sunrise": dades.get("sunrise"),
2103
+ "sunset": dades.get("sunset")
2104
+ }
2105
+
2106
+ def _reset_data(self):
2107
+ """Resetea los datos a valores nulos."""
2108
+ return {
2109
+ "actualizado": datetime.now(ZoneInfo(self.timezone_str)).isoformat(),
2110
+ "sunrise": None,
2111
+ "sunset": None
2112
+ }
@@ -20,5 +20,5 @@
20
20
  "packaging>=20.3",
21
21
  "wrapt>=1.14.0"
22
22
  ],
23
- "version": "3.0.0"
23
+ "version": "3.1.0"
24
24
  }
@@ -13,8 +13,11 @@ from .const import (
13
13
  LIMIT_PREDICCIO,
14
14
  LIMIT_XDDE,
15
15
  LIMIT_QUOTA,
16
- LIMIT_BASIC
16
+ LIMIT_BASIC,
17
+ LATITUDE,
18
+ LONGITUDE,
17
19
  )
20
+
18
21
  from meteocatpy.town import MeteocatTown
19
22
  from meteocatpy.exceptions import (
20
23
  BadRequestError,
@@ -35,6 +38,11 @@ class MeteocatOptionsFlowHandler(OptionsFlow):
35
38
  self.api_key: str | None = None
36
39
  self.limit_xema: int | None = None
37
40
  self.limit_prediccio: int | None = None
41
+ self.limit_xdde: int | None = None
42
+ self.limit_quota: int | None = None
43
+ self.limit_basic: int | None = None
44
+ self.latitude: float | None = None
45
+ self.longitude: float | None = None
38
46
 
39
47
  async def async_step_init(self, user_input: dict | None = None):
40
48
  """Paso inicial del flujo de opciones."""
@@ -45,6 +53,8 @@ class MeteocatOptionsFlowHandler(OptionsFlow):
45
53
  return await self.async_step_update_limits_only()
46
54
  elif user_input["option"] == "regenerate_assets":
47
55
  return await self.async_step_confirm_regenerate_assets()
56
+ elif user_input["option"] == "update_coordinates":
57
+ return await self.async_step_update_coordinates()
48
58
 
49
59
  return self.async_show_form(
50
60
  step_id="init",
@@ -54,7 +64,8 @@ class MeteocatOptionsFlowHandler(OptionsFlow):
54
64
  options=[
55
65
  "update_api_and_limits",
56
66
  "update_limits_only",
57
- "regenerate_assets"
67
+ "regenerate_assets",
68
+ "update_coordinates" # Nueva opción
58
69
  ],
59
70
  translation_key="option"
60
71
  )
@@ -177,7 +188,54 @@ class MeteocatOptionsFlowHandler(OptionsFlow):
177
188
  return self.async_show_form(
178
189
  step_id="update_limits_only", data_schema=schema, errors=errors
179
190
  )
180
-
191
+
192
+ async def async_step_update_coordinates(self, user_input: dict | None = None):
193
+ """Permite al usuario actualizar las coordenadas (latitude, longitude)."""
194
+ errors = {}
195
+
196
+ if user_input is not None:
197
+ self.latitude = user_input.get(LATITUDE)
198
+ self.longitude = user_input.get(LONGITUDE)
199
+
200
+ # Validar que las coordenadas estén dentro del rango de Cataluña
201
+ if not (40.5 <= self.latitude <= 42.5 and 0.1 <= self.longitude <= 3.3):
202
+ _LOGGER.error(
203
+ "Coordenadas fuera del rango de Cataluña (latitude: %s, longitude: %s).",
204
+ self.latitude, self.longitude
205
+ )
206
+ errors["base"] = "invalid_coordinates"
207
+ else:
208
+ # Actualizar la configuración con las nuevas coordenadas
209
+ self.hass.config_entries.async_update_entry(
210
+ self._config_entry,
211
+ data={
212
+ **self._config_entry.data,
213
+ LATITUDE: self.latitude,
214
+ LONGITUDE: self.longitude
215
+ },
216
+ )
217
+ # Recargar la integración para aplicar los cambios
218
+ await self.hass.config_entries.async_reload(self._config_entry.entry_id)
219
+ _LOGGER.info(
220
+ "Coordenadas actualizadas a latitude: %s, longitude: %s",
221
+ self.latitude, self.longitude
222
+ )
223
+ return self.async_create_entry(title="", data={})
224
+
225
+ schema = vol.Schema({
226
+ vol.Required(LATITUDE, default=self._config_entry.data.get(LATITUDE)): cv.latitude,
227
+ vol.Required(LONGITUDE, default=self._config_entry.data.get(LONGITUDE)): cv.longitude,
228
+ })
229
+ return self.async_show_form(
230
+ step_id="update_coordinates",
231
+ data_schema=schema,
232
+ errors=errors,
233
+ description_placeholders={
234
+ "current_latitude": self._config_entry.data.get(LATITUDE),
235
+ "current_longitude": self._config_entry.data.get(LONGITUDE)
236
+ }
237
+ )
238
+
181
239
  async def async_step_confirm_regenerate_assets(self, user_input: dict | None = None):
182
240
  """Confirma si el usuario realmente quiere regenerar los assets."""
183
241
  if user_input is not None: