meteocat 2.2.5 → 2.2.7

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.
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: "🐞 Bug report"
3
+ about: Report a problem with the integration
4
+ title: "[Bug] "
5
+ labels: bug
6
+ assignees: ""
7
+ ---
8
+
9
+ **IMPORTANT: Please search the issues, including closed issues before opening a new issue.
10
+ The template is mandatory; failure to use it will result in issue closure.**
11
+
12
+ ---
13
+
14
+ ### Describe the bug
15
+ <!-- A clear and concise description of what the bug is. -->
16
+
17
+ ### To Reproduce
18
+ <!-- Steps to reproduce the behavior. -->
19
+ 1. Go to '...'
20
+ 2. Click on '....'
21
+ 3. Scroll down to '....'
22
+ 4. See error
23
+
24
+ ### Expected behavior
25
+ <!-- A clear and concise description of what you expected to happen. -->
26
+
27
+ ### Screenshots
28
+ <!-- If applicable, add screenshots to help explain your problem. -->
29
+
30
+ ### System details
31
+ - Home Assistant version:
32
+ - meteocat version (from `const.py` or HA startup log):
33
+ - meteocatpy version (from `pip show meteocatpy` in the HA container or HA startup log):
34
+
35
+ ### Debug Logs (meteocat & meteocatpy)
36
+ <!-- Please provide [logs] - Enable debug logging for the component. -->
37
+
38
+ ### Additional context
39
+ <!-- Add any other context about the problem here. -->
@@ -0,0 +1 @@
1
+ blank_issues_enabled: false
@@ -0,0 +1,25 @@
1
+ name: Autocloser
2
+
3
+ on:
4
+ issues:
5
+ types: [opened, edited, reopened]
6
+ issue_comment:
7
+ types: [created]
8
+
9
+ jobs:
10
+ autoclose:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Autoclose issues that did not follow issue template
14
+ uses: roots/issue-closer@v1.1
15
+ with:
16
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
17
+ issue-close-message: >
18
+ 👋 @${{ github.event.issue.user.login }} this issue was automatically closed because it did not follow the
19
+ [issue template](https://github.com/figorr/meteocat/issues/new/choose).
20
+
21
+ ⚠️ Reminder:
22
+ **IMPORTANT: Please search the issues, including closed issues before opening a new issue.
23
+ The template is mandatory; failure to use it will result in issue closure.**
24
+ issue-pattern: >
25
+ (Describe the bug|To Reproduce|Expected behavior|System details|Debug Logs)
@@ -0,0 +1,57 @@
1
+ name: Close duplicate issues
2
+
3
+ on:
4
+ issues:
5
+ types:
6
+ - labeled
7
+
8
+ jobs:
9
+ close-duplicate:
10
+ runs-on: ubuntu-latest
11
+ if: github.event.label.name == 'duplicate'
12
+ steps:
13
+ - name: Extract linked issue number
14
+ id: extract
15
+ run: |
16
+ body="${{ github.event.issue.body }}"
17
+ # Convertimos a minúsculas para que el match sea insensible a mayúsculas
18
+ lower=$(echo "$body" | tr '[:upper:]' '[:lower:]')
19
+
20
+ # Buscamos "closed as duplicate of #123" o "duplicate of #123"
21
+ if [[ "$lower" =~ closed[[:space:]]+as[[:space:]]+duplicate[[:space:]]+of[[:space:]]+#([0-9]+) ]]; then
22
+ echo "number=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
23
+ elif [[ "$lower" =~ duplicate[[:space:]]+of[[:space:]]+#([0-9]+) ]]; then
24
+ echo "number=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
25
+ fi
26
+
27
+ - name: Close issue
28
+ if: steps.extract.outputs.number != ''
29
+ uses: peter-evans/close-issue@v2
30
+ with:
31
+ issue-number: ${{ github.event.issue.number }}
32
+ comment: |
33
+ Closed as duplicate of #${{ steps.extract.outputs.number }}.
34
+ Please follow the discussion there.
35
+
36
+ - name: Update title
37
+ if: steps.extract.outputs.number != ''
38
+ uses: actions-ecosystem/action-edit-issue@v1
39
+ with:
40
+ github_token: ${{ secrets.GITHUB_TOKEN }}
41
+ issue_number: ${{ github.event.issue.number }}
42
+ title: "[Duplicate] ${{ github.event.issue.title }}"
43
+
44
+ - name: Remove duplicate label
45
+ if: steps.extract.outputs.number != ''
46
+ uses: actions-ecosystem/action-remove-labels@v1
47
+ with:
48
+ github_token: ${{ secrets.GITHUB_TOKEN }}
49
+ issue_number: ${{ github.event.issue.number }}
50
+ labels: duplicate
51
+
52
+ - name: Lock conversation
53
+ if: steps.extract.outputs.number != ''
54
+ uses: dessant/lock-threads@v4
55
+ with:
56
+ github-token: ${{ secrets.GITHUB_TOKEN }}
57
+ issue-lock-reason: "resolved"
@@ -0,0 +1,57 @@
1
+ name: Publish zip
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish-zip:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: write
12
+ steps:
13
+ - name: Checkout repository
14
+ uses: actions/checkout@v4
15
+ with:
16
+ token: ${{ secrets.GITHUB_TOKEN }}
17
+ ref: master
18
+
19
+ - name: Get latest Home Assistant version
20
+ id: ha-version
21
+ run: |
22
+ latest=$(curl -s https://api.github.com/repos/home-assistant/core/releases/latest | jq -r .tag_name)
23
+ # quitamos prefijo "v" si existe
24
+ latest_clean=${latest#v}
25
+ echo "Latest HA version: $latest_clean"
26
+ echo "version=$latest_clean" >> $GITHUB_OUTPUT
27
+
28
+ - name: Update hacs.json
29
+ run: |
30
+ ha_version="${{ steps.ha-version.outputs.version }}"
31
+ echo "Updating hacs.json with HA version: $ha_version"
32
+ jq --arg ver "$ha_version" '.homeassistant = $ver' hacs.json > tmp.json && mv tmp.json hacs.json
33
+ cat hacs.json
34
+
35
+ - name: Commit updated hacs.json
36
+ run: |
37
+ git config user.name "github-actions[bot]"
38
+ git config user.email "github-actions[bot]@users.noreply.github.com"
39
+ git add hacs.json
40
+ if git commit -m "chore: update hacs.json with latest Home Assistant version"; then
41
+ git push origin master
42
+ else
43
+ echo "No changes to commit"
44
+ fi
45
+
46
+ - name: Zip integration
47
+ run: |
48
+ cd custom_components/meteocat
49
+ zip -r meteocat.zip ./
50
+ mv meteocat.zip $GITHUB_WORKSPACE/
51
+
52
+ - name: Upload meteocat.zip to release
53
+ uses: softprops/action-gh-release@v2.2.1
54
+ with:
55
+ files: meteocat.zip
56
+ env:
57
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,63 @@
1
+ name: Mark stale issues and pull requests
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "30 1 * * *" # Ejecutar diariamente a la 01:30 UTC
6
+ workflow_dispatch: # Permite ejecución manual desde la interfaz
7
+
8
+ jobs:
9
+ stale:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ issues: write
13
+ pull-requests: write
14
+
15
+ steps:
16
+ # 1. Issues/PRs generales sin actividad (excepto etiquetados)
17
+ - uses: actions/stale@v8
18
+ with:
19
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
20
+ days-before-stale: 60
21
+ days-before-close: 7
22
+ exempt-issue-labels: "help wanted,bug,feature"
23
+ exempt-pr-labels: "help wanted"
24
+ stale-issue-message: "This issue has had no activity for 60 days and will be closed in a week if there is no further activity."
25
+ stale-pr-message: "This pull request has had no activity for 60 days and will be closed in a week if there is no further activity."
26
+ stale-issue-label: "stale"
27
+ stale-pr-label: "stale"
28
+
29
+ # 2. Issues tipo "feature" sin actividad (solo etiquetar)
30
+ - uses: actions/stale@v8
31
+ with:
32
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
33
+ only-labels: "feature"
34
+ days-before-stale: 21
35
+ days-before-close: -1
36
+ stale-issue-message: >-
37
+ This feature request has had no activity for 3 weeks.
38
+ It has been marked as *help wanted* and will remain open.
39
+ stale-issue-label: "help wanted"
40
+
41
+ # 3. Issues tipo "bug" sin actividad (solo etiquetar)
42
+ - uses: actions/stale@v8
43
+ with:
44
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
45
+ only-labels: "bug"
46
+ days-before-stale: 21
47
+ days-before-close: -1
48
+ stale-issue-message: >-
49
+ This bug report has had no activity for 3 weeks.
50
+ It has been marked as *help wanted* and will remain open.
51
+ stale-issue-label: "help wanted"
52
+
53
+ # 4. Issues con "logs required" sin actividad (cerrar en 14 días)
54
+ - uses: actions/stale@v8
55
+ with:
56
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
57
+ only-labels: "logs required"
58
+ days-before-stale: 7
59
+ days-before-close: 7
60
+ stale-issue-message: >-
61
+ This issue has been marked as *requiring logs*, but no activity has been detected for 7 days.
62
+ Since logs were not provided, this issue will now be closed.
63
+ stale-issue-label: "stale"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ ## [2.2.7](https://github.com/figorr/meteocat/compare/v2.2.6...v2.2.7) (2025-08-29)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * 2.2.7 ([ece96b9](https://github.com/figorr/meteocat/commit/ece96b983e6ba5df8a2811fdb86452857cadbb18))
7
+ * fix HACS info ([ae829fa](https://github.com/figorr/meteocat/commit/ae829fa09047bd57d8f5aa8eae746bf31f3a30db))
8
+ * Fix meteor case sensitive for alerts sensors ([d247476](https://github.com/figorr/meteocat/commit/d24747660175d2c305fcffbbae6472ccd490cfb1))
9
+ * Fix umbral case insensitive ([73d6b58](https://github.com/figorr/meteocat/commit/73d6b5808acf8c896a7d822cab7640607f430b37))
10
+ * Fix warning log when an umbral is not in UMBRAL_MAPPING ([adf3511](https://github.com/figorr/meteocat/commit/adf351111e8cb14cba3fd2f2868496701b445b97))
11
+ * Include warning when umbral is not at UMBRAL_MAPPING ([c1b1f75](https://github.com/figorr/meteocat/commit/c1b1f75d7b6a219fbce4580a29e85e168127998e))
12
+
13
+ ## [2.2.6](https://github.com/figorr/meteocat/compare/v2.2.5...v2.2.6) (2025-08-27)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * 2.2.6 ([bb92000](https://github.com/figorr/meteocat/commit/bb9200099ed951168bc891c870c6fd61b758c73d))
19
+ * Fix alerts region data update at first setup of the next entrances ([40018dc](https://github.com/figorr/meteocat/commit/40018dc0bb703a8dc606aaedcfa27b5c3e6bbf7a))
20
+ * Fix delete an entry but keeping common files for the rest of entries ([211e545](https://github.com/figorr/meteocat/commit/211e54510c458378ca46c465303995d1a9df0dbb))
21
+ * Fix region alerts json file for multiple entrances ([2b7072c](https://github.com/figorr/meteocat/commit/2b7072cb6fd7eafa1ab95dcedb2c2dea8f35005d))
22
+
1
23
  ## [2.2.5](https://github.com/figorr/meteocat/compare/v2.2.4...v2.2.5) (2025-02-16)
2
24
 
3
25
 
@@ -33,7 +33,7 @@ from .const import DOMAIN, PLATFORMS
33
33
  _LOGGER = logging.getLogger(__name__)
34
34
 
35
35
  # Versión
36
- __version__ = "2.2.5"
36
+ __version__ = "2.2.7"
37
37
 
38
38
  # Definir el esquema de configuración CONFIG_SCHEMA
39
39
  CONFIG_SCHEMA = vol.Schema(
@@ -192,68 +192,48 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
192
192
  """Limpia cualquier dato adicional al desinstalar la integración."""
193
193
  _LOGGER.info(f"Eliminando datos residuales de la integración: {entry.entry_id}")
194
194
 
195
- # Definir las rutas base a eliminar
195
+ # Definir las rutas base
196
196
  custom_components_path = Path(hass.config.path("custom_components")) / DOMAIN
197
197
  assets_folder = custom_components_path / "assets"
198
198
  files_folder = custom_components_path / "files"
199
199
 
200
- # Definir archivos relacionados a eliminar
200
+ # Archivos comunes
201
201
  symbols_file = assets_folder / "symbols.json"
202
202
  variables_file = assets_folder / "variables.json"
203
+ alerts_file = files_folder / "alerts.json"
204
+ quotes_file = files_folder / "quotes.json"
203
205
 
204
- # Obtener el `station_id` para identificar el archivo a eliminar
206
+ # Archivos específicos de cada entry
205
207
  station_id = entry.data.get("station_id")
206
- if not station_id:
207
- _LOGGER.warning("No se encontró 'station_id' en la configuración. No se puede eliminar el archivo de datos de la estación.")
208
- return
209
-
210
- # Archivo JSON de la estación
211
- station_data_file = files_folder / f"station_{station_id.lower()}_data.json"
212
-
213
- # Obtener el `town_id` para identificar el archivo a eliminar
214
208
  town_id = entry.data.get("town_id")
215
- if not town_id:
216
- _LOGGER.warning("No se encontró 'town_id' en la configuración. No se puede eliminar el archivo de datos de la estación.")
217
- return
218
-
219
- # Archivo JSON UVI del municipio
220
- town_data_file = files_folder / f"uvi_{town_id.lower()}_data.json"
221
-
222
- # Arhivos JSON de las predicciones del municipio a eliminar
223
- forecast_hourly_data_file = files_folder / f"forecast_{town_id.lower()}_hourly_data.json"
224
- forecast_daily_data_file = files_folder / f"forecast_{town_id.lower()}_daily_data.json"
225
-
226
- # Obtener el `region_id` para identificar el archivo a eliminar
227
209
  region_id = entry.data.get("region_id")
228
- if not region_id:
229
- _LOGGER.warning("No se encontró 'region_id' en la configuración. No se puede eliminar el archivo de alertas de la comarca.")
230
- return
231
-
232
- # Archivos JSON de alertas
233
- alerts_file = files_folder / "alerts.json"
234
- alerts_region_file = files_folder / f"alerts_{region_id}.json"
235
-
236
- # Archivo JSON de cuotas
237
- quotes_file = files_folder / f"quotes.json"
238
210
 
239
- # Archivo JSON de rayos
240
- lightning_file = files_folder / f"lightning_{region_id}.json"
241
-
242
- # Validar la ruta base
243
211
  if not custom_components_path.exists():
244
212
  _LOGGER.warning(f"La ruta {custom_components_path} no existe. No se realizará la limpieza.")
245
213
  return
246
214
 
247
- # Eliminar archivos y carpetas
215
+ # Eliminar archivos específicos de la entrada
216
+ if station_id:
217
+ safe_remove(files_folder / f"station_{station_id.lower()}_data.json")
218
+ if town_id:
219
+ safe_remove(files_folder / f"uvi_{town_id.lower()}_data.json")
220
+ safe_remove(files_folder / f"forecast_{town_id.lower()}_hourly_data.json")
221
+ safe_remove(files_folder / f"forecast_{town_id.lower()}_daily_data.json")
222
+ if region_id:
223
+ safe_remove(files_folder / f"alerts_{region_id}.json")
224
+ safe_remove(files_folder / f"lightning_{region_id}.json")
225
+
226
+ # Siempre eliminables
248
227
  safe_remove(symbols_file)
249
228
  safe_remove(variables_file)
250
- safe_remove(station_data_file)
251
- safe_remove(town_data_file)
252
- safe_remove(forecast_hourly_data_file)
253
- safe_remove(forecast_daily_data_file)
254
- safe_remove(alerts_file)
255
- safe_remove(quotes_file)
256
- safe_remove(alerts_region_file)
257
- safe_remove(lightning_file)
258
- safe_remove(assets_folder, is_folder=True)
259
- safe_remove(files_folder, is_folder=True)
229
+
230
+ # 🔑 Solo eliminar los archivos comunes si ya no quedan otras entradas
231
+ remaining_entries = [
232
+ e for e in hass.config_entries.async_entries(DOMAIN)
233
+ if e.entry_id != entry.entry_id
234
+ ]
235
+ if not remaining_entries: # significa que estamos borrando la última
236
+ safe_remove(alerts_file)
237
+ safe_remove(quotes_file)
238
+ safe_remove(assets_folder, is_folder=True)
239
+ safe_remove(files_folder, is_folder=True)
@@ -10,14 +10,13 @@ from datetime import datetime, timezone
10
10
  from zoneinfo import ZoneInfo
11
11
 
12
12
  import voluptuous as vol
13
- from aiohttp import ClientError
14
13
  import aiofiles
15
14
  import unicodedata
16
15
 
17
16
  from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
18
17
  from homeassistant.core import callback
19
18
  from homeassistant.exceptions import HomeAssistantError
20
- from homeassistant.helpers import aiohttp_client, config_validation as cv
19
+ from homeassistant.helpers import config_validation as cv
21
20
 
22
21
  from .const import (
23
22
  DOMAIN,
@@ -38,12 +37,12 @@ from .const import (
38
37
  PROVINCE_NAME,
39
38
  STATION_STATUS,
40
39
  LIMIT_XEMA,
41
- LIMIT_PREDICCIO,
40
+ LIMIT_PREDICCIO,
42
41
  LIMIT_XDDE,
43
42
  LIMIT_BASIC,
44
43
  LIMIT_QUOTA
45
44
  )
46
-
45
+
47
46
  from .options_flow import MeteocatOptionsFlowHandler
48
47
  from meteocatpy.town import MeteocatTown
49
48
  from meteocatpy.symbols import MeteocatSymbols
@@ -51,18 +50,22 @@ from meteocatpy.variables import MeteocatVariables
51
50
  from meteocatpy.townstations import MeteocatTownStations
52
51
  from meteocatpy.infostation import MeteocatInfoStation
53
52
  from meteocatpy.quotes import MeteocatQuotes
54
-
55
53
  from meteocatpy.exceptions import BadRequestError, ForbiddenError, TooManyRequestsError, InternalServerError, UnknownAPIError
56
54
 
57
55
  _LOGGER = logging.getLogger(__name__)
58
-
59
56
  TIMEZONE = ZoneInfo("Europe/Madrid")
60
57
 
61
- def normalize_name(name):
58
+ INITIAL_TEMPLATE = {
59
+ "actualitzat": {"dataUpdate": "1970-01-01T00:00:00+00:00"},
60
+ "dades": []
61
+ }
62
+
63
+ def normalize_name(name: str) -> str:
62
64
  """Normaliza el nombre eliminando acentos y convirtiendo a minúsculas."""
63
65
  name = unicodedata.normalize("NFKD", name).encode("ASCII", "ignore").decode("utf-8")
64
66
  return name.lower()
65
67
 
68
+
66
69
  class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
67
70
  """Flujo de configuración para Meteocat."""
68
71
 
@@ -76,27 +79,25 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
76
79
  self.station_id: str | None = None
77
80
  self.station_name: str | None = None
78
81
  self.region_id: str | None = None
79
- self._cache = {}
80
-
81
- async def fetch_and_save_quotes(self, api_key):
82
+ self.region_name: str | None = None
83
+ self.province_id: str | None = None
84
+ self.province_name: str | None = None
85
+ self.station_type: str | None = None
86
+ self.latitude: float | None = None
87
+ self.longitude: float | None = None
88
+ self.altitude: float | None = None
89
+ self.station_status: str | None = None
90
+
91
+ async def fetch_and_save_quotes(self, api_key: str):
82
92
  """Obtiene las cuotas de la API de Meteocat y las guarda en quotes.json."""
83
93
  meteocat_quotes = MeteocatQuotes(api_key)
84
- quotes_dir = os.path.join(
85
- self.hass.config.path(),
86
- "custom_components",
87
- "meteocat",
88
- "files"
89
- )
94
+ quotes_dir = os.path.join(self.hass.config.path(), "custom_components", "meteocat", "files")
90
95
  os.makedirs(quotes_dir, exist_ok=True)
91
96
  quotes_file = os.path.join(quotes_dir, "quotes.json")
92
97
 
93
98
  try:
94
- data = await asyncio.wait_for(
95
- meteocat_quotes.get_quotes(),
96
- timeout=30
97
- )
98
-
99
- # Modificar los nombres de los planes con normalización
99
+ data = await asyncio.wait_for(meteocat_quotes.get_quotes(), timeout=30)
100
+
100
101
  plan_mapping = {
101
102
  "xdde_": "XDDE",
102
103
  "prediccio_": "Prediccio",
@@ -109,7 +110,6 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
109
110
  for plan in data["plans"]:
110
111
  normalized_nom = normalize_name(plan["nom"])
111
112
  new_name = next((v for k, v in plan_mapping.items() if normalized_nom.startswith(k)), plan["nom"])
112
-
113
113
  modified_plans.append({
114
114
  "nom": new_name,
115
115
  "periode": plan["periode"],
@@ -118,28 +118,23 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
118
118
  "consultesRealitzades": plan["consultesRealitzades"]
119
119
  })
120
120
 
121
- # Añadir la clave 'actualitzat' con la fecha y hora actual de la zona horaria local
122
121
  current_time = datetime.now(timezone.utc).astimezone(TIMEZONE).isoformat()
123
122
  data_with_timestamp = {
124
- "actualitzat": {
125
- "dataUpdate": current_time
126
- },
123
+ "actualitzat": {"dataUpdate": current_time},
127
124
  "client": data["client"],
128
125
  "plans": modified_plans
129
126
  }
130
127
 
131
- # Guardar los datos en el archivo JSON
132
128
  async with aiofiles.open(quotes_file, "w", encoding="utf-8") as file:
133
129
  await file.write(json.dumps(data_with_timestamp, ensure_ascii=False, indent=4))
134
-
135
130
  _LOGGER.info("Cuotas guardadas exitosamente en %s", quotes_file)
136
131
 
137
132
  except Exception as ex:
138
133
  _LOGGER.error("Error al obtener o guardar las cuotas: %s", ex)
139
134
  raise HomeAssistantError("No se pudieron obtener las cuotas de la API")
140
-
135
+
141
136
  async def create_alerts_file(self):
142
- """Crea el archivo alerts.json si no existe."""
137
+ """Crea los archivos de alertas global y regional si no existen."""
143
138
  alerts_dir = os.path.join(
144
139
  self.hass.config.path(),
145
140
  "custom_components",
@@ -147,67 +142,44 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
147
142
  "files"
148
143
  )
149
144
  os.makedirs(alerts_dir, exist_ok=True)
150
- alerts_file = os.path.join(alerts_dir, "alerts.json")
151
145
 
146
+ # Archivo global de alertas
147
+ alerts_file = os.path.join(alerts_dir, "alerts.json")
152
148
  if not os.path.exists(alerts_file):
153
- initial_data = {
154
- "actualitzat": {
155
- "dataUpdate": "1970-01-01T00:00:00+00:00"
156
- },
157
- "dades": []
158
- }
159
149
  async with aiofiles.open(alerts_file, "w", encoding="utf-8") as file:
160
- await file.write(json.dumps(initial_data, ensure_ascii=False, indent=4))
161
-
162
- _LOGGER.info("Archivo alerts.json creado en %s", alerts_file)
163
-
164
- async def create_lightning_file(self):
165
- """Crea el archivo lightning_{self.region_id}.json si no existe."""
166
- lightning_dir = os.path.join(
167
- self.hass.config.path(),
168
- "custom_components",
169
- "meteocat",
170
- "files"
171
- )
172
- os.makedirs(lightning_dir, exist_ok=True)
173
- lightning_file = os.path.join(lightning_dir, f"lightning_{self.region_id}.json")
174
-
175
- if not os.path.exists(lightning_file):
176
- initial_data = {
177
- "actualitzat": {
178
- "dataUpdate": "1970-01-01T00:00:00+00:00"
179
- },
180
- "dades": []
181
- }
182
- async with aiofiles.open(lightning_file, "w", encoding="utf-8") as file:
183
- await file.write(json.dumps(initial_data, ensure_ascii=False, indent=4))
184
-
185
- _LOGGER.info("Archivo %s creado", lightning_file)
186
-
187
- async def async_step_user(
188
- self, user_input: dict[str, Any] | None = None
189
- ) -> ConfigFlowResult:
190
- """Primer paso: Solicitar la API Key."""
150
+ await file.write(json.dumps(INITIAL_TEMPLATE, ensure_ascii=False, indent=4))
151
+ _LOGGER.info("Archivo global %s creado con plantilla inicial", alerts_file)
152
+
153
+ # Solo si existe region_id
154
+ if self.region_id:
155
+ # 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):
158
+ 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):
165
+ 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:
170
+ """Primer paso: solicitar API Key."""
191
171
  errors = {}
192
-
193
172
  if user_input is not None:
194
173
  self.api_key = user_input[CONF_API_KEY]
195
-
196
174
  town_client = MeteocatTown(self.api_key)
197
-
198
175
  try:
199
176
  self.municipis = await town_client.get_municipis()
200
- # Aquí obtenemos y guardamos las cuotas
201
177
  await self.fetch_and_save_quotes(self.api_key)
202
- # Aquí creamos el archivo alerts.json si no existe
178
+ # Crea solo el archivo global de alertas (regional se hará después)
203
179
  await self.create_alerts_file()
204
- except (BadRequestError, ForbiddenError, TooManyRequestsError, InternalServerError, UnknownAPIError) as ex:
180
+ except Exception as ex:
205
181
  _LOGGER.error("Error al conectar con la API de Meteocat: %s", ex)
206
182
  errors["base"] = "cannot_connect"
207
- except Exception as ex:
208
- _LOGGER.error("Error inesperado al validar la API Key: %s", ex)
209
- errors["base"] = "unknown"
210
-
211
183
  if not errors:
212
184
  return await self.async_step_select_municipi()
213
185
 
@@ -215,92 +187,44 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
215
187
  return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
216
188
 
217
189
  async def async_step_select_municipi(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
218
- """Segundo paso: Seleccionar el municipio."""
190
+ """Segundo paso: seleccionar el municipio."""
219
191
  errors = {}
220
-
221
192
  if user_input is not None:
222
193
  selected_codi = user_input["municipi"]
223
- self.selected_municipi = next(
224
- (m for m in self.municipis if m["codi"] == selected_codi), None
225
- )
226
-
194
+ self.selected_municipi = next((m for m in self.municipis if m["codi"] == selected_codi), None)
227
195
  if self.selected_municipi:
228
196
  await self.fetch_symbols_and_variables()
229
197
 
230
- if not errors and self.selected_municipi:
198
+ if self.selected_municipi:
231
199
  return await self.async_step_select_station()
232
200
 
233
- schema = vol.Schema(
234
- {vol.Required("municipi"): vol.In({m["codi"]: m["nom"] for m in self.municipis})}
235
- )
236
-
201
+ schema = vol.Schema({vol.Required("municipi"): vol.In({m["codi"]: m["nom"] for m in self.municipis})})
237
202
  return self.async_show_form(step_id="select_municipi", data_schema=schema, errors=errors)
238
203
 
239
204
  async def fetch_symbols_and_variables(self):
240
205
  """Descarga y guarda los símbolos y variables después de seleccionar el municipio."""
241
-
242
- errors = {}
243
-
244
- # Crear directorio de activos (assets) si no existe
245
- assets_dir = os.path.join(
246
- self.hass.config.path(),
247
- "custom_components",
248
- "meteocat",
249
- "assets"
250
- )
206
+ assets_dir = os.path.join(self.hass.config.path(), "custom_components", "meteocat", "assets")
251
207
  os.makedirs(assets_dir, exist_ok=True)
252
-
253
- # Rutas para los archivos de símbolos y variables
254
208
  symbols_file = os.path.join(assets_dir, "symbols.json")
255
209
  variables_file = os.path.join(assets_dir, "variables.json")
256
-
257
210
  try:
258
- # Descargar y guardar los símbolos
259
- symbols_client = MeteocatSymbols(self.api_key)
260
- symbols_data = await symbols_client.fetch_symbols()
261
-
211
+ symbols_data = await MeteocatSymbols(self.api_key).fetch_symbols()
262
212
  async with aiofiles.open(symbols_file, "w", encoding="utf-8") as file:
263
213
  await file.write(json.dumps({"symbols": symbols_data}, ensure_ascii=False, indent=4))
264
-
265
- _LOGGER.info(f"Símbolos guardados en {symbols_file}")
266
-
267
- # Descargar y guardar las variables
268
- variables_client = MeteocatVariables(self.api_key)
269
- variables_data = await variables_client.get_variables()
270
-
214
+ variables_data = await MeteocatVariables(self.api_key).get_variables()
271
215
  async with aiofiles.open(variables_file, "w", encoding="utf-8") as file:
272
216
  await file.write(json.dumps({"variables": variables_data}, ensure_ascii=False, indent=4))
273
-
274
- _LOGGER.info(f"Variables guardadas en {variables_file}")
275
-
276
- # Buscar la variable de temperatura
277
- self.variable_id = next(
278
- (v["codi"] for v in variables_data if v["nom"].lower() == "temperatura"), None
279
- )
280
- if not self.variable_id:
281
- _LOGGER.error("No se encontró la variable 'Temperatura'")
282
- errors["base"] = "variable_not_found"
283
- except (BadRequestError, ForbiddenError, TooManyRequestsError, InternalServerError, UnknownAPIError) as ex:
284
- _LOGGER.error("Error al conectar con la API de Meteocat: %s", ex)
285
- errors["base"] = "cannot_connect"
217
+ self.variable_id = next((v["codi"] for v in variables_data if v["nom"].lower() == "temperatura"), None)
286
218
  except Exception as ex:
287
- _LOGGER.error("Error inesperado al descargar los datos: %s", ex)
288
- errors["base"] = "unknown"
219
+ _LOGGER.error("Error al descargar símbolos o variables: %s", ex)
220
+ raise HomeAssistantError("No se pudieron obtener símbolos o variables")
289
221
 
290
- if errors:
291
- raise HomeAssistantError(errors)
292
-
293
- async def async_step_select_station(
294
- self, user_input: dict[str, Any] | None = None
295
- ) -> ConfigFlowResult:
296
- """Tercer paso: Seleccionar la estación para la variable seleccionada."""
222
+ async def async_step_select_station(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
223
+ """Tercer paso: seleccionar estación."""
297
224
  errors = {}
298
-
299
225
  townstations_client = MeteocatTownStations(self.api_key)
300
226
  try:
301
- stations_data = await townstations_client.get_town_stations(
302
- self.selected_municipi["codi"], self.variable_id
303
- )
227
+ stations_data = await townstations_client.get_town_stations(self.selected_municipi["codi"], self.variable_id)
304
228
  except Exception as ex:
305
229
  _LOGGER.error("Error al obtener las estaciones: %s", ex)
306
230
  errors["base"] = "stations_fetch_failed"
@@ -309,19 +233,15 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
309
233
  if user_input is not None:
310
234
  selected_station_codi = user_input["station"]
311
235
  selected_station = next(
312
- (station for station in stations_data[0]["variables"][0]["estacions"] if station["codi"] == selected_station_codi),
313
- None
236
+ (station for station in stations_data[0]["variables"][0]["estacions"] if station["codi"] == selected_station_codi), None
314
237
  )
315
-
316
238
  if selected_station:
317
239
  self.station_id = selected_station["codi"]
318
240
  self.station_name = selected_station["nom"]
319
241
 
320
242
  # Obtener metadatos de la estación
321
- infostation_client = MeteocatInfoStation(self.api_key)
322
243
  try:
323
- station_metadata = await infostation_client.get_infostation(self.station_id)
324
- # Extraer los valores necesarios de los metadatos
244
+ station_metadata = await MeteocatInfoStation(self.api_key).get_infostation(self.station_id)
325
245
  self.station_type = station_metadata.get("tipus", "")
326
246
  self.latitude = station_metadata.get("coordenades", {}).get("latitud", 0.0)
327
247
  self.longitude = station_metadata.get("coordenades", {}).get("longitud", 0.0)
@@ -332,9 +252,9 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
332
252
  self.province_name = station_metadata.get("provincia", {}).get("nom", "")
333
253
  self.station_status = station_metadata.get("estats", [{}])[0].get("codi", "")
334
254
 
335
- # Crear el archivo lightning después de obtener region_id
336
- await self.create_lightning_file()
337
-
255
+ # Crear archivos regionales de alertas y lightning
256
+ await self.create_alerts_file()
257
+
338
258
  return await self.async_step_set_api_limits()
339
259
  except Exception as ex:
340
260
  _LOGGER.error("Error al obtener los metadatos de la estación: %s", ex)
@@ -342,22 +262,12 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
342
262
  else:
343
263
  errors["base"] = "station_not_found"
344
264
 
345
- schema = vol.Schema(
346
- {
347
- vol.Required("station"): vol.In(
348
- {station["codi"]: station["nom"] for station in stations_data[0]["variables"][0]["estacions"]}
349
- )
350
- }
351
- )
265
+ schema = vol.Schema({vol.Required("station"): vol.In({station["codi"]: station["nom"] for station in stations_data[0]["variables"][0]["estacions"]})})
266
+ return self.async_show_form(step_id="select_station", data_schema=schema, errors=errors)
352
267
 
353
- return self.async_show_form(
354
- step_id="select_station", data_schema=schema, errors=errors
355
- )
356
-
357
268
  async def async_step_set_api_limits(self, user_input=None):
358
- """Cuarto paso: Introducir los límites de XEMA y PREDICCIO del plan de la API."""
269
+ """Cuarto paso: límites de la API."""
359
270
  errors = {}
360
-
361
271
  if user_input is not None:
362
272
  self.limit_xema = user_input.get(LIMIT_XEMA, 750)
363
273
  self.limit_prediccio = user_input.get(LIMIT_PREDICCIO, 100)
@@ -388,22 +298,17 @@ class MeteocatConfigFlow(ConfigFlow, domain=DOMAIN):
388
298
  LIMIT_XDDE: self.limit_xdde,
389
299
  LIMIT_QUOTA: self.limit_quota,
390
300
  LIMIT_BASIC: self.limit_basic,
391
- },
301
+ }
392
302
  )
393
303
 
394
- schema = vol.Schema(
395
- {
396
- vol.Required(LIMIT_XEMA, default=750): cv.positive_int,
397
- vol.Required(LIMIT_PREDICCIO, default=100): cv.positive_int,
398
- vol.Required(LIMIT_XDDE, default=250): cv.positive_int,
399
- vol.Required(LIMIT_QUOTA, default=300): cv.positive_int,
400
- vol.Required(LIMIT_BASIC, default=2000): cv.positive_int,
401
- }
402
- )
403
-
404
- return self.async_show_form(
405
- step_id="set_api_limits", data_schema=schema, errors=errors
406
- )
304
+ schema = vol.Schema({
305
+ vol.Required(LIMIT_XEMA, default=750): cv.positive_int,
306
+ vol.Required(LIMIT_PREDICCIO, default=100): cv.positive_int,
307
+ vol.Required(LIMIT_XDDE, default=250): cv.positive_int,
308
+ vol.Required(LIMIT_QUOTA, default=300): cv.positive_int,
309
+ vol.Required(LIMIT_BASIC, default=2000): cv.positive_int,
310
+ })
311
+ return self.async_show_form(step_id="set_api_limits", data_schema=schema, errors=errors)
407
312
 
408
313
  @staticmethod
409
314
  @callback
@@ -1268,6 +1268,18 @@ class MeteocatAlertsCoordinator(DataUpdateCoordinator):
1268
1268
  if now - last_update > validity_duration:
1269
1269
  return await self._fetch_and_save_new_data()
1270
1270
  else:
1271
+ # Comprobar si el archivo regional sigue con INITIAL_TEMPLATE o sin datos válidos
1272
+ region_data = await load_json_from_file(self.alerts_region_file)
1273
+ if (
1274
+ not region_data
1275
+ or region_data.get("actualitzat", {}).get("dataUpdate") in [None, "1970-01-01T00:00:00+00:00"]
1276
+ ):
1277
+ _LOGGER.info(
1278
+ "El archivo regional %s sigue con plantilla inicial. Regenerando a partir de alerts.json",
1279
+ self.alerts_region_file,
1280
+ )
1281
+ await self._filter_alerts_by_region()
1282
+
1271
1283
  # Devolver los datos del archivo existente
1272
1284
  _LOGGER.debug("Usando datos existentes de alertas: %s", existing_data)
1273
1285
  return {
@@ -9,5 +9,5 @@
9
9
  "issue_tracker": "https://github.com/figorr/meteocat/issues",
10
10
  "loggers": ["meteocatpy"],
11
11
  "requirements": ["meteocatpy==1.0.1", "packaging>=20.3", "wrapt>=1.14.0"],
12
- "version": "2.2.5"
12
+ "version": "2.2.7"
13
13
  }
@@ -1357,6 +1357,13 @@ class MeteocatAlertRegionSensor(CoordinatorEntity[MeteocatAlertsRegionCoordinato
1357
1357
  # Assign entity_category if defined in the description
1358
1358
  self._attr_entity_category = getattr(description, "entity_category", None)
1359
1359
 
1360
+ def _map_meteor_case_insensitive(self, meteor: str) -> str:
1361
+ """Busca el meteor en el mapping sin importar mayúsculas/minúsculas."""
1362
+ for key, value in self.METEOR_MAPPING.items():
1363
+ if key.lower() == meteor.lower():
1364
+ return value
1365
+ return "unknown"
1366
+
1360
1367
  @property
1361
1368
  def native_value(self):
1362
1369
  """Devuelve el número de alertas activas."""
@@ -1368,10 +1375,13 @@ class MeteocatAlertRegionSensor(CoordinatorEntity[MeteocatAlertsRegionCoordinato
1368
1375
  meteor_details = self.coordinator.data.get("detalles", {}).get("meteor", {})
1369
1376
 
1370
1377
  # Convertimos las claves al formato deseado usando el mapping
1371
- attributes = {
1372
- f"alert_{i+1}": self.METEOR_MAPPING.get(meteor, "unknown")
1373
- for i, meteor in enumerate(meteor_details.keys())
1374
- }
1378
+ attributes = {}
1379
+ for i, meteor in enumerate(meteor_details.keys()):
1380
+ mapped_name = self._map_meteor_case_insensitive(meteor)
1381
+ if not mapped_name:
1382
+ _LOGGER.warning("Meteor desconocido sin mapeo: '%s'. Añadirlo a 'METEOR_MAPPING' del coordinador 'MeteocatAlertRegionSensor' si es necesario.", meteor)
1383
+ mapped_name = "unknown"
1384
+ attributes[f"alert_{i+1}"] = mapped_name
1375
1385
 
1376
1386
  _LOGGER.info("Atributos traducidos del sensor: %s", attributes)
1377
1387
  return attributes
@@ -1461,6 +1471,23 @@ class MeteocatAlertMeteorSensor(CoordinatorEntity[MeteocatAlertsRegionCoordinato
1461
1471
  self._attr_unique_id,
1462
1472
  )
1463
1473
 
1474
+ def _get_meteor_data_case_insensitive(self, meteor_type: str) -> dict:
1475
+ """Busca en los datos de meteor de forma case-insensitive."""
1476
+ meteor_data_dict = self.coordinator.data.get("detalles", {}).get("meteor", {})
1477
+ for key, value in meteor_data_dict.items():
1478
+ if key.lower() == meteor_type.lower():
1479
+ return value
1480
+ return {}
1481
+
1482
+ def _get_umbral_case_insensitive(self, umbral: str) -> str:
1483
+ """Convierte un umbral a su clave interna usando case-insensitive."""
1484
+ if umbral is None:
1485
+ return "unknown"
1486
+ for key, value in self.UMBRAL_MAPPING.items():
1487
+ if key.lower() == umbral.lower():
1488
+ return value
1489
+ return "unknown"
1490
+
1464
1491
  @property
1465
1492
  def native_value(self):
1466
1493
  """Devuelve el estado de la alerta específica."""
@@ -1468,7 +1495,7 @@ class MeteocatAlertMeteorSensor(CoordinatorEntity[MeteocatAlertsRegionCoordinato
1468
1495
  if not meteor_type:
1469
1496
  return "Desconocido"
1470
1497
 
1471
- meteor_data = self.coordinator.data.get("detalles", {}).get("meteor", {}).get(meteor_type, {})
1498
+ meteor_data = self._get_meteor_data_case_insensitive(meteor_type)
1472
1499
 
1473
1500
  # Convertir estado para translation_key
1474
1501
  estado_original = meteor_data.get("estado", "Tancat")
@@ -1479,15 +1506,27 @@ class MeteocatAlertMeteorSensor(CoordinatorEntity[MeteocatAlertsRegionCoordinato
1479
1506
  """Devuelve los atributos específicos de la alerta."""
1480
1507
  meteor_type = self.METEOR_MAPPING.get(self.entity_description.key)
1481
1508
  if not meteor_type:
1482
- return {}
1509
+ _LOGGER.warning(
1510
+ "Tipo de meteor desconocido para sensor %s: '%s'. Añadirlo a 'METEOR_MAPPING' del coordinador 'MeteocatAlertMeteorSensor' si es necesario.",
1511
+ self.entity_description.key,
1512
+ self.coordinator.data.get("detalles", {}).get("meteor", {}).keys(),
1513
+ )
1514
+ return "unknown"
1483
1515
 
1484
- meteor_data = self.coordinator.data.get("detalles", {}).get("meteor", {}).get(meteor_type, {})
1516
+ meteor_data = self._get_meteor_data_case_insensitive(meteor_type)
1485
1517
  if not meteor_data:
1486
1518
  return {}
1487
1519
 
1488
1520
  # Convertir umbral para translation_key
1489
1521
  umbral_original = meteor_data.get("umbral")
1490
- umbral_convertido = self.UMBRAL_MAPPING.get(umbral_original, "unknown")
1522
+ umbral_convertido = self._get_umbral_case_insensitive(umbral_original)
1523
+
1524
+ if umbral_convertido == "unknown" and umbral_original is not None:
1525
+ _LOGGER.warning(
1526
+ "Umbral desconocido para sensor %s: '%s'. Añadirlo a 'UMBRAL_MAPPING' del coordinador 'MeteocatAlertMeteorSensor' si es necesario.",
1527
+ self.entity_description.key,
1528
+ umbral_original
1529
+ )
1491
1530
 
1492
1531
  return {
1493
1532
  "inicio": meteor_data.get("inicio"),
@@ -1,2 +1,2 @@
1
1
  # version.py
2
- __version__ = "2.2.5"
2
+ __version__ = "2.2.7"
package/hacs.json CHANGED
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "name": "Meteocat",
3
- "render_readme": true
4
- }
5
-
3
+ "homeassistant": "2025.3.0",
4
+ "hide_default_branch": true,
5
+ "render_readme": true,
6
+ "zip_release": true,
7
+ "filename": "meteocat.zip"
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meteocat",
3
- "version": "2.2.5",
3
+ "version": "2.2.7",
4
4
  "description": "[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)\r [![Python version compatibility](https://img.shields.io/pypi/pyversions/meteocat)](https://pypi.org/project/meteocat)\r [![pipeline status](https://gitlab.com/figorr/meteocat/badges/master/pipeline.svg)](https://gitlab.com/figorr/meteocat/commits/master)",
5
5
  "main": "index.js",
6
6
  "directories": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "meteocat"
3
- version = "2.2.5"
3
+ version = "2.2.7"
4
4
  description = "Script para obtener datos meteorológicos de la API de Meteocat"
5
5
  authors = ["figorr <jdcuartero@yahoo.es>"]
6
6
  license = "Apache-2.0"