meteocat 2.1.0 → 2.2.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.
- package/CHANGELOG.md +8 -0
- package/custom_components/meteocat/__init__.py +15 -1
- package/custom_components/meteocat/config_flow.py +112 -1
- package/custom_components/meteocat/const.py +10 -0
- package/custom_components/meteocat/coordinator.py +303 -7
- package/custom_components/meteocat/manifest.json +2 -2
- package/custom_components/meteocat/options_flow.py +30 -4
- package/custom_components/meteocat/sensor.py +243 -2
- package/custom_components/meteocat/strings.json +151 -4
- package/custom_components/meteocat/translations/ca.json +151 -4
- package/custom_components/meteocat/translations/en.json +151 -4
- package/custom_components/meteocat/translations/es.json +151 -4
- package/custom_components/meteocat/version.py +1 -1
- package/package.json +1 -1
- package/poetry.lock +4 -4
- package/pyproject.toml +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## [2.2.2](https://github.com/figorr/meteocat/compare/v2.2.1...v2.2.2) (2025-02-04)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* 2.2.2 ([3667e5d](https://github.com/figorr/meteocat/commit/3667e5d65069ee0079ebbaebeab6c929be8b9630))
|
|
7
|
+
* fix version 2.2.1 ([359cc7f](https://github.com/figorr/meteocat/commit/359cc7f7a9f1f025890bedc5177785016e9968d1))
|
|
8
|
+
|
|
1
9
|
# [2.1.0](https://github.com/figorr/meteocat/compare/v2.0.3...v2.1.0) (2025-01-27)
|
|
2
10
|
|
|
3
11
|
|
|
@@ -22,6 +22,8 @@ from .coordinator import (
|
|
|
22
22
|
MeteocatTempForecastCoordinator,
|
|
23
23
|
MeteocatAlertsCoordinator,
|
|
24
24
|
MeteocatAlertsRegionCoordinator,
|
|
25
|
+
MeteocatQuotesCoordinator,
|
|
26
|
+
MeteocatQuotesFileCoordinator,
|
|
25
27
|
)
|
|
26
28
|
|
|
27
29
|
from .const import DOMAIN, PLATFORMS
|
|
@@ -29,7 +31,7 @@ from .const import DOMAIN, PLATFORMS
|
|
|
29
31
|
_LOGGER = logging.getLogger(__name__)
|
|
30
32
|
|
|
31
33
|
# Versión
|
|
32
|
-
__version__ = "2.
|
|
34
|
+
__version__ = "2.2.2"
|
|
33
35
|
|
|
34
36
|
# Definir el esquema de configuración CONFIG_SCHEMA
|
|
35
37
|
CONFIG_SCHEMA = vol.Schema(
|
|
@@ -129,6 +131,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
|
129
131
|
alerts_region_coordinator = MeteocatAlertsRegionCoordinator(hass=hass, entry_data=entry_data)
|
|
130
132
|
await alerts_region_coordinator.async_config_entry_first_refresh()
|
|
131
133
|
|
|
134
|
+
quotes_coordinator = MeteocatQuotesCoordinator(hass=hass, entry_data=entry_data)
|
|
135
|
+
await quotes_coordinator.async_config_entry_first_refresh()
|
|
136
|
+
|
|
137
|
+
quotes_file_coordinator = MeteocatQuotesFileCoordinator(hass=hass, entry_data=entry_data)
|
|
138
|
+
await quotes_file_coordinator.async_config_entry_first_refresh()
|
|
139
|
+
|
|
132
140
|
except Exception as err: # Capturar todos los errores
|
|
133
141
|
_LOGGER.exception(f"Error al inicializar los coordinadores: {err}")
|
|
134
142
|
return False
|
|
@@ -147,6 +155,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
|
147
155
|
"temp_forecast_coordinator": temp_forecast_coordinator,
|
|
148
156
|
"alerts_coordinator": alerts_coordinator,
|
|
149
157
|
"alerts_region_coordinator": alerts_region_coordinator,
|
|
158
|
+
"quotes_coordinator": quotes_coordinator,
|
|
159
|
+
"quotes_file_coordinator": quotes_file_coordinator,
|
|
150
160
|
**entry_data,
|
|
151
161
|
}
|
|
152
162
|
|
|
@@ -213,6 +223,9 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
|
213
223
|
alerts_file = files_folder / "alerts.json"
|
|
214
224
|
alerts_region_file = files_folder / f"alerts_{region_id}.json"
|
|
215
225
|
|
|
226
|
+
# Archivos JSON de cuotas
|
|
227
|
+
quotes_file = files_folder / f"quotes.json"
|
|
228
|
+
|
|
216
229
|
# Validar la ruta base
|
|
217
230
|
if not custom_components_path.exists():
|
|
218
231
|
_LOGGER.warning(f"La ruta {custom_components_path} no existe. No se realizará la limpieza.")
|
|
@@ -226,6 +239,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
|
226
239
|
safe_remove(forecast_hourly_data_file)
|
|
227
240
|
safe_remove(forecast_daily_data_file)
|
|
228
241
|
safe_remove(alerts_file)
|
|
242
|
+
safe_remove(quotes_file)
|
|
229
243
|
safe_remove(alerts_region_file)
|
|
230
244
|
safe_remove(assets_folder, is_folder=True)
|
|
231
245
|
safe_remove(files_folder, is_folder=True)
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import asyncio
|
|
4
5
|
import json
|
|
5
6
|
import logging
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Any
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
8
11
|
|
|
9
12
|
import voluptuous as vol
|
|
10
13
|
from aiohttp import ClientError
|
|
11
14
|
import aiofiles
|
|
15
|
+
import unicodedata
|
|
12
16
|
|
|
13
17
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
|
14
18
|
from homeassistant.core import callback
|
|
@@ -34,7 +38,10 @@ from .const import (
|
|
|
34
38
|
PROVINCE_NAME,
|
|
35
39
|
STATION_STATUS,
|
|
36
40
|
LIMIT_XEMA,
|
|
37
|
-
LIMIT_PREDICCIO
|
|
41
|
+
LIMIT_PREDICCIO,
|
|
42
|
+
LIMIT_XDDE,
|
|
43
|
+
LIMIT_BASIC,
|
|
44
|
+
LIMIT_QUOTA
|
|
38
45
|
)
|
|
39
46
|
|
|
40
47
|
from .options_flow import MeteocatOptionsFlowHandler
|
|
@@ -43,11 +50,19 @@ from meteocatpy.symbols import MeteocatSymbols
|
|
|
43
50
|
from meteocatpy.variables import MeteocatVariables
|
|
44
51
|
from meteocatpy.townstations import MeteocatTownStations
|
|
45
52
|
from meteocatpy.infostation import MeteocatInfoStation
|
|
53
|
+
from meteocatpy.quotes import MeteocatQuotes
|
|
46
54
|
|
|
47
55
|
from meteocatpy.exceptions import BadRequestError, ForbiddenError, TooManyRequestsError, InternalServerError, UnknownAPIError
|
|
48
56
|
|
|
49
57
|
_LOGGER = logging.getLogger(__name__)
|
|
50
58
|
|
|
59
|
+
TIMEZONE = ZoneInfo("Europe/Madrid")
|
|
60
|
+
|
|
61
|
+
def normalize_name(name):
|
|
62
|
+
"""Normaliza el nombre eliminando acentos y convirtiendo a minúsculas."""
|
|
63
|
+
name = unicodedata.normalize("NFKD", name).encode("ASCII", "ignore").decode("utf-8")
|
|
64
|
+
return name.lower()
|
|
65
|
+
|
|
51
66
|
class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
52
67
|
"""Flujo de configuración para Meteocat."""
|
|
53
68
|
|
|
@@ -62,6 +77,89 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
|
62
77
|
self.station_name: str | None = None
|
|
63
78
|
self._cache = {}
|
|
64
79
|
|
|
80
|
+
async def fetch_and_save_quotes(self, api_key):
|
|
81
|
+
"""Obtiene las cuotas de la API de Meteocat y las guarda en quotes.json."""
|
|
82
|
+
meteocat_quotes = MeteocatQuotes(api_key)
|
|
83
|
+
quotes_dir = os.path.join(
|
|
84
|
+
self.hass.config.path(),
|
|
85
|
+
"custom_components",
|
|
86
|
+
"meteocat",
|
|
87
|
+
"files"
|
|
88
|
+
)
|
|
89
|
+
os.makedirs(quotes_dir, exist_ok=True)
|
|
90
|
+
quotes_file = os.path.join(quotes_dir, "quotes.json")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
data = await asyncio.wait_for(
|
|
94
|
+
meteocat_quotes.get_quotes(),
|
|
95
|
+
timeout=30
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Modificar los nombres de los planes con normalización
|
|
99
|
+
plan_mapping = {
|
|
100
|
+
"xdde_": "XDDE",
|
|
101
|
+
"prediccio_": "Prediccio",
|
|
102
|
+
"referencia basic": "Basic",
|
|
103
|
+
"xema_": "XEMA",
|
|
104
|
+
"quota": "Quota"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
modified_plans = []
|
|
108
|
+
for plan in data["plans"]:
|
|
109
|
+
normalized_nom = normalize_name(plan["nom"])
|
|
110
|
+
new_name = next((v for k, v in plan_mapping.items() if normalized_nom.startswith(k)), plan["nom"])
|
|
111
|
+
|
|
112
|
+
modified_plans.append({
|
|
113
|
+
"nom": new_name,
|
|
114
|
+
"periode": plan["periode"],
|
|
115
|
+
"maxConsultes": plan["maxConsultes"],
|
|
116
|
+
"consultesRestants": plan["consultesRestants"],
|
|
117
|
+
"consultesRealitzades": plan["consultesRealitzades"]
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
# Añadir la clave 'actualitzat' con la fecha y hora actual de la zona horaria local
|
|
121
|
+
current_time = datetime.now(timezone.utc).astimezone(TIMEZONE).isoformat()
|
|
122
|
+
data_with_timestamp = {
|
|
123
|
+
"actualitzat": {
|
|
124
|
+
"dataUpdate": current_time
|
|
125
|
+
},
|
|
126
|
+
"client": data["client"],
|
|
127
|
+
"plans": modified_plans
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Guardar los datos en el archivo JSON
|
|
131
|
+
async with aiofiles.open(quotes_file, "w", encoding="utf-8") as file:
|
|
132
|
+
await file.write(json.dumps(data_with_timestamp, ensure_ascii=False, indent=4))
|
|
133
|
+
|
|
134
|
+
_LOGGER.info("Cuotas guardadas exitosamente en %s", quotes_file)
|
|
135
|
+
|
|
136
|
+
except Exception as ex:
|
|
137
|
+
_LOGGER.error("Error al obtener o guardar las cuotas: %s", ex)
|
|
138
|
+
raise HomeAssistantError("No se pudieron obtener las cuotas de la API")
|
|
139
|
+
|
|
140
|
+
async def create_alerts_file(self):
|
|
141
|
+
"""Crea el archivo alerts.json si no existe."""
|
|
142
|
+
alerts_dir = os.path.join(
|
|
143
|
+
self.hass.config.path(),
|
|
144
|
+
"custom_components",
|
|
145
|
+
"meteocat",
|
|
146
|
+
"files"
|
|
147
|
+
)
|
|
148
|
+
os.makedirs(alerts_dir, exist_ok=True)
|
|
149
|
+
alerts_file = os.path.join(alerts_dir, "alerts.json")
|
|
150
|
+
|
|
151
|
+
if not os.path.exists(alerts_file):
|
|
152
|
+
initial_data = {
|
|
153
|
+
"actualitzat": {
|
|
154
|
+
"dataUpdate": "1970-01-01T00:00:00+00:00"
|
|
155
|
+
},
|
|
156
|
+
"dades": []
|
|
157
|
+
}
|
|
158
|
+
async with aiofiles.open(alerts_file, "w", encoding="utf-8") as file:
|
|
159
|
+
await file.write(json.dumps(initial_data, ensure_ascii=False, indent=4))
|
|
160
|
+
|
|
161
|
+
_LOGGER.info("Archivo alerts.json creado en %s", alerts_file)
|
|
162
|
+
|
|
65
163
|
async def async_step_user(
|
|
66
164
|
self, user_input: dict[str, Any] | None = None
|
|
67
165
|
) -> ConfigFlowResult:
|
|
@@ -75,6 +173,10 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
|
75
173
|
|
|
76
174
|
try:
|
|
77
175
|
self.municipis = await town_client.get_municipis()
|
|
176
|
+
# Aquí obtenemos y guardamos las cuotas
|
|
177
|
+
await self.fetch_and_save_quotes(self.api_key)
|
|
178
|
+
# Aquí creamos el archivo alerts.json si no existe
|
|
179
|
+
await self.create_alerts_file()
|
|
78
180
|
except (BadRequestError, ForbiddenError, TooManyRequestsError, InternalServerError, UnknownAPIError) as ex:
|
|
79
181
|
_LOGGER.error("Error al conectar con la API de Meteocat: %s", ex)
|
|
80
182
|
errors["base"] = "cannot_connect"
|
|
@@ -231,6 +333,9 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
|
231
333
|
if user_input is not None:
|
|
232
334
|
self.limit_xema = user_input.get(LIMIT_XEMA, 750)
|
|
233
335
|
self.limit_prediccio = user_input.get(LIMIT_PREDICCIO, 100)
|
|
336
|
+
self.limit_xdde = user_input.get(LIMIT_XDDE, 250)
|
|
337
|
+
self.limit_quota = user_input.get(LIMIT_QUOTA, 300)
|
|
338
|
+
self.limit_basic = user_input.get(LIMIT_BASIC, 300)
|
|
234
339
|
return self.async_create_entry(
|
|
235
340
|
title=self.selected_municipi["nom"],
|
|
236
341
|
data={
|
|
@@ -252,6 +357,9 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
|
252
357
|
STATION_STATUS: str(self.station_status),
|
|
253
358
|
LIMIT_XEMA: self.limit_xema,
|
|
254
359
|
LIMIT_PREDICCIO: self.limit_prediccio,
|
|
360
|
+
LIMIT_XDDE: self.limit_xdde,
|
|
361
|
+
LIMIT_QUOTA: self.limit_quota,
|
|
362
|
+
LIMIT_BASIC: self.limit_basic,
|
|
255
363
|
},
|
|
256
364
|
)
|
|
257
365
|
|
|
@@ -259,6 +367,9 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
|
259
367
|
{
|
|
260
368
|
vol.Required(LIMIT_XEMA, default=750): cv.positive_int,
|
|
261
369
|
vol.Required(LIMIT_PREDICCIO, default=100): cv.positive_int,
|
|
370
|
+
vol.Required(LIMIT_XDDE, default=250): cv.positive_int,
|
|
371
|
+
vol.Required(LIMIT_QUOTA, default=300): cv.positive_int,
|
|
372
|
+
vol.Required(LIMIT_BASIC, default=2000): cv.positive_int,
|
|
262
373
|
}
|
|
263
374
|
)
|
|
264
375
|
|
|
@@ -18,6 +18,9 @@ PROVINCE_ID = "province_id"
|
|
|
18
18
|
PROVINCE_NAME = "province_name"
|
|
19
19
|
LIMIT_XEMA = "limit_xema"
|
|
20
20
|
LIMIT_PREDICCIO = "limit_prediccio"
|
|
21
|
+
LIMIT_XDDE = "limit_xdde"
|
|
22
|
+
LIMIT_BASIC = "limit_basic"
|
|
23
|
+
LIMIT_QUOTA = "limit_quota"
|
|
21
24
|
STATION_STATUS = "station_status"
|
|
22
25
|
HOURLY_FORECAST_FILE_STATUS = "hourly_forecast_file_status"
|
|
23
26
|
DAILY_FORECAST_FILE_STATUS = "daily_forecast_file_status"
|
|
@@ -32,6 +35,12 @@ ALERT_COLD = "alert_cold"
|
|
|
32
35
|
ALERT_WARM = "alert_warm"
|
|
33
36
|
ALERT_WARM_NIGHT = "alert_warm_night"
|
|
34
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"
|
|
35
44
|
|
|
36
45
|
from homeassistant.const import Platform
|
|
37
46
|
|
|
@@ -44,6 +53,7 @@ DEFAULT_VALIDITY_DAYS = 1 # Número de días a partir de los cuales se consider
|
|
|
44
53
|
DEFAULT_VALIDITY_HOURS = 5 # Hora a partir de la cual la API tiene la información actualizada de predicciones disponible para descarga
|
|
45
54
|
DEFAULT_VALIDITY_MINUTES = 0 # Minutos a partir de los cuales la API tiene la información actualizada de predicciones disponible para descarga
|
|
46
55
|
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
|
|
56
|
+
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
|
|
47
57
|
|
|
48
58
|
# Multiplicadores para la duración de validez basada en limit_prediccio
|
|
49
59
|
ALERT_VALIDITY_MULTIPLIER_100 = 12 # para limit_prediccio <= 100
|
|
@@ -5,6 +5,7 @@ import json
|
|
|
5
5
|
import aiofiles
|
|
6
6
|
import logging
|
|
7
7
|
import asyncio
|
|
8
|
+
import unicodedata
|
|
8
9
|
from datetime import datetime, timedelta, timezone, time
|
|
9
10
|
from zoneinfo import ZoneInfo
|
|
10
11
|
from typing import Dict, Any
|
|
@@ -18,6 +19,7 @@ from meteocatpy.data import MeteocatStationData
|
|
|
18
19
|
from meteocatpy.uvi import MeteocatUviData
|
|
19
20
|
from meteocatpy.forecast import MeteocatForecast
|
|
20
21
|
from meteocatpy.alerts import MeteocatAlerts
|
|
22
|
+
from meteocatpy.quotes import MeteocatQuotes
|
|
21
23
|
|
|
22
24
|
from meteocatpy.exceptions import (
|
|
23
25
|
BadRequestError,
|
|
@@ -35,6 +37,7 @@ from .const import (
|
|
|
35
37
|
DEFAULT_VALIDITY_HOURS,
|
|
36
38
|
DEFAULT_VALIDITY_MINUTES,
|
|
37
39
|
DEFAULT_ALERT_VALIDITY_TIME,
|
|
40
|
+
DEFAULT_QUOTES_VALIDITY_TIME,
|
|
38
41
|
ALERT_VALIDITY_MULTIPLIER_100,
|
|
39
42
|
ALERT_VALIDITY_MULTIPLIER_200,
|
|
40
43
|
ALERT_VALIDITY_MULTIPLIER_500,
|
|
@@ -55,6 +58,8 @@ DEFAULT_CONDITION_SENSOR_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
|
55
58
|
DEFAULT_TEMP_FORECAST_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
56
59
|
DEFAULT_ALERTS_UPDATE_INTERVAL = timedelta(minutes=10)
|
|
57
60
|
DEFAULT_ALERTS_REGION_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
61
|
+
DEFAULT_QUOTES_UPDATE_INTERVAL = timedelta(minutes=10)
|
|
62
|
+
DEFAULT_QUOTES_FILE_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
58
63
|
|
|
59
64
|
# Definir la zona horaria local
|
|
60
65
|
TIMEZONE = ZoneInfo("Europe/Madrid")
|
|
@@ -92,6 +97,52 @@ async def load_json_from_file(input_file: str) -> dict:
|
|
|
92
97
|
_LOGGER.error("Error al decodificar JSON del archivo %s: %s", input_file, err)
|
|
93
98
|
return {}
|
|
94
99
|
|
|
100
|
+
def normalize_name(name):
|
|
101
|
+
"""Normaliza el nombre eliminando acentos y convirtiendo a minúsculas."""
|
|
102
|
+
name = unicodedata.normalize("NFKD", name).encode("ASCII", "ignore").decode("utf-8")
|
|
103
|
+
return name.lower()
|
|
104
|
+
|
|
105
|
+
# Definir _quotes_lock para evitar que varios coordinadores inicien una carrera para modificar quotes.json al mismo tiempo
|
|
106
|
+
_quotes_lock = asyncio.Lock()
|
|
107
|
+
|
|
108
|
+
async def _update_quotes(hass: HomeAssistant, plan_name: str) -> None:
|
|
109
|
+
"""Actualiza las cuotas en quotes.json después de una consulta."""
|
|
110
|
+
async with _quotes_lock:
|
|
111
|
+
quotes_file = hass.config.path(
|
|
112
|
+
"custom_components", "meteocat", "files", "quotes.json"
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
data = await load_json_from_file(quotes_file)
|
|
116
|
+
|
|
117
|
+
# Validar estructura del archivo
|
|
118
|
+
if not data or not isinstance(data, dict):
|
|
119
|
+
_LOGGER.warning("quotes.json está vacío o tiene un formato inválido: %s", data)
|
|
120
|
+
return
|
|
121
|
+
if "plans" not in data or not isinstance(data["plans"], list):
|
|
122
|
+
_LOGGER.warning("Estructura inesperada en quotes.json: %s", data)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# Buscar el plan y actualizar las cuotas
|
|
126
|
+
for plan in data["plans"]:
|
|
127
|
+
if plan.get("nom") == plan_name:
|
|
128
|
+
plan["consultesRealitzades"] += 1
|
|
129
|
+
plan["consultesRestants"] = max(0, plan["consultesRestants"] - 1)
|
|
130
|
+
_LOGGER.debug(
|
|
131
|
+
"Cuota actualizada para el plan %s: Consultas realizadas %s, restantes %s",
|
|
132
|
+
plan_name, plan["consultesRealitzades"], plan["consultesRestants"]
|
|
133
|
+
)
|
|
134
|
+
break # Salimos del bucle al encontrar el plan
|
|
135
|
+
|
|
136
|
+
# Guardar cambios en el archivo
|
|
137
|
+
await save_json_to_file(data, quotes_file)
|
|
138
|
+
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
_LOGGER.error("El archivo quotes.json no fue encontrado en la ruta esperada: %s", quotes_file)
|
|
141
|
+
except json.JSONDecodeError:
|
|
142
|
+
_LOGGER.error("Error al decodificar quotes.json, posiblemente el archivo está corrupto.")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
_LOGGER.exception("Error inesperado al actualizar las cuotas en quotes.json: %s", str(e))
|
|
145
|
+
|
|
95
146
|
class MeteocatSensorCoordinator(DataUpdateCoordinator):
|
|
96
147
|
"""Coordinator para manejar la actualización de datos de los sensores."""
|
|
97
148
|
|
|
@@ -142,6 +193,9 @@ class MeteocatSensorCoordinator(DataUpdateCoordinator):
|
|
|
142
193
|
)
|
|
143
194
|
_LOGGER.debug("Datos de sensores actualizados exitosamente: %s", data)
|
|
144
195
|
|
|
196
|
+
# Actualizar las cuotas usando la función externa
|
|
197
|
+
await _update_quotes(self.hass, "XEMA") # Asegúrate de usar el nombre correcto del plan aquí
|
|
198
|
+
|
|
145
199
|
# Validar que los datos sean una lista de diccionarios
|
|
146
200
|
if not isinstance(data, list) or not all(isinstance(item, dict) for item in data):
|
|
147
201
|
_LOGGER.error(
|
|
@@ -196,7 +250,7 @@ class MeteocatSensorCoordinator(DataUpdateCoordinator):
|
|
|
196
250
|
err,
|
|
197
251
|
)
|
|
198
252
|
# Intentar cargar datos en caché si hay un error
|
|
199
|
-
cached_data = load_json_from_file(self.station_file)
|
|
253
|
+
cached_data = await load_json_from_file(self.station_file)
|
|
200
254
|
if cached_data:
|
|
201
255
|
_LOGGER.warning("Usando datos en caché para la estación %s.", self.station_id)
|
|
202
256
|
return cached_data
|
|
@@ -340,6 +394,9 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
|
|
|
340
394
|
)
|
|
341
395
|
_LOGGER.debug("Datos actualizados exitosamente: %s", data)
|
|
342
396
|
|
|
397
|
+
# Actualizar las cuotas usando la función externa
|
|
398
|
+
await _update_quotes(self.hass, "Prediccio") # Asegúrate de usar el nombre correcto del plan aquí
|
|
399
|
+
|
|
343
400
|
# Validar que los datos sean un dict con una clave 'uvi'
|
|
344
401
|
if not isinstance(data, dict) or 'uvi' not in data:
|
|
345
402
|
_LOGGER.error("Formato inválido: Se esperaba un dict con la clave 'uvi'. Datos: %s", data)
|
|
@@ -380,7 +437,7 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
|
|
|
380
437
|
err,
|
|
381
438
|
)
|
|
382
439
|
# Intentar cargar datos en caché si hay un error
|
|
383
|
-
cached_data = load_json_from_file(self.uvi_file)
|
|
440
|
+
cached_data = await load_json_from_file(self.uvi_file)
|
|
384
441
|
if cached_data:
|
|
385
442
|
_LOGGER.warning("Usando datos en caché para la ciudad %s.", self.town_id)
|
|
386
443
|
return cached_data.get('uvi', [])
|
|
@@ -551,9 +608,27 @@ class MeteocatEntityCoordinator(DataUpdateCoordinator):
|
|
|
551
608
|
|
|
552
609
|
async def _fetch_and_save_data(self, api_method, file_path: str) -> dict:
|
|
553
610
|
"""Obtiene datos de la API y los guarda en un archivo JSON."""
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
611
|
+
try:
|
|
612
|
+
data = await asyncio.wait_for(api_method(self.town_id), timeout=30)
|
|
613
|
+
|
|
614
|
+
# Procesar los datos antes de guardarlos
|
|
615
|
+
for day in data.get('dies', []):
|
|
616
|
+
for var, details in day.get('variables', {}).items():
|
|
617
|
+
if var == 'precipitacio' and isinstance(details.get('valor'), str) and details['valor'].startswith('-'):
|
|
618
|
+
details['valor'] = '0.0'
|
|
619
|
+
|
|
620
|
+
await save_json_to_file(data, file_path)
|
|
621
|
+
|
|
622
|
+
# Actualizar cuotas dependiendo del tipo de predicción
|
|
623
|
+
if api_method.__name__ == 'get_prediccion_horaria':
|
|
624
|
+
await _update_quotes(self.hass, "Prediccio")
|
|
625
|
+
elif api_method.__name__ == 'get_prediccion_diaria':
|
|
626
|
+
await _update_quotes(self.hass, "Prediccio")
|
|
627
|
+
|
|
628
|
+
return data
|
|
629
|
+
except Exception as err:
|
|
630
|
+
_LOGGER.error(f"Error al obtener datos de la API para {file_path}: {err}")
|
|
631
|
+
raise
|
|
557
632
|
|
|
558
633
|
async def _async_update_data(self) -> dict:
|
|
559
634
|
"""Actualiza los datos de predicción horaria y diaria."""
|
|
@@ -602,8 +677,8 @@ class MeteocatEntityCoordinator(DataUpdateCoordinator):
|
|
|
602
677
|
_LOGGER.exception("Error inesperado al obtener datos de predicción: %s", err)
|
|
603
678
|
|
|
604
679
|
# Si ocurre un error, intentar cargar datos desde los archivos locales
|
|
605
|
-
hourly_cache = load_json_from_file(self.hourly_file) or {}
|
|
606
|
-
daily_cache = load_json_from_file(self.daily_file) or {}
|
|
680
|
+
hourly_cache = await load_json_from_file(self.hourly_file) or {}
|
|
681
|
+
daily_cache = await load_json_from_file(self.daily_file) or {}
|
|
607
682
|
|
|
608
683
|
_LOGGER.warning(
|
|
609
684
|
"Cargando datos desde caché para %s. Datos horarios: %s, Datos diarios: %s",
|
|
@@ -1203,6 +1278,9 @@ class MeteocatAlertsCoordinator(DataUpdateCoordinator):
|
|
|
1203
1278
|
)
|
|
1204
1279
|
raise ValueError("Formato de datos inválido")
|
|
1205
1280
|
|
|
1281
|
+
# Actualizar cuotas usando la función externa
|
|
1282
|
+
await _update_quotes(self.hass, "Prediccio") # Asegúrate de usar el nombre correcto del plan aquí
|
|
1283
|
+
|
|
1206
1284
|
# Añadir la clave 'actualitzat' con la fecha y hora actual de la zona horaria local
|
|
1207
1285
|
current_time = datetime.now(timezone.utc).astimezone(TIMEZONE).isoformat()
|
|
1208
1286
|
data_with_timestamp = {
|
|
@@ -1557,3 +1635,221 @@ class MeteocatAlertsRegionCoordinator(DataUpdateCoordinator):
|
|
|
1557
1635
|
_LOGGER.info("Detalles recibidos: %s", alert_data.get("detalles", []))
|
|
1558
1636
|
|
|
1559
1637
|
return alert_data
|
|
1638
|
+
|
|
1639
|
+
class MeteocatQuotesCoordinator(DataUpdateCoordinator):
|
|
1640
|
+
"""Coordinator para manejar la actualización de las cuotas de la API de Meteocat."""
|
|
1641
|
+
|
|
1642
|
+
def __init__(
|
|
1643
|
+
self,
|
|
1644
|
+
hass: HomeAssistant,
|
|
1645
|
+
entry_data: dict,
|
|
1646
|
+
):
|
|
1647
|
+
"""
|
|
1648
|
+
Inicializa el coordinador de cuotas de Meteocat.
|
|
1649
|
+
|
|
1650
|
+
Args:
|
|
1651
|
+
hass (HomeAssistant): Instancia de Home Assistant.
|
|
1652
|
+
entry_data (dict): Datos de configuración obtenidos de core.config_entries.
|
|
1653
|
+
"""
|
|
1654
|
+
self.api_key = entry_data["api_key"] # Usamos la API key de la configuración
|
|
1655
|
+
self.meteocat_quotes = MeteocatQuotes(self.api_key)
|
|
1656
|
+
|
|
1657
|
+
self.quotes_file = os.path.join(
|
|
1658
|
+
hass.config.path(),
|
|
1659
|
+
"custom_components",
|
|
1660
|
+
"meteocat",
|
|
1661
|
+
"files",
|
|
1662
|
+
"quotes.json"
|
|
1663
|
+
)
|
|
1664
|
+
|
|
1665
|
+
super().__init__(
|
|
1666
|
+
hass,
|
|
1667
|
+
_LOGGER,
|
|
1668
|
+
name=f"{DOMAIN} Quotes Coordinator",
|
|
1669
|
+
update_interval=DEFAULT_QUOTES_UPDATE_INTERVAL,
|
|
1670
|
+
)
|
|
1671
|
+
|
|
1672
|
+
async def _async_update_data(self) -> Dict:
|
|
1673
|
+
"""Actualiza los datos de las cuotas desde la API de Meteocat o usa datos en caché según la antigüedad."""
|
|
1674
|
+
existing_data = await load_json_from_file(self.quotes_file) or {}
|
|
1675
|
+
|
|
1676
|
+
# Definir la duración de validez de los datos
|
|
1677
|
+
validity_duration = timedelta(minutes=DEFAULT_QUOTES_VALIDITY_TIME)
|
|
1678
|
+
|
|
1679
|
+
# Si no existe el archivo
|
|
1680
|
+
if not existing_data:
|
|
1681
|
+
return await self._fetch_and_save_new_data()
|
|
1682
|
+
else:
|
|
1683
|
+
# Comprobar la antigüedad de los datos
|
|
1684
|
+
last_update = datetime.fromisoformat(existing_data['actualitzat']['dataUpdate'])
|
|
1685
|
+
now = datetime.now(timezone.utc).astimezone(TIMEZONE)
|
|
1686
|
+
|
|
1687
|
+
# Comparar la antigüedad de los datos
|
|
1688
|
+
if now - last_update >= validity_duration:
|
|
1689
|
+
return await self._fetch_and_save_new_data()
|
|
1690
|
+
else:
|
|
1691
|
+
# Devolver los datos del archivo existente
|
|
1692
|
+
_LOGGER.debug("Usando datos existentes de cuotas: %s", existing_data)
|
|
1693
|
+
return {
|
|
1694
|
+
"actualizado": existing_data['actualitzat']['dataUpdate']
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
async def _fetch_and_save_new_data(self):
|
|
1698
|
+
"""Obtiene nuevos datos de la API y los guarda en el archivo JSON."""
|
|
1699
|
+
try:
|
|
1700
|
+
data = await asyncio.wait_for(
|
|
1701
|
+
self.meteocat_quotes.get_quotes(),
|
|
1702
|
+
timeout=30 # Tiempo límite de 30 segundos
|
|
1703
|
+
)
|
|
1704
|
+
_LOGGER.debug("Datos de cuotas actualizados exitosamente: %s", data)
|
|
1705
|
+
|
|
1706
|
+
if not isinstance(data, dict):
|
|
1707
|
+
_LOGGER.error("Formato inválido: Se esperaba un diccionario, pero se obtuvo %s", type(data).__name__)
|
|
1708
|
+
raise ValueError("Formato de datos inválido")
|
|
1709
|
+
|
|
1710
|
+
# Modificar los nombres de los planes con normalización
|
|
1711
|
+
plan_mapping = {
|
|
1712
|
+
"xdde_": "XDDE",
|
|
1713
|
+
"prediccio_": "Prediccio",
|
|
1714
|
+
"referencia basic": "Basic",
|
|
1715
|
+
"xema_": "XEMA",
|
|
1716
|
+
"quota": "Quota"
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
modified_plans = []
|
|
1720
|
+
for plan in data["plans"]:
|
|
1721
|
+
normalized_nom = normalize_name(plan["nom"])
|
|
1722
|
+
new_name = next((v for k, v in plan_mapping.items() if normalized_nom.startswith(k)), plan["nom"])
|
|
1723
|
+
|
|
1724
|
+
modified_plans.append({
|
|
1725
|
+
"nom": new_name,
|
|
1726
|
+
"periode": plan["periode"],
|
|
1727
|
+
"maxConsultes": plan["maxConsultes"],
|
|
1728
|
+
"consultesRestants": plan["consultesRestants"],
|
|
1729
|
+
"consultesRealitzades": plan["consultesRealitzades"]
|
|
1730
|
+
})
|
|
1731
|
+
|
|
1732
|
+
# Añadir la clave 'actualitzat' con la fecha y hora actual de la zona horaria local
|
|
1733
|
+
current_time = datetime.now(timezone.utc).astimezone(TIMEZONE).isoformat()
|
|
1734
|
+
data_with_timestamp = {
|
|
1735
|
+
"actualitzat": {
|
|
1736
|
+
"dataUpdate": current_time
|
|
1737
|
+
},
|
|
1738
|
+
"client": data["client"],
|
|
1739
|
+
"plans": modified_plans
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
# Guardar los datos en un archivo JSON
|
|
1743
|
+
await save_json_to_file(data_with_timestamp, self.quotes_file)
|
|
1744
|
+
|
|
1745
|
+
# Devolver tanto los datos de alertas como la fecha de actualización
|
|
1746
|
+
return {
|
|
1747
|
+
"actualizado": data_with_timestamp['actualitzat']['dataUpdate']
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
except asyncio.TimeoutError as err:
|
|
1751
|
+
_LOGGER.warning("Tiempo de espera agotado al obtener las cuotas de la API de Meteocat.")
|
|
1752
|
+
raise ConfigEntryNotReady from err
|
|
1753
|
+
except ForbiddenError as err:
|
|
1754
|
+
_LOGGER.error("Acceso denegado al obtener cuotas de la API de Meteocat: %s", err)
|
|
1755
|
+
raise ConfigEntryNotReady from err
|
|
1756
|
+
except TooManyRequestsError as err:
|
|
1757
|
+
_LOGGER.warning("Límite de solicitudes alcanzado al obtener cuotas de la API de Meteocat: %s", err)
|
|
1758
|
+
raise ConfigEntryNotReady from err
|
|
1759
|
+
except (BadRequestError, InternalServerError, UnknownAPIError) as err:
|
|
1760
|
+
_LOGGER.error("Error al obtener cuotas de la API de Meteocat: %s", err)
|
|
1761
|
+
raise
|
|
1762
|
+
except Exception as err:
|
|
1763
|
+
_LOGGER.exception("Error inesperado al obtener cuotas de la API de Meteocat: %s", err)
|
|
1764
|
+
|
|
1765
|
+
# Intentar cargar datos en caché si hay un error
|
|
1766
|
+
cached_data = await load_json_from_file(self.quotes_file)
|
|
1767
|
+
if cached_data:
|
|
1768
|
+
_LOGGER.warning("Usando datos en caché para las cuotas de la API de Meteocat.")
|
|
1769
|
+
return cached_data
|
|
1770
|
+
|
|
1771
|
+
_LOGGER.error("No se pudo obtener datos actualizados ni cargar datos en caché.")
|
|
1772
|
+
return None
|
|
1773
|
+
|
|
1774
|
+
class MeteocatQuotesFileCoordinator(DataUpdateCoordinator):
|
|
1775
|
+
"""Coordinator para manejar la actualización de las cuotas desde quotes.json."""
|
|
1776
|
+
|
|
1777
|
+
def __init__(
|
|
1778
|
+
self,
|
|
1779
|
+
hass: HomeAssistant,
|
|
1780
|
+
entry_data: dict,
|
|
1781
|
+
):
|
|
1782
|
+
"""
|
|
1783
|
+
Inicializa el coordinador del sensor de cuotas de la API de Meteocat.
|
|
1784
|
+
|
|
1785
|
+
Args:
|
|
1786
|
+
hass (HomeAssistant): Instancia de Home Assistant.
|
|
1787
|
+
entry_data (dict): Datos de configuración obtenidos de core.config_entries.
|
|
1788
|
+
update_interval (timedelta): Intervalo de actualización.
|
|
1789
|
+
"""
|
|
1790
|
+
self.town_id = entry_data["town_id"] # Usamos el ID del municipio
|
|
1791
|
+
|
|
1792
|
+
super().__init__(
|
|
1793
|
+
hass,
|
|
1794
|
+
_LOGGER,
|
|
1795
|
+
name="Meteocat Quotes File Coordinator",
|
|
1796
|
+
update_interval=DEFAULT_QUOTES_FILE_UPDATE_INTERVAL,
|
|
1797
|
+
)
|
|
1798
|
+
self.quotes_file = os.path.join(
|
|
1799
|
+
hass.config.path(),
|
|
1800
|
+
"custom_components",
|
|
1801
|
+
"meteocat",
|
|
1802
|
+
"files",
|
|
1803
|
+
"quotes.json"
|
|
1804
|
+
)
|
|
1805
|
+
|
|
1806
|
+
async def _async_update_data(self) -> Dict[str, Any]:
|
|
1807
|
+
"""Carga los datos de quotes.json y devuelve el estado de las cuotas."""
|
|
1808
|
+
existing_data = await self._load_json_file()
|
|
1809
|
+
|
|
1810
|
+
if not existing_data:
|
|
1811
|
+
_LOGGER.warning("No se encontraron datos en quotes.json.")
|
|
1812
|
+
return {}
|
|
1813
|
+
|
|
1814
|
+
return {
|
|
1815
|
+
"actualizado": existing_data.get("actualitzat", {}).get("dataUpdate"),
|
|
1816
|
+
"client": existing_data.get("client", {}).get("nom"),
|
|
1817
|
+
"plans": [
|
|
1818
|
+
{
|
|
1819
|
+
"nom": plan.get("nom"),
|
|
1820
|
+
"periode": plan.get("periode"),
|
|
1821
|
+
"maxConsultes": plan.get("maxConsultes"),
|
|
1822
|
+
"consultesRestants": plan.get("consultesRestants"),
|
|
1823
|
+
"consultesRealitzades": plan.get("consultesRealitzades"),
|
|
1824
|
+
}
|
|
1825
|
+
for plan in existing_data.get("plans", [])
|
|
1826
|
+
]
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
async def _load_json_file(self) -> dict:
|
|
1830
|
+
"""Carga el archivo JSON de forma asincrónica."""
|
|
1831
|
+
try:
|
|
1832
|
+
async with aiofiles.open(self.quotes_file, "r", encoding="utf-8") as f:
|
|
1833
|
+
data = await f.read()
|
|
1834
|
+
return json.loads(data)
|
|
1835
|
+
except FileNotFoundError:
|
|
1836
|
+
_LOGGER.warning("El archivo %s no existe.", self.quotes_file)
|
|
1837
|
+
return {}
|
|
1838
|
+
except json.JSONDecodeError as err:
|
|
1839
|
+
_LOGGER.error("Error al decodificar JSON del archivo %s: %s", self.quotes_file, err)
|
|
1840
|
+
return {}
|
|
1841
|
+
|
|
1842
|
+
async def get_plan_info(self, plan_name: str) -> dict:
|
|
1843
|
+
"""Obtiene la información de un plan específico."""
|
|
1844
|
+
data = await self._async_update_data()
|
|
1845
|
+
for plan in data.get("plans", []):
|
|
1846
|
+
if plan.get("nom") == plan_name:
|
|
1847
|
+
return {
|
|
1848
|
+
"nom": plan.get("nom"),
|
|
1849
|
+
"periode": plan.get("periode"),
|
|
1850
|
+
"maxConsultes": plan.get("maxConsultes"),
|
|
1851
|
+
"consultesRestants": plan.get("consultesRestants"),
|
|
1852
|
+
"consultesRealitzades": plan.get("consultesRealitzades"),
|
|
1853
|
+
}
|
|
1854
|
+
_LOGGER.warning("Plan %s no encontrado en quotes.json.", plan_name)
|
|
1855
|
+
return {}
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
"iot_class": "cloud_polling",
|
|
9
9
|
"issue_tracker": "https://github.com/figorr/meteocat/issues",
|
|
10
10
|
"loggers": ["meteocatpy"],
|
|
11
|
-
"requirements": ["meteocatpy==0.0
|
|
12
|
-
"version": "2.
|
|
11
|
+
"requirements": ["meteocatpy==1.0.0", "packaging>=20.3", "wrapt>=1.14.0"],
|
|
12
|
+
"version": "2.2.2"
|
|
13
13
|
}
|