meteocat 1.1.1 → 2.0.1
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 +17 -0
- package/README.md +16 -2
- package/custom_components/meteocat/__init__.py +23 -1
- package/custom_components/meteocat/config_flow.py +56 -35
- package/custom_components/meteocat/const.py +21 -2
- package/custom_components/meteocat/coordinator.py +455 -0
- package/custom_components/meteocat/manifest.json +1 -1
- package/custom_components/meteocat/options_flow.py +104 -25
- package/custom_components/meteocat/sensor.py +340 -3
- package/custom_components/meteocat/strings.json +447 -2
- package/custom_components/meteocat/translations/ca.json +448 -3
- package/custom_components/meteocat/translations/en.json +447 -3
- package/custom_components/meteocat/translations/es.json +447 -2
- package/custom_components/meteocat/version.py +1 -1
- package/filetree.txt +1 -0
- package/images/api_limits.png +0 -0
- package/images/dynamic_sensors.png +0 -0
- package/images/pick_station.png +0 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -17,6 +17,7 @@ from homeassistant.components.weather import Forecast
|
|
|
17
17
|
from meteocatpy.data import MeteocatStationData
|
|
18
18
|
from meteocatpy.uvi import MeteocatUviData
|
|
19
19
|
from meteocatpy.forecast import MeteocatForecast
|
|
20
|
+
from meteocatpy.alerts import MeteocatAlerts
|
|
20
21
|
|
|
21
22
|
from meteocatpy.exceptions import (
|
|
22
23
|
BadRequestError,
|
|
@@ -33,6 +34,11 @@ from .const import (
|
|
|
33
34
|
DEFAULT_VALIDITY_DAYS,
|
|
34
35
|
DEFAULT_VALIDITY_HOURS,
|
|
35
36
|
DEFAULT_VALIDITY_MINUTES,
|
|
37
|
+
DEFAULT_ALERT_VALIDITY_TIME,
|
|
38
|
+
ALERT_VALIDITY_MULTIPLIER_100,
|
|
39
|
+
ALERT_VALIDITY_MULTIPLIER_200,
|
|
40
|
+
ALERT_VALIDITY_MULTIPLIER_500,
|
|
41
|
+
ALERT_VALIDITY_MULTIPLIER_DEFAULT
|
|
36
42
|
)
|
|
37
43
|
|
|
38
44
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -47,6 +53,8 @@ DEFAULT_UVI_UPDATE_INTERVAL = timedelta(minutes=60)
|
|
|
47
53
|
DEFAULT_UVI_SENSOR_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
48
54
|
DEFAULT_CONDITION_SENSOR_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
49
55
|
DEFAULT_TEMP_FORECAST_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
56
|
+
DEFAULT_ALERTS_UPDATE_INTERVAL = timedelta(minutes=10)
|
|
57
|
+
DEFAULT_ALERTS_REGION_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
50
58
|
|
|
51
59
|
# Definir la zona horaria local
|
|
52
60
|
TIMEZONE = ZoneInfo("Europe/Madrid")
|
|
@@ -1102,3 +1110,450 @@ class MeteocatTempForecastCoordinator(DataUpdateCoordinator):
|
|
|
1102
1110
|
"min_temp_forecast": float(variables.get("tmin", {}).get("valor", 0.0)),
|
|
1103
1111
|
}
|
|
1104
1112
|
return temp_forecast_data
|
|
1113
|
+
|
|
1114
|
+
class MeteocatAlertsCoordinator(DataUpdateCoordinator):
|
|
1115
|
+
"""Coordinator para manejar la actualización de alertas."""
|
|
1116
|
+
|
|
1117
|
+
def __init__(self, hass: HomeAssistant, entry_data: dict):
|
|
1118
|
+
"""
|
|
1119
|
+
Inicializa el coordinador de alertas de Meteocat.
|
|
1120
|
+
|
|
1121
|
+
Args:
|
|
1122
|
+
hass (HomeAssistant): Instancia de Home Assistant.
|
|
1123
|
+
entry_data (dict): Datos de configuración obtenidos de core.config_entries.
|
|
1124
|
+
"""
|
|
1125
|
+
self.api_key = entry_data["api_key"]
|
|
1126
|
+
self.region_id = entry_data["region_id"] # ID de la región o comarca
|
|
1127
|
+
self.limit_prediccio = entry_data["limit_prediccio"] # Límite de llamada a la API para PREDICCIONES
|
|
1128
|
+
self.alerts_data = MeteocatAlerts(self.api_key)
|
|
1129
|
+
|
|
1130
|
+
# Define la ruta del archivo JSON principal donde se guardarán las alertas
|
|
1131
|
+
self.alerts_file = os.path.join(
|
|
1132
|
+
hass.config.path(),
|
|
1133
|
+
"custom_components",
|
|
1134
|
+
"meteocat",
|
|
1135
|
+
"files",
|
|
1136
|
+
"alerts.json"
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
# Define la ruta del archivo JSON filtrado por región
|
|
1140
|
+
self.alerts_region_file = os.path.join(
|
|
1141
|
+
hass.config.path(),
|
|
1142
|
+
"custom_components",
|
|
1143
|
+
"meteocat",
|
|
1144
|
+
"files",
|
|
1145
|
+
f"alerts_{self.region_id}.json"
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
super().__init__(
|
|
1149
|
+
hass,
|
|
1150
|
+
_LOGGER,
|
|
1151
|
+
name=f"{DOMAIN} Alerts Coordinator",
|
|
1152
|
+
update_interval=DEFAULT_ALERTS_UPDATE_INTERVAL,
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
async def _async_update_data(self) -> Dict:
|
|
1156
|
+
"""Actualiza los datos de alertas desde la API de Meteocat o desde el archivo local según las condiciones especificadas."""
|
|
1157
|
+
# Comprobar si existe el archivo 'alerts.json'
|
|
1158
|
+
existing_data = await load_json_from_file(self.alerts_file)
|
|
1159
|
+
|
|
1160
|
+
# Calcular el tiempo de validez basado en el límite de predicción
|
|
1161
|
+
if self.limit_prediccio <= 100:
|
|
1162
|
+
multiplier = ALERT_VALIDITY_MULTIPLIER_100
|
|
1163
|
+
elif 100 < self.limit_prediccio <= 200:
|
|
1164
|
+
multiplier = ALERT_VALIDITY_MULTIPLIER_200
|
|
1165
|
+
elif 200 < self.limit_prediccio <= 500:
|
|
1166
|
+
multiplier = ALERT_VALIDITY_MULTIPLIER_500
|
|
1167
|
+
else:
|
|
1168
|
+
multiplier = ALERT_VALIDITY_MULTIPLIER_DEFAULT
|
|
1169
|
+
|
|
1170
|
+
validity_duration = timedelta(minutes=DEFAULT_ALERT_VALIDITY_TIME * multiplier)
|
|
1171
|
+
|
|
1172
|
+
# Si no existe el archivo
|
|
1173
|
+
if not existing_data:
|
|
1174
|
+
return await self._fetch_and_save_new_data()
|
|
1175
|
+
else:
|
|
1176
|
+
# Comprobar la antigüedad de los datos
|
|
1177
|
+
last_update = datetime.fromisoformat(existing_data['actualitzat']['dataUpdate'])
|
|
1178
|
+
now = datetime.now(timezone.utc).astimezone(TIMEZONE)
|
|
1179
|
+
|
|
1180
|
+
# Comparar la antigüedad de los datos
|
|
1181
|
+
if now - last_update > validity_duration:
|
|
1182
|
+
return await self._fetch_and_save_new_data()
|
|
1183
|
+
else:
|
|
1184
|
+
# Devolver los datos del archivo existente
|
|
1185
|
+
_LOGGER.debug("Usando datos existentes de alertas: %s", existing_data)
|
|
1186
|
+
return {
|
|
1187
|
+
"actualizado": existing_data['actualitzat']['dataUpdate']
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async def _fetch_and_save_new_data(self):
|
|
1191
|
+
"""Obtiene nuevos datos de la API y los guarda en el archivo JSON."""
|
|
1192
|
+
try:
|
|
1193
|
+
# Obtener los datos de alertas desde la API
|
|
1194
|
+
data = await asyncio.wait_for(self.alerts_data.get_alerts(), timeout=30)
|
|
1195
|
+
_LOGGER.debug("Datos de alertas actualizados exitosamente: %s", data)
|
|
1196
|
+
|
|
1197
|
+
# Validar que los datos sean una lista de diccionarios o una lista vacía
|
|
1198
|
+
if not isinstance(data, list) or (data and not all(isinstance(item, dict) for item in data)):
|
|
1199
|
+
_LOGGER.error(
|
|
1200
|
+
"Formato inválido: Se esperaba una lista de diccionarios, pero se obtuvo %s. Datos: %s",
|
|
1201
|
+
type(data).__name__,
|
|
1202
|
+
data,
|
|
1203
|
+
)
|
|
1204
|
+
raise ValueError("Formato de datos inválido")
|
|
1205
|
+
|
|
1206
|
+
# Añadir la clave 'actualitzat' con la fecha y hora actual de la zona horaria local
|
|
1207
|
+
current_time = datetime.now(timezone.utc).astimezone(TIMEZONE).isoformat()
|
|
1208
|
+
data_with_timestamp = {
|
|
1209
|
+
"actualitzat": {
|
|
1210
|
+
"dataUpdate": current_time
|
|
1211
|
+
},
|
|
1212
|
+
"dades": data
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
# Guardar los datos de alertas en un archivo JSON
|
|
1216
|
+
await save_json_to_file(data_with_timestamp, self.alerts_file)
|
|
1217
|
+
|
|
1218
|
+
# Filtrar los datos por región y guardar en un nuevo archivo JSON
|
|
1219
|
+
await self._filter_alerts_by_region()
|
|
1220
|
+
|
|
1221
|
+
# Devolver tanto los datos de alertas como la fecha de actualización
|
|
1222
|
+
return {
|
|
1223
|
+
"actualizado": data_with_timestamp['actualitzat']['dataUpdate']
|
|
1224
|
+
}
|
|
1225
|
+
except asyncio.TimeoutError as err:
|
|
1226
|
+
_LOGGER.warning("Tiempo de espera agotado al obtener datos de alertas.")
|
|
1227
|
+
raise ConfigEntryNotReady from err
|
|
1228
|
+
except ForbiddenError as err:
|
|
1229
|
+
_LOGGER.error("Acceso denegado al obtener datos de alertas: %s", err)
|
|
1230
|
+
raise ConfigEntryNotReady from err
|
|
1231
|
+
except TooManyRequestsError as err:
|
|
1232
|
+
_LOGGER.warning("Límite de solicitudes alcanzado al obtener datos de alertas: %s", err)
|
|
1233
|
+
raise ConfigEntryNotReady from err
|
|
1234
|
+
except (BadRequestError, InternalServerError, UnknownAPIError) as err:
|
|
1235
|
+
_LOGGER.error("Error al obtener datos de alertas: %s", err)
|
|
1236
|
+
raise
|
|
1237
|
+
except Exception as err:
|
|
1238
|
+
_LOGGER.exception("Error inesperado al obtener datos de alertas: %s", err)
|
|
1239
|
+
|
|
1240
|
+
# Intentar cargar datos en caché si hay un error
|
|
1241
|
+
cached_data = await load_json_from_file(self.alerts_file)
|
|
1242
|
+
if self._is_valid_alert_data(cached_data):
|
|
1243
|
+
_LOGGER.warning(
|
|
1244
|
+
"Usando datos en caché para las alertas. Última actualización: %s",
|
|
1245
|
+
cached_data["actualitzat"]["dataUpdate"],
|
|
1246
|
+
)
|
|
1247
|
+
return cached_data
|
|
1248
|
+
|
|
1249
|
+
# Si no se puede actualizar ni cargar datos en caché, retornar None
|
|
1250
|
+
_LOGGER.error("No se pudo obtener datos actualizados ni cargar datos en caché de alertas.")
|
|
1251
|
+
return None
|
|
1252
|
+
|
|
1253
|
+
@staticmethod
|
|
1254
|
+
def _is_valid_alert_data(data: dict) -> bool:
|
|
1255
|
+
"""Valida que los datos de alertas tengan el formato esperado."""
|
|
1256
|
+
return (
|
|
1257
|
+
isinstance(data, dict)
|
|
1258
|
+
and "dades" in data
|
|
1259
|
+
and isinstance(data["dades"], list)
|
|
1260
|
+
and (not data["dades"] or all(isinstance(item, dict) for item in data["dades"]))
|
|
1261
|
+
and "actualitzat" in data
|
|
1262
|
+
and isinstance(data["actualitzat"], dict)
|
|
1263
|
+
and "dataUpdate" in data["actualitzat"]
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
async def _filter_alerts_by_region(self):
|
|
1267
|
+
"""Filtra las alertas por la región y guarda los resultados en un archivo JSON."""
|
|
1268
|
+
# Obtener el momento actual
|
|
1269
|
+
now = datetime.now(timezone.utc).astimezone(TIMEZONE).isoformat()
|
|
1270
|
+
|
|
1271
|
+
# Carga el archivo alerts.json
|
|
1272
|
+
data = await load_json_from_file(self.alerts_file)
|
|
1273
|
+
if not data:
|
|
1274
|
+
_LOGGER.error("El archivo de alertas %s no existe o está vacío.", self.alerts_file)
|
|
1275
|
+
return
|
|
1276
|
+
|
|
1277
|
+
filtered_alerts = []
|
|
1278
|
+
|
|
1279
|
+
for item in data.get("dades", []):
|
|
1280
|
+
avisos_filtrados = []
|
|
1281
|
+
|
|
1282
|
+
for aviso in item.get("avisos", []):
|
|
1283
|
+
evolucions = []
|
|
1284
|
+
data_inici = None
|
|
1285
|
+
data_fi = None
|
|
1286
|
+
|
|
1287
|
+
for evolucion in aviso.get("evolucions", []):
|
|
1288
|
+
periodes = []
|
|
1289
|
+
for periode in evolucion.get("periodes", []):
|
|
1290
|
+
afectacions = [
|
|
1291
|
+
afectacio for afectacio in (periode.get("afectacions") or [])
|
|
1292
|
+
if afectacio and str(afectacio.get("idComarca")) == self.region_id
|
|
1293
|
+
]
|
|
1294
|
+
if afectacions:
|
|
1295
|
+
if not data_inici:
|
|
1296
|
+
dia = evolucion.get("dia")[:-6]
|
|
1297
|
+
data_inici = f"{dia}{periode.get('nom').split('-')[0]}:00Z"
|
|
1298
|
+
|
|
1299
|
+
# Calcular dataFi
|
|
1300
|
+
dia = evolucion.get('dia')[:-6] # Eliminar "T00:00Z"
|
|
1301
|
+
hora_fin = periode.get('nom').split('-')[1]
|
|
1302
|
+
# Ajustar dataFi correctamente si termina a medianoche
|
|
1303
|
+
if hora_fin == "00":
|
|
1304
|
+
dia = evolucion.get('dia')[:-6] # Eliminar "T00:00Z"
|
|
1305
|
+
data_fi = f"{dia}23:59Z"
|
|
1306
|
+
else:
|
|
1307
|
+
dia = evolucion.get('dia')[:-6] # Eliminar "T00:00Z"
|
|
1308
|
+
data_fi = f"{dia}{hora_fin}:00Z"
|
|
1309
|
+
|
|
1310
|
+
periodes.append({
|
|
1311
|
+
"nom": periode.get("nom"),
|
|
1312
|
+
"afectacions": afectacions if afectacions else None
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
if any(p.get("afectacions") for p in periodes):
|
|
1316
|
+
evolucions.append({
|
|
1317
|
+
"dia": evolucion.get("dia"),
|
|
1318
|
+
"comentari": evolucion.get("comentari"),
|
|
1319
|
+
"representatiu": evolucion.get("representatiu"),
|
|
1320
|
+
"llindar1": evolucion.get("llindar1"),
|
|
1321
|
+
"llindar2": evolucion.get("llindar2"),
|
|
1322
|
+
"distribucioGeografica": evolucion.get("distribucioGeografica"),
|
|
1323
|
+
"periodes": periodes,
|
|
1324
|
+
"valorMaxim": evolucion.get("valorMaxim")
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1327
|
+
# Comprobar si la fecha de fin ya ha pasado
|
|
1328
|
+
if evolucions and data_fi >= now:
|
|
1329
|
+
avisos_filtrados.append({
|
|
1330
|
+
"tipus": aviso.get("tipus"),
|
|
1331
|
+
"dataEmisio": aviso.get("dataEmisio"),
|
|
1332
|
+
"dataInici": data_inici,
|
|
1333
|
+
"dataFi": data_fi,
|
|
1334
|
+
"evolucions": evolucions,
|
|
1335
|
+
"estat": aviso.get("estat")
|
|
1336
|
+
})
|
|
1337
|
+
|
|
1338
|
+
if avisos_filtrados:
|
|
1339
|
+
filtered_alerts.append({
|
|
1340
|
+
"estat": item.get("estat"),
|
|
1341
|
+
"meteor": item.get("meteor"),
|
|
1342
|
+
"avisos": avisos_filtrados
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
# Guardar los datos filtrados en un archivo JSON
|
|
1346
|
+
await save_json_to_file({
|
|
1347
|
+
"actualitzat": data.get("actualitzat"),
|
|
1348
|
+
"dades": filtered_alerts
|
|
1349
|
+
}, self.alerts_region_file)
|
|
1350
|
+
|
|
1351
|
+
class MeteocatAlertsRegionCoordinator(DataUpdateCoordinator):
|
|
1352
|
+
"""Coordinator para manejar la actualización de alertas por región."""
|
|
1353
|
+
|
|
1354
|
+
def __init__(self, hass: HomeAssistant, entry_data: dict):
|
|
1355
|
+
"""Inicializa el coordinador para alertas de una comarca."""
|
|
1356
|
+
self.town_name = entry_data["town_name"]
|
|
1357
|
+
self.town_id = entry_data["town_id"]
|
|
1358
|
+
self.station_name = entry_data["station_name"]
|
|
1359
|
+
self.station_id = entry_data["station_id"]
|
|
1360
|
+
self.region_name = entry_data["region_name"]
|
|
1361
|
+
self.region_id = entry_data["region_id"]
|
|
1362
|
+
super().__init__(
|
|
1363
|
+
hass,
|
|
1364
|
+
_LOGGER,
|
|
1365
|
+
name=f"{DOMAIN} Alerts Region Coordinator",
|
|
1366
|
+
update_interval=DEFAULT_ALERTS_REGION_UPDATE_INTERVAL,
|
|
1367
|
+
)
|
|
1368
|
+
self._file_path = os.path.join(
|
|
1369
|
+
hass.config.path(),
|
|
1370
|
+
"custom_components",
|
|
1371
|
+
"meteocat",
|
|
1372
|
+
"files",
|
|
1373
|
+
f"alerts_{self.region_id}.json",
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1376
|
+
def _convert_to_local_time(self, time_str: str) -> datetime:
|
|
1377
|
+
"""Convierte una cadena de tiempo UTC a la zona horaria de Madrid."""
|
|
1378
|
+
if not time_str:
|
|
1379
|
+
return None
|
|
1380
|
+
# Convertir el tiempo de ISO a datetime con zona horaria UTC
|
|
1381
|
+
utc_time = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
|
|
1382
|
+
# Convertir la hora UTC a la hora local
|
|
1383
|
+
local_time = utc_time.astimezone(TIMEZONE)
|
|
1384
|
+
return local_time
|
|
1385
|
+
|
|
1386
|
+
def _count_active_alerts(self, data: dict) -> int:
|
|
1387
|
+
"""Cuenta las alertas activas procesando todas las alertas, sin detenerse en la primera coincidencia."""
|
|
1388
|
+
if not isinstance(data, dict) or "dades" not in data:
|
|
1389
|
+
_LOGGER.warning("Formato inesperado: 'dades' no es un diccionario o no contiene 'dades'.")
|
|
1390
|
+
return 0
|
|
1391
|
+
|
|
1392
|
+
active_alerts = 0
|
|
1393
|
+
current_time = datetime.now(TIMEZONE) # Hora local de Madrid
|
|
1394
|
+
|
|
1395
|
+
for item in data["dades"]: # Directamente acceder a 'dades' ya que sabemos que existe
|
|
1396
|
+
# Obtener el estado global de la alerta desde "dades"
|
|
1397
|
+
estat = item.get("estat", {}).get("nom")
|
|
1398
|
+
|
|
1399
|
+
# Proceder solo si el estado es "Obert"
|
|
1400
|
+
if estat == "Obert":
|
|
1401
|
+
avisos = item.get("avisos", [])
|
|
1402
|
+
for aviso in avisos:
|
|
1403
|
+
# Convertir las fechas de inicio y fin a hora local
|
|
1404
|
+
start_time = self._convert_to_local_time(aviso.get("dataInici"))
|
|
1405
|
+
end_time = self._convert_to_local_time(aviso.get("dataFi"))
|
|
1406
|
+
|
|
1407
|
+
# Verificar las condiciones para contar como alerta activa
|
|
1408
|
+
if start_time and end_time and start_time <= current_time <= end_time + timedelta(seconds=1):
|
|
1409
|
+
_LOGGER.debug(
|
|
1410
|
+
f"Alerta activa encontrada: {item.get('meteor', {}).get('nom', 'Desconocido')}"
|
|
1411
|
+
)
|
|
1412
|
+
active_alerts += 1
|
|
1413
|
+
|
|
1414
|
+
return active_alerts
|
|
1415
|
+
|
|
1416
|
+
def _get_time_period(self, current_hour: int) -> str:
|
|
1417
|
+
"""Devuelve la franja horaria actual en formato 'nom'."""
|
|
1418
|
+
periods = ["00-06", "06-12", "12-18", "18-00"]
|
|
1419
|
+
if 0 <= current_hour < 6:
|
|
1420
|
+
return periods[0]
|
|
1421
|
+
elif 6 <= current_hour < 12:
|
|
1422
|
+
return periods[1]
|
|
1423
|
+
elif 12 <= current_hour < 18:
|
|
1424
|
+
return periods[2]
|
|
1425
|
+
else:
|
|
1426
|
+
return periods[3]
|
|
1427
|
+
|
|
1428
|
+
def _convert_period_to_local_time(self, period: str, date: str) -> str:
|
|
1429
|
+
"""Convierte un periodo UTC a la hora local para una fecha específica."""
|
|
1430
|
+
utc_times = period.split("-")
|
|
1431
|
+
# Manejar la transición de "18-00" como "18-24" para el cálculo
|
|
1432
|
+
start_utc = utc_times[0]
|
|
1433
|
+
end_utc = "24" if utc_times[1] == "00" else utc_times[1]
|
|
1434
|
+
|
|
1435
|
+
# Convertir los tiempos UTC a datetime
|
|
1436
|
+
date_utc = datetime.fromisoformat(f"{date}T00:00+00:00") # Fecha base en UTC
|
|
1437
|
+
start_local = (date_utc + timedelta(hours=int(start_utc))).astimezone(TIMEZONE)
|
|
1438
|
+
end_local = (date_utc + timedelta(hours=int(end_utc))).astimezone(TIMEZONE)
|
|
1439
|
+
|
|
1440
|
+
# Formatear los tiempos a "HH:MM"
|
|
1441
|
+
return f"{start_local.strftime('%H:%M')} - {end_local.strftime('%H:%M')}"
|
|
1442
|
+
|
|
1443
|
+
async def _async_update_data(self) -> Dict[str, Any]:
|
|
1444
|
+
"""Carga y procesa los datos de alertas desde el archivo JSON."""
|
|
1445
|
+
try:
|
|
1446
|
+
async with aiofiles.open(self._file_path, "r", encoding="utf-8") as file:
|
|
1447
|
+
raw_data = await file.read()
|
|
1448
|
+
data = json.loads(raw_data)
|
|
1449
|
+
_LOGGER.info("Datos cargados desde %s: %s", self._file_path, data) # Log de la carga de datos
|
|
1450
|
+
except FileNotFoundError:
|
|
1451
|
+
_LOGGER.error("No se encontró el archivo JSON de alertas en %s.", self._file_path)
|
|
1452
|
+
return {}
|
|
1453
|
+
except json.JSONDecodeError:
|
|
1454
|
+
_LOGGER.error("Error al decodificar el archivo JSON de alertas en %s.", self._file_path)
|
|
1455
|
+
return {}
|
|
1456
|
+
|
|
1457
|
+
return self._process_alerts_data(data)
|
|
1458
|
+
|
|
1459
|
+
def _process_alerts_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
1460
|
+
"""Procesa los datos de alertas y devuelve un diccionario filtrado por región."""
|
|
1461
|
+
if not data.get("dades"):
|
|
1462
|
+
_LOGGER.info("No hay alertas activas para la región %s.", self.region_id)
|
|
1463
|
+
return {
|
|
1464
|
+
"estado": "Tancat",
|
|
1465
|
+
"actualizado": data.get("actualitzat", {}).get("dataUpdate", ""),
|
|
1466
|
+
"activas": 0, # Sin alertas activas
|
|
1467
|
+
"detalles": {"meteor": {}}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
# Obtener la fecha de actualización y añadirla a detalles
|
|
1471
|
+
data_update = data.get("actualitzat", {}).get("dataUpdate", "")
|
|
1472
|
+
current_time = datetime.now(TIMEZONE)
|
|
1473
|
+
current_date = current_time.date()
|
|
1474
|
+
current_hour = current_time.hour
|
|
1475
|
+
current_period = self._get_time_period(current_hour)
|
|
1476
|
+
|
|
1477
|
+
periods = ["00-06", "06-12", "12-18", "18-00"]
|
|
1478
|
+
current_period_index = periods.index(current_period)
|
|
1479
|
+
|
|
1480
|
+
alert_data = {
|
|
1481
|
+
"estado": "Obert",
|
|
1482
|
+
"actualizado": data_update,
|
|
1483
|
+
"activas": 0, # Inicializamos a 0 y actualizamos al final
|
|
1484
|
+
"detalles": {"meteor": {}}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
for alert in data["dades"]:
|
|
1488
|
+
estat = alert.get("estat", {}).get("nom", "Desconocido")
|
|
1489
|
+
meteor = alert.get("meteor", {}).get("nom", "Desconocido")
|
|
1490
|
+
avisos = alert.get("avisos", [])
|
|
1491
|
+
alert_found = False # Bandera para saber si encontramos una alerta para este meteor
|
|
1492
|
+
|
|
1493
|
+
for aviso in avisos:
|
|
1494
|
+
data_inici = self._convert_to_local_time(aviso.get("dataInici"))
|
|
1495
|
+
data_fi = self._convert_to_local_time(aviso.get("dataFi"))
|
|
1496
|
+
_LOGGER.info("Procesando aviso: inicio=%s, fin=%s", data_inici, data_fi) # Log del aviso
|
|
1497
|
+
|
|
1498
|
+
if data_inici and data_fi and data_inici <= data_fi: # Asegurarse que data_inici no sea mayor que data_fi
|
|
1499
|
+
evoluciones = aviso.get("evolucions", [])
|
|
1500
|
+
for evolucion in evoluciones:
|
|
1501
|
+
evolucion_date = datetime.fromisoformat(evolucion["dia"].replace("Z", "+00:00")).date()
|
|
1502
|
+
comentario = evolucion.get("comentari", "")
|
|
1503
|
+
|
|
1504
|
+
if evolucion_date >= current_date: # Mirar desde el día actual hacia adelante
|
|
1505
|
+
if evolucion_date == current_date:
|
|
1506
|
+
# Mirar el periodo actual y el siguiente si no hay alerta
|
|
1507
|
+
periodos_a_revisar = evolucion.get("periodes", [])[current_period_index:]
|
|
1508
|
+
if len(periodos_a_revisar) == 0 or not any(periodo.get("afectacions") for periodo in periodos_a_revisar):
|
|
1509
|
+
# Si no hay alertas en los periodos actuales o siguientes, miramos el siguiente periodo
|
|
1510
|
+
if current_period_index + 1 < len(periods):
|
|
1511
|
+
next_period = next((p for p in evolucion.get("periodes", []) if p.get("nom") == periods[current_period_index + 1]), None)
|
|
1512
|
+
if next_period and next_period.get("afectacions"):
|
|
1513
|
+
periodos_a_revisar = [next_period]
|
|
1514
|
+
else:
|
|
1515
|
+
periodos_a_revisar = []
|
|
1516
|
+
else:
|
|
1517
|
+
# Si estamos en el último periodo, miramos a días futuros
|
|
1518
|
+
periodos_a_revisar = []
|
|
1519
|
+
else:
|
|
1520
|
+
periodos_a_revisar = evolucion.get("periodes", [])
|
|
1521
|
+
|
|
1522
|
+
for periodo in periodos_a_revisar:
|
|
1523
|
+
local_period = self._convert_period_to_local_time(periodo["nom"], evolucion["dia"][:10])
|
|
1524
|
+
afectaciones = periodo.get("afectacions", [])
|
|
1525
|
+
if not afectaciones:
|
|
1526
|
+
_LOGGER.debug("No se encontraron afectaciones en el período: %s", periodo["nom"])
|
|
1527
|
+
continue
|
|
1528
|
+
|
|
1529
|
+
for afectacion in afectaciones:
|
|
1530
|
+
if afectacion.get("idComarca") == int(self.region_id): # Filtrar por idComarca de la región
|
|
1531
|
+
if not alert_found: # Solo agregamos la primera alerta encontrada para este meteor
|
|
1532
|
+
alert_data["detalles"]["meteor"][meteor] = {
|
|
1533
|
+
"fecha": evolucion["dia"][:10],
|
|
1534
|
+
"periodo": local_period,
|
|
1535
|
+
"estado": estat,
|
|
1536
|
+
"motivo": meteor,
|
|
1537
|
+
"inicio": data_inici,
|
|
1538
|
+
"fin": data_fi,
|
|
1539
|
+
"comentario": comentario,
|
|
1540
|
+
"umbral": afectacion.get("llindar", "Desconocido"),
|
|
1541
|
+
"peligro": afectacion.get("perill", 0),
|
|
1542
|
+
"nivel": afectacion.get("nivell", 0),
|
|
1543
|
+
}
|
|
1544
|
+
alert_found = True
|
|
1545
|
+
_LOGGER.info(
|
|
1546
|
+
"Alerta encontrada y agregada para %s: %s",
|
|
1547
|
+
meteor,
|
|
1548
|
+
alert_data["detalles"]["meteor"][meteor],
|
|
1549
|
+
)
|
|
1550
|
+
break # Salimos del ciclo de afectaciones ya que encontramos una alerta
|
|
1551
|
+
if alert_found:
|
|
1552
|
+
break # Salimos del ciclo de periodos ya que encontramos una alerta
|
|
1553
|
+
if alert_found:
|
|
1554
|
+
break # Salimos del ciclo de evoluciones si ya encontramos una alerta
|
|
1555
|
+
|
|
1556
|
+
alert_data["activas"] = len(alert_data["detalles"]["meteor"]) # Actualizar el número de alertas activas
|
|
1557
|
+
_LOGGER.info("Detalles recibidos: %s", alert_data.get("detalles", []))
|
|
1558
|
+
|
|
1559
|
+
return alert_data
|
|
@@ -4,10 +4,13 @@ import logging
|
|
|
4
4
|
from homeassistant.config_entries import ConfigEntry, OptionsFlow
|
|
5
5
|
from homeassistant.exceptions import HomeAssistantError
|
|
6
6
|
from homeassistant.helpers import config_validation as cv
|
|
7
|
+
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
|
7
8
|
import voluptuous as vol
|
|
8
9
|
|
|
9
10
|
from .const import (
|
|
10
|
-
CONF_API_KEY
|
|
11
|
+
CONF_API_KEY,
|
|
12
|
+
LIMIT_XEMA,
|
|
13
|
+
LIMIT_PREDICCIO
|
|
11
14
|
)
|
|
12
15
|
from meteocatpy.town import MeteocatTown
|
|
13
16
|
from meteocatpy.exceptions import (
|
|
@@ -27,45 +30,121 @@ class MeteocatOptionsFlowHandler(OptionsFlow):
|
|
|
27
30
|
"""Inicializa el flujo de opciones."""
|
|
28
31
|
self._config_entry = config_entry
|
|
29
32
|
self.api_key: str | None = None
|
|
33
|
+
self.limit_xema: int | None = None
|
|
34
|
+
self.limit_prediccio: int | None = None
|
|
30
35
|
|
|
31
36
|
async def async_step_init(self, user_input: dict | None = None):
|
|
32
37
|
"""Paso inicial del flujo de opciones."""
|
|
33
|
-
|
|
38
|
+
if user_input is not None:
|
|
39
|
+
if user_input["option"] == "update_api_and_limits":
|
|
40
|
+
return await self.async_step_update_api_and_limits()
|
|
41
|
+
elif user_input["option"] == "update_limits_only":
|
|
42
|
+
return await self.async_step_update_limits_only()
|
|
43
|
+
|
|
44
|
+
return self.async_show_form(
|
|
45
|
+
step_id="init",
|
|
46
|
+
data_schema=vol.Schema({
|
|
47
|
+
vol.Required("option"): SelectSelector(
|
|
48
|
+
SelectSelectorConfig(
|
|
49
|
+
options=[
|
|
50
|
+
"update_api_and_limits",
|
|
51
|
+
"update_limits_only"
|
|
52
|
+
],
|
|
53
|
+
translation_key="option"
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
)
|
|
34
58
|
|
|
35
|
-
async def
|
|
36
|
-
"""Permite al usuario actualizar la API Key."""
|
|
59
|
+
async def async_step_update_api_and_limits(self, user_input: dict | None = None):
|
|
60
|
+
"""Permite al usuario actualizar la API Key y los límites."""
|
|
37
61
|
errors = {}
|
|
38
62
|
|
|
39
63
|
if user_input is not None:
|
|
40
|
-
self.api_key = user_input
|
|
64
|
+
self.api_key = user_input.get(CONF_API_KEY)
|
|
65
|
+
self.limit_xema = user_input.get(LIMIT_XEMA)
|
|
66
|
+
self.limit_prediccio = user_input.get(LIMIT_PREDICCIO)
|
|
41
67
|
|
|
42
68
|
# Validar la nueva API Key utilizando MeteocatTown
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
if self.api_key:
|
|
70
|
+
town_client = MeteocatTown(self.api_key)
|
|
71
|
+
try:
|
|
72
|
+
await town_client.get_municipis() # Verificar que la API Key sea válida
|
|
73
|
+
except (
|
|
74
|
+
BadRequestError,
|
|
75
|
+
ForbiddenError,
|
|
76
|
+
TooManyRequestsError,
|
|
77
|
+
InternalServerError,
|
|
78
|
+
UnknownAPIError,
|
|
79
|
+
) as ex:
|
|
80
|
+
_LOGGER.error("Error al validar la nueva API Key: %s", ex)
|
|
81
|
+
errors["base"] = "cannot_connect"
|
|
82
|
+
except Exception as ex:
|
|
83
|
+
_LOGGER.error("Error inesperado al validar la nueva API Key: %s", ex)
|
|
84
|
+
errors["base"] = "unknown"
|
|
85
|
+
|
|
86
|
+
# Validar que los límites sean números positivos
|
|
87
|
+
if not cv.positive_int(self.limit_xema) or not cv.positive_int(self.limit_prediccio):
|
|
88
|
+
errors["base"] = "invalid_limit"
|
|
59
89
|
|
|
60
90
|
if not errors:
|
|
61
|
-
# Actualizar la configuración de la entrada con la nueva API Key
|
|
91
|
+
# Actualizar la configuración de la entrada con la nueva API Key y límites
|
|
92
|
+
data_update = {}
|
|
93
|
+
if self.api_key:
|
|
94
|
+
data_update[CONF_API_KEY] = self.api_key
|
|
95
|
+
if self.limit_xema:
|
|
96
|
+
data_update[LIMIT_XEMA] = self.limit_xema
|
|
97
|
+
if self.limit_prediccio:
|
|
98
|
+
data_update[LIMIT_PREDICCIO] = self.limit_prediccio
|
|
99
|
+
|
|
62
100
|
self.hass.config_entries.async_update_entry(
|
|
63
101
|
self._config_entry,
|
|
64
|
-
data={**self._config_entry.data,
|
|
102
|
+
data={**self._config_entry.data, **data_update},
|
|
65
103
|
)
|
|
104
|
+
# Recargar la integración para aplicar los cambios dinámicamente
|
|
105
|
+
await self.hass.config_entries.async_reload(self._config_entry.entry_id)
|
|
106
|
+
|
|
66
107
|
return self.async_create_entry(title="", data={})
|
|
67
108
|
|
|
68
|
-
schema = vol.Schema({
|
|
109
|
+
schema = vol.Schema({
|
|
110
|
+
vol.Required(CONF_API_KEY): str,
|
|
111
|
+
vol.Required(LIMIT_XEMA, default=self._config_entry.data.get(LIMIT_XEMA)): cv.positive_int,
|
|
112
|
+
vol.Required(LIMIT_PREDICCIO, default=self._config_entry.data.get(LIMIT_PREDICCIO)): cv.positive_int,
|
|
113
|
+
})
|
|
69
114
|
return self.async_show_form(
|
|
70
|
-
step_id="
|
|
115
|
+
step_id="update_api_and_limits", data_schema=schema, errors=errors
|
|
71
116
|
)
|
|
117
|
+
|
|
118
|
+
async def async_step_update_limits_only(self, user_input: dict | None = None):
|
|
119
|
+
"""Permite al usuario actualizar solo los límites de la API."""
|
|
120
|
+
errors = {}
|
|
121
|
+
|
|
122
|
+
if user_input is not None:
|
|
123
|
+
self.limit_xema = user_input.get(LIMIT_XEMA)
|
|
124
|
+
self.limit_prediccio = user_input.get(LIMIT_PREDICCIO)
|
|
125
|
+
|
|
126
|
+
# Validar que los límites sean números positivos
|
|
127
|
+
if not cv.positive_int(self.limit_xema) or not cv.positive_int(self.limit_prediccio):
|
|
128
|
+
errors["base"] = "invalid_limit"
|
|
129
|
+
|
|
130
|
+
if not errors:
|
|
131
|
+
self.hass.config_entries.async_update_entry(
|
|
132
|
+
self._config_entry,
|
|
133
|
+
data={
|
|
134
|
+
**self._config_entry.data,
|
|
135
|
+
LIMIT_XEMA: self.limit_xema,
|
|
136
|
+
LIMIT_PREDICCIO: self.limit_prediccio
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
# Recargar la integración para aplicar los cambios dinámicamente
|
|
140
|
+
await self.hass.config_entries.async_reload(self._config_entry.entry_id)
|
|
141
|
+
|
|
142
|
+
return self.async_create_entry(title="", data={})
|
|
143
|
+
|
|
144
|
+
schema = vol.Schema({
|
|
145
|
+
vol.Required(LIMIT_XEMA, default=self._config_entry.data.get(LIMIT_XEMA)): cv.positive_int,
|
|
146
|
+
vol.Required(LIMIT_PREDICCIO, default=self._config_entry.data.get(LIMIT_PREDICCIO)): cv.positive_int,
|
|
147
|
+
})
|
|
148
|
+
return self.async_show_form(
|
|
149
|
+
step_id="update_limits_only", data_schema=schema, errors=errors
|
|
150
|
+
)
|