meteocat 2.2.6 → 2.3.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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +39 -0
- package/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/.github/workflows/autocloser.yaml +25 -0
- package/.github/workflows/close-duplicates.yml +57 -0
- package/.github/workflows/publish-zip.yml +67 -0
- package/.github/workflows/release.yml +38 -6
- package/.github/workflows/stale.yml +12 -0
- package/.github/workflows/sync-gitlab.yml +94 -0
- package/.releaserc +1 -8
- package/CHANGELOG.md +29 -0
- package/README.md +29 -4
- package/custom_components/meteocat/__init__.py +154 -110
- package/custom_components/meteocat/config_flow.py +125 -55
- package/custom_components/meteocat/coordinator.py +200 -368
- package/custom_components/meteocat/helpers.py +12 -0
- package/custom_components/meteocat/manifest.json +22 -11
- package/custom_components/meteocat/options_flow.py +46 -2
- package/custom_components/meteocat/sensor.py +47 -8
- package/custom_components/meteocat/strings.json +10 -2
- package/custom_components/meteocat/translations/ca.json +10 -2
- package/custom_components/meteocat/translations/en.json +10 -2
- package/custom_components/meteocat/translations/es.json +10 -2
- package/custom_components/meteocat/version.py +1 -2
- package/filetree.txt +9 -0
- package/hacs.json +5 -2
- package/images/options.png +0 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/.releaserc.toml +0 -14
- package/releaserc.json +0 -18
|
@@ -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 =
|
|
95
|
-
|
|
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(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
148
|
-
if not
|
|
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(
|
|
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 =
|
|
157
|
-
if not
|
|
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(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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"]
|
|
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(
|
|
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,
|
|
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({
|