meteocat 2.2.7 → 3.0.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.
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import os
4
3
  import asyncio
5
4
  import json
6
5
  import logging
@@ -18,6 +17,7 @@ from homeassistant.core import callback
18
17
  from homeassistant.exceptions import HomeAssistantError
19
18
  from homeassistant.helpers import config_validation as cv
20
19
 
20
+ from .helpers import get_storage_dir
21
21
  from .const import (
22
22
  DOMAIN,
23
23
  CONF_API_KEY,
@@ -40,7 +40,7 @@ from .const import (
40
40
  LIMIT_PREDICCIO,
41
41
  LIMIT_XDDE,
42
42
  LIMIT_BASIC,
43
- LIMIT_QUOTA
43
+ LIMIT_QUOTA,
44
44
  )
45
45
 
46
46
  from .options_flow import MeteocatOptionsFlowHandler
@@ -53,6 +53,8 @@ from meteocatpy.quotes import MeteocatQuotes
53
53
  from meteocatpy.exceptions import BadRequestError, ForbiddenError, TooManyRequestsError, InternalServerError, UnknownAPIError
54
54
 
55
55
  _LOGGER = logging.getLogger(__name__)
56
+
57
+ # Definir la zona horaria local
56
58
  TIMEZONE = ZoneInfo("Europe/Madrid")
57
59
 
58
60
  INITIAL_TEMPLATE = {
@@ -65,7 +67,6 @@ def normalize_name(name: str) -> str:
65
67
  name = unicodedata.normalize("NFKD", name).encode("ASCII", "ignore").decode("utf-8")
66
68
  return name.lower()
67
69
 
68
-
69
70
  class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
70
71
  """Flujo de configuración para Meteocat."""
71
72
 
@@ -91,9 +92,8 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
91
92
  async def fetch_and_save_quotes(self, api_key: str):
92
93
  """Obtiene las cuotas de la API de Meteocat y las guarda en quotes.json."""
93
94
  meteocat_quotes = MeteocatQuotes(api_key)
94
- quotes_dir = os.path.join(self.hass.config.path(), "custom_components", "meteocat", "files")
95
- os.makedirs(quotes_dir, exist_ok=True)
96
- quotes_file = os.path.join(quotes_dir, "quotes.json")
95
+ quotes_dir = get_storage_dir(self.hass, "files")
96
+ quotes_file = quotes_dir / "quotes.json"
97
97
 
98
98
  try:
99
99
  data = await asyncio.wait_for(meteocat_quotes.get_quotes(), timeout=30)
@@ -103,30 +103,43 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
103
103
  "prediccio_": "Prediccio",
104
104
  "referencia basic": "Basic",
105
105
  "xema_": "XEMA",
106
- "quota": "Quota"
106
+ "quota": "Quota",
107
107
  }
108
108
 
109
109
  modified_plans = []
110
110
  for plan in data["plans"]:
111
111
  normalized_nom = normalize_name(plan["nom"])
112
- new_name = next((v for k, v in plan_mapping.items() if normalized_nom.startswith(k)), plan["nom"])
113
- modified_plans.append({
114
- "nom": new_name,
115
- "periode": plan["periode"],
116
- "maxConsultes": plan["maxConsultes"],
117
- "consultesRestants": plan["consultesRestants"],
118
- "consultesRealitzades": plan["consultesRealitzades"]
119
- })
112
+ new_name = next(
113
+ (v for k, v in plan_mapping.items() if normalized_nom.startswith(k)), None
114
+ )
115
+ if new_name is None:
116
+ _LOGGER.warning(
117
+ "Nombre de plan desconocido en la API: %s (se usará el original)",
118
+ plan["nom"],
119
+ )
120
+ new_name = plan["nom"]
121
+
122
+ modified_plans.append(
123
+ {
124
+ "nom": new_name,
125
+ "periode": plan["periode"],
126
+ "maxConsultes": plan["maxConsultes"],
127
+ "consultesRestants": plan["consultesRestants"],
128
+ "consultesRealitzades": plan["consultesRealitzades"],
129
+ }
130
+ )
120
131
 
121
132
  current_time = datetime.now(timezone.utc).astimezone(TIMEZONE).isoformat()
122
133
  data_with_timestamp = {
123
134
  "actualitzat": {"dataUpdate": current_time},
124
135
  "client": data["client"],
125
- "plans": modified_plans
136
+ "plans": modified_plans,
126
137
  }
127
138
 
128
139
  async with aiofiles.open(quotes_file, "w", encoding="utf-8") as file:
129
- await file.write(json.dumps(data_with_timestamp, ensure_ascii=False, indent=4))
140
+ await file.write(
141
+ json.dumps(data_with_timestamp, ensure_ascii=False, indent=4)
142
+ )
130
143
  _LOGGER.info("Cuotas guardadas exitosamente en %s", quotes_file)
131
144
 
132
145
  except Exception as ex:
@@ -135,38 +148,44 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
135
148
 
136
149
  async def create_alerts_file(self):
137
150
  """Crea los archivos de alertas global y regional si no existen."""
138
- alerts_dir = os.path.join(
139
- self.hass.config.path(),
140
- "custom_components",
141
- "meteocat",
142
- "files"
143
- )
144
- os.makedirs(alerts_dir, exist_ok=True)
151
+ alerts_dir = get_storage_dir(self.hass, "files")
145
152
 
146
153
  # Archivo global de alertas
147
- alerts_file = os.path.join(alerts_dir, "alerts.json")
148
- if not os.path.exists(alerts_file):
154
+ alerts_file = alerts_dir / "alerts.json"
155
+ if not alerts_file.exists():
149
156
  async with aiofiles.open(alerts_file, "w", encoding="utf-8") as file:
150
- await file.write(json.dumps(INITIAL_TEMPLATE, ensure_ascii=False, indent=4))
157
+ await file.write(
158
+ json.dumps(INITIAL_TEMPLATE, ensure_ascii=False, indent=4)
159
+ )
151
160
  _LOGGER.info("Archivo global %s creado con plantilla inicial", alerts_file)
152
161
 
153
162
  # Solo si existe region_id
154
163
  if self.region_id:
155
164
  # Archivo regional de alertas
156
- alerts_region_file = os.path.join(alerts_dir, f"alerts_{self.region_id}.json")
157
- if not os.path.exists(alerts_region_file):
165
+ alerts_region_file = alerts_dir / f"alerts_{self.region_id}.json"
166
+ if not alerts_region_file.exists():
158
167
  async with aiofiles.open(alerts_region_file, "w", encoding="utf-8") as file:
159
- await file.write(json.dumps(INITIAL_TEMPLATE, ensure_ascii=False, indent=4))
160
- _LOGGER.info("Archivo regional %s creado con plantilla inicial", alerts_region_file)
161
-
162
- # Archivo lightning regional
163
- lightning_file = os.path.join(alerts_dir, f"lightning_{self.region_id}.json")
164
- if not os.path.exists(lightning_file):
168
+ await file.write(
169
+ json.dumps(INITIAL_TEMPLATE, ensure_ascii=False, indent=4)
170
+ )
171
+ _LOGGER.info(
172
+ "Archivo regional %s creado con plantilla inicial", alerts_region_file
173
+ )
174
+
175
+ # Archivo lightning regional
176
+ lightning_file = alerts_dir / f"lightning_{self.region_id}.json"
177
+ if not lightning_file.exists():
165
178
  async with aiofiles.open(lightning_file, "w", encoding="utf-8") as file:
166
- await file.write(json.dumps(INITIAL_TEMPLATE, ensure_ascii=False, indent=4))
167
- _LOGGER.info("Archivo lightning %s creado con plantilla inicial", lightning_file)
168
-
169
- async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
179
+ await file.write(
180
+ json.dumps(INITIAL_TEMPLATE, ensure_ascii=False, indent=4)
181
+ )
182
+ _LOGGER.info(
183
+ "Archivo lightning %s creado con plantilla inicial", lightning_file
184
+ )
185
+
186
+ async def async_step_user(
187
+ self, user_input: dict[str, Any] | None = None
188
+ ) -> ConfigFlowResult:
170
189
  """Primer paso: solicitar API Key."""
171
190
  errors = {}
172
191
  if user_input is not None:
@@ -174,6 +193,15 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
174
193
  town_client = MeteocatTown(self.api_key)
175
194
  try:
176
195
  self.municipis = await town_client.get_municipis()
196
+
197
+ # Guardar lista de municipios en towns.json
198
+ assets_dir = get_storage_dir(self.hass, "assets")
199
+ towns_file = assets_dir / "towns.json"
200
+ async with aiofiles.open(towns_file, "w", encoding="utf-8") as file:
201
+ await file.write(json.dumps({"towns": self.municipis}, ensure_ascii=False, indent=4))
202
+ _LOGGER.info("Towns guardados en %s", towns_file)
203
+
204
+ # Crea el archivo de cuotas
177
205
  await self.fetch_and_save_quotes(self.api_key)
178
206
  # Crea solo el archivo global de alertas (regional se hará después)
179
207
  await self.create_alerts_file()
@@ -186,54 +214,93 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
186
214
  schema = vol.Schema({vol.Required(CONF_API_KEY): str})
187
215
  return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
188
216
 
189
- async def async_step_select_municipi(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
217
+ async def async_step_select_municipi(
218
+ self, user_input: dict[str, Any] | None = None
219
+ ) -> ConfigFlowResult:
190
220
  """Segundo paso: seleccionar el municipio."""
191
221
  errors = {}
192
222
  if user_input is not None:
193
223
  selected_codi = user_input["municipi"]
194
- self.selected_municipi = next((m for m in self.municipis if m["codi"] == selected_codi), None)
224
+ self.selected_municipi = next(
225
+ (m for m in self.municipis if m["codi"] == selected_codi), None
226
+ )
195
227
  if self.selected_municipi:
196
228
  await self.fetch_symbols_and_variables()
197
229
 
198
230
  if self.selected_municipi:
199
231
  return await self.async_step_select_station()
200
232
 
201
- schema = vol.Schema({vol.Required("municipi"): vol.In({m["codi"]: m["nom"] for m in self.municipis})})
233
+ schema = vol.Schema(
234
+ {vol.Required("municipi"): vol.In({m["codi"]: m["nom"] for m in self.municipis})}
235
+ )
202
236
  return self.async_show_form(step_id="select_municipi", data_schema=schema, errors=errors)
203
237
 
204
238
  async def fetch_symbols_and_variables(self):
205
239
  """Descarga y guarda los símbolos y variables después de seleccionar el municipio."""
206
- assets_dir = os.path.join(self.hass.config.path(), "custom_components", "meteocat", "assets")
207
- os.makedirs(assets_dir, exist_ok=True)
208
- symbols_file = os.path.join(assets_dir, "symbols.json")
209
- variables_file = os.path.join(assets_dir, "variables.json")
240
+ assets_dir = get_storage_dir(self.hass, "assets")
241
+ symbols_file = assets_dir / "symbols.json"
242
+ variables_file = assets_dir / "variables.json"
210
243
  try:
211
244
  symbols_data = await MeteocatSymbols(self.api_key).fetch_symbols()
212
245
  async with aiofiles.open(symbols_file, "w", encoding="utf-8") as file:
213
246
  await file.write(json.dumps({"symbols": symbols_data}, ensure_ascii=False, indent=4))
247
+
214
248
  variables_data = await MeteocatVariables(self.api_key).get_variables()
215
249
  async with aiofiles.open(variables_file, "w", encoding="utf-8") as file:
216
250
  await file.write(json.dumps({"variables": variables_data}, ensure_ascii=False, indent=4))
217
- self.variable_id = next((v["codi"] for v in variables_data if v["nom"].lower() == "temperatura"), None)
251
+
252
+ self.variable_id = next(
253
+ (v["codi"] for v in variables_data if v["nom"].lower() == "temperatura"),
254
+ None,
255
+ )
256
+ except json.JSONDecodeError as ex:
257
+ _LOGGER.error("Archivo existente corrupto al cargar símbolos/variables: %s", ex)
258
+ raise HomeAssistantError("Archivo corrupto de símbolos o variables")
218
259
  except Exception as ex:
219
260
  _LOGGER.error("Error al descargar símbolos o variables: %s", ex)
220
261
  raise HomeAssistantError("No se pudieron obtener símbolos o variables")
221
262
 
222
- async def async_step_select_station(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
263
+ async def async_step_select_station(
264
+ self, user_input: dict[str, Any] | None = None
265
+ ) -> ConfigFlowResult:
223
266
  """Tercer paso: seleccionar estación."""
224
267
  errors = {}
225
268
  townstations_client = MeteocatTownStations(self.api_key)
269
+
226
270
  try:
227
- stations_data = await townstations_client.get_town_stations(self.selected_municipi["codi"], self.variable_id)
271
+ # Obtener la lista completa de estaciones de la API
272
+ all_stations = await townstations_client.stations_service.get_stations()
273
+ assets_dir = get_storage_dir(self.hass, "assets")
274
+ stations_file = assets_dir / "stations.json"
275
+ async with aiofiles.open(stations_file, "w", encoding="utf-8") as file:
276
+ await file.write(json.dumps({"stations": all_stations}, ensure_ascii=False, indent=4))
277
+ _LOGGER.info("Lista completa de estaciones guardadas en %s", stations_file)
278
+
279
+ # Obtener estaciones filtradas por municipio y variable
280
+ stations_data = await townstations_client.get_town_stations(
281
+ self.selected_municipi["codi"], self.variable_id
282
+ )
283
+
284
+ town_stations_file = assets_dir / f"stations_{self.selected_municipi['codi']}.json"
285
+ async with aiofiles.open(town_stations_file, "w", encoding="utf-8") as file:
286
+ await file.write(json.dumps({"town_stations": stations_data}, ensure_ascii=False, indent=4))
287
+ _LOGGER.info("Lista de estaciones del municipio guardadas en %s", town_stations_file)
288
+
228
289
  except Exception as ex:
229
290
  _LOGGER.error("Error al obtener las estaciones: %s", ex)
230
291
  errors["base"] = "stations_fetch_failed"
231
292
  stations_data = []
232
293
 
294
+ if not stations_data or "variables" not in stations_data[0]:
295
+ errors["base"] = "no_stations"
296
+ return self.async_show_form(step_id="select_station", errors=errors)
297
+
233
298
  if user_input is not None:
234
299
  selected_station_codi = user_input["station"]
235
300
  selected_station = next(
236
- (station for station in stations_data[0]["variables"][0]["estacions"] if station["codi"] == selected_station_codi), None
301
+ (station for station in stations_data[0]["variables"][0]["estacions"]
302
+ if station["codi"] == selected_station_codi),
303
+ None,
237
304
  )
238
305
  if selected_station:
239
306
  self.station_id = selected_station["codi"]
@@ -252,9 +319,7 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
252
319
  self.province_name = station_metadata.get("provincia", {}).get("nom", "")
253
320
  self.station_status = station_metadata.get("estats", [{}])[0].get("codi", "")
254
321
 
255
- # Crear archivos regionales de alertas y lightning
256
322
  await self.create_alerts_file()
257
-
258
323
  return await self.async_step_set_api_limits()
259
324
  except Exception as ex:
260
325
  _LOGGER.error("Error al obtener los metadatos de la estación: %s", ex)
@@ -262,7 +327,11 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
262
327
  else:
263
328
  errors["base"] = "station_not_found"
264
329
 
265
- schema = vol.Schema({vol.Required("station"): vol.In({station["codi"]: station["nom"] for station in stations_data[0]["variables"][0]["estacions"]})})
330
+ schema = vol.Schema(
331
+ {vol.Required("station"): vol.In(
332
+ {station["codi"]: station["nom"] for station in stations_data[0]["variables"][0]["estacions"]}
333
+ )}
334
+ )
266
335
  return self.async_show_form(step_id="select_station", data_schema=schema, errors=errors)
267
336
 
268
337
  async def async_step_set_api_limits(self, user_input=None):
@@ -273,7 +342,8 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
273
342
  self.limit_prediccio = user_input.get(LIMIT_PREDICCIO, 100)
274
343
  self.limit_xdde = user_input.get(LIMIT_XDDE, 250)
275
344
  self.limit_quota = user_input.get(LIMIT_QUOTA, 300)
276
- self.limit_basic = user_input.get(LIMIT_BASIC, 300)
345
+ self.limit_basic = user_input.get(LIMIT_BASIC, 2000)
346
+
277
347
  return self.async_create_entry(
278
348
  title=self.selected_municipi["nom"],
279
349
  data={
@@ -298,7 +368,7 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
298
368
  LIMIT_XDDE: self.limit_xdde,
299
369
  LIMIT_QUOTA: self.limit_quota,
300
370
  LIMIT_BASIC: self.limit_basic,
301
- }
371
+ },
302
372
  )
303
373
 
304
374
  schema = vol.Schema({