viaggiatreno 0.1.2__tar.gz

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,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # hatch-vcs generated version files
10
+ packages/*/src/*/_version.py
11
+
12
+ # Virtual environments
13
+ .venv
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.5
2
+ Name: viaggiatreno
3
+ Version: 0.1.2
4
+ Summary: ViaggiaTreno API Client - Core Library
5
+ Project-URL: Homepage, https://github.com/matteoredaelli/pyviaggiatreno
6
+ Project-URL: Documentation, https://github.com/matteoredaelli/pyviaggiatreno/blob/main/README.md
7
+ Project-URL: Repository, https://github.com/matteoredaelli/pyviaggiatreno.git
8
+ Project-URL: Issues, https://github.com/matteoredaelli/pyviaggiatreno/issues
9
+ Author-email: Matteo Redaelli <matteo.redaelli@gmail.com>
10
+ License: MIT
11
+ Requires-Python: >=3.12
12
+ Requires-Dist: httpx>=0.28.1
13
+ Requires-Dist: pydantic>=2.13.5
14
+ Description-Content-Type: text/markdown
15
+
16
+ # viaggiatreno
17
+
18
+ Client Python per le API non ufficiali di [ViaggiaTreno](http://www.viaggiatreno.it),
19
+ il sistema informativo in tempo reale della rete ferroviaria italiana.
20
+
21
+ La libreria fornisce:
22
+
23
+ - **`ViaggiaTrenoClient`** — client HTTP asincrono (basato su `httpx`) per tutti
24
+ gli endpoint REST di ViaggiaTreno (stazioni, partenze/arrivi, andamento treni,
25
+ tratte, infomobilità, meteo, statistiche).
26
+ - **Modelli dati tipizzati** (pydantic): `StationDetail`, `TrainBoardItem`,
27
+ `TrainStatus`, `DettaglioTratta`, `Statistics`, e altri.
28
+ - **Parser** per le risposte non-JSON (RSS infomobilità, autocomplete testuali).
29
+
30
+ ## Installazione
31
+
32
+ ```bash
33
+ pip install viaggiatreno
34
+ ```
35
+
36
+ ## Esempio
37
+
38
+ ```python
39
+ import asyncio
40
+ from viaggiatreno import ViaggiaTrenoClient
41
+
42
+
43
+ async def main() -> None:
44
+ async with ViaggiaTrenoClient() as client:
45
+ stazioni = await client.autocompleta_stazione("Milano")
46
+ for s in stazioni:
47
+ print(s)
48
+
49
+
50
+ asyncio.run(main())
51
+ ```
52
+
53
+ ## Licenza
54
+
55
+ Vedi il file `LICENSE` nel repository.
56
+
57
+ Repository: https://github.com/matteoredaelli/pyviaggiatreno
@@ -0,0 +1,42 @@
1
+ # viaggiatreno
2
+
3
+ Client Python per le API non ufficiali di [ViaggiaTreno](http://www.viaggiatreno.it),
4
+ il sistema informativo in tempo reale della rete ferroviaria italiana.
5
+
6
+ La libreria fornisce:
7
+
8
+ - **`ViaggiaTrenoClient`** — client HTTP asincrono (basato su `httpx`) per tutti
9
+ gli endpoint REST di ViaggiaTreno (stazioni, partenze/arrivi, andamento treni,
10
+ tratte, infomobilità, meteo, statistiche).
11
+ - **Modelli dati tipizzati** (pydantic): `StationDetail`, `TrainBoardItem`,
12
+ `TrainStatus`, `DettaglioTratta`, `Statistics`, e altri.
13
+ - **Parser** per le risposte non-JSON (RSS infomobilità, autocomplete testuali).
14
+
15
+ ## Installazione
16
+
17
+ ```bash
18
+ pip install viaggiatreno
19
+ ```
20
+
21
+ ## Esempio
22
+
23
+ ```python
24
+ import asyncio
25
+ from viaggiatreno import ViaggiaTrenoClient
26
+
27
+
28
+ async def main() -> None:
29
+ async with ViaggiaTrenoClient() as client:
30
+ stazioni = await client.autocompleta_stazione("Milano")
31
+ for s in stazioni:
32
+ print(s)
33
+
34
+
35
+ asyncio.run(main())
36
+ ```
37
+
38
+ ## Licenza
39
+
40
+ Vedi il file `LICENSE` nel repository.
41
+
42
+ Repository: https://github.com/matteoredaelli/pyviaggiatreno
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.32", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "viaggiatreno"
7
+ dynamic = ["version"]
8
+ description = "ViaggiaTreno API Client - Core Library"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [
12
+ { name = "Matteo Redaelli", email = "matteo.redaelli@gmail.com" }
13
+ ]
14
+ requires-python = ">=3.12"
15
+ dependencies = [
16
+ "httpx>=0.28.1",
17
+ "pydantic>=2.13.5",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/matteoredaelli/pyviaggiatreno"
22
+ Documentation = "https://github.com/matteoredaelli/pyviaggiatreno/blob/main/README.md"
23
+ Repository = "https://github.com/matteoredaelli/pyviaggiatreno.git"
24
+ Issues = "https://github.com/matteoredaelli/pyviaggiatreno/issues"
25
+
26
+ # Derive the version from Git tags (e.g. tag "v0.5.0" -> version "0.5.0").
27
+ # The repository root is two levels up from this package.
28
+ [tool.hatch.version]
29
+ source = "vcs"
30
+ raw-options = { root = "../.." }
31
+
32
+ # Write the resolved version to a generated module so it is importable at
33
+ # runtime (viaggiatreno.__version__) without re-inspecting Git.
34
+ [tool.hatch.build.hooks.vcs]
35
+ version-file = "src/viaggiatreno/_version.py"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/viaggiatreno"]
@@ -0,0 +1,85 @@
1
+ """ViaggiaTreno API Client - Core library.
2
+
3
+ Libreria per interrogare le API non ufficiali di ViaggiaTreno: client HTTP
4
+ asincrono, parser delle risposte e modelli dati tipizzati (pydantic).
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ from viaggiatreno.api.client import ViaggiaTrenoClient, format_viaggiatreno_datetime
10
+ from viaggiatreno.api.parsers import (
11
+ parse_infomobilita_rss,
12
+ parse_infomobilita_rss_box,
13
+ parse_infomobilita_ticker,
14
+ parse_station_autocomplete,
15
+ parse_station_nts_autocomplete,
16
+ parse_train_autocomplete,
17
+ )
18
+ from viaggiatreno.models.common import (
19
+ CLIENT_COMPANIES,
20
+ REGIONS,
21
+ TRAIN_STATUS_DESCRIPTIONS,
22
+ ViaggiaTrenoBaseModel,
23
+ )
24
+ from viaggiatreno.models.route import DettaglioTratta, TrattaSegment
25
+ from viaggiatreno.models.service import (
26
+ InfomobilitaNews,
27
+ InfomobilitaNewsHeadline,
28
+ StationWeather,
29
+ Statistics,
30
+ )
31
+ from viaggiatreno.models.station import (
32
+ StationAutocompleteItem,
33
+ StationDetail,
34
+ StationNTSAutocompleteItem,
35
+ StationSearchResult,
36
+ )
37
+ from viaggiatreno.models.train import (
38
+ Fermata,
39
+ TrainAutocompleteItem,
40
+ TrainBoardItem,
41
+ TrainSearchResult,
42
+ TrainStatus,
43
+ TrattaCanvasItem,
44
+ )
45
+
46
+ try:
47
+ # Generated at build time by hatch-vcs from the Git tag.
48
+ from viaggiatreno._version import __version__ # type: ignore[import-not-found,unused-ignore]
49
+ except ImportError: # pragma: no cover - only when running from an unbuilt tree
50
+ try:
51
+ __version__ = version("viaggiatreno")
52
+ except PackageNotFoundError:
53
+ __version__ = "0.0.0.dev0"
54
+
55
+ __all__ = [
56
+ "CLIENT_COMPANIES",
57
+ "REGIONS",
58
+ "TRAIN_STATUS_DESCRIPTIONS",
59
+ "DettaglioTratta",
60
+ "Fermata",
61
+ "InfomobilitaNews",
62
+ "InfomobilitaNewsHeadline",
63
+ "StationAutocompleteItem",
64
+ "StationDetail",
65
+ "StationNTSAutocompleteItem",
66
+ "StationSearchResult",
67
+ "StationWeather",
68
+ "Statistics",
69
+ "TrainAutocompleteItem",
70
+ "TrainBoardItem",
71
+ "TrainSearchResult",
72
+ "TrainStatus",
73
+ "TrattaCanvasItem",
74
+ "TrattaSegment",
75
+ "ViaggiaTrenoBaseModel",
76
+ "ViaggiaTrenoClient",
77
+ "__version__",
78
+ "format_viaggiatreno_datetime",
79
+ "parse_infomobilita_rss",
80
+ "parse_infomobilita_rss_box",
81
+ "parse_infomobilita_ticker",
82
+ "parse_station_autocomplete",
83
+ "parse_station_nts_autocomplete",
84
+ "parse_train_autocomplete",
85
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.2'
22
+ __version_tuple__ = version_tuple = (0, 1, 2)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,22 @@
1
+ """Package API per la libreria ViaggiaTreno."""
2
+
3
+ from viaggiatreno.api.client import ViaggiaTrenoClient, format_viaggiatreno_datetime
4
+ from viaggiatreno.api.parsers import (
5
+ parse_infomobilita_rss,
6
+ parse_infomobilita_rss_box,
7
+ parse_infomobilita_ticker,
8
+ parse_station_autocomplete,
9
+ parse_station_nts_autocomplete,
10
+ parse_train_autocomplete,
11
+ )
12
+
13
+ __all__ = [
14
+ "ViaggiaTrenoClient",
15
+ "format_viaggiatreno_datetime",
16
+ "parse_infomobilita_rss",
17
+ "parse_infomobilita_rss_box",
18
+ "parse_infomobilita_ticker",
19
+ "parse_station_autocomplete",
20
+ "parse_station_nts_autocomplete",
21
+ "parse_train_autocomplete",
22
+ ]
@@ -0,0 +1,355 @@
1
+ """Client asincrono per le API di ViaggiaTreno."""
2
+
3
+ import time
4
+ from datetime import UTC, datetime
5
+ from types import TracebackType
6
+ from typing import Self
7
+ from urllib.parse import quote
8
+
9
+ import httpx
10
+
11
+ from viaggiatreno.api.parsers import (
12
+ parse_infomobilita_rss,
13
+ parse_infomobilita_rss_box,
14
+ parse_infomobilita_ticker,
15
+ parse_station_autocomplete,
16
+ parse_station_nts_autocomplete,
17
+ parse_train_autocomplete,
18
+ )
19
+ from viaggiatreno.models.route import DettaglioTratta, TrattaSegment
20
+ from viaggiatreno.models.service import (
21
+ InfomobilitaNews,
22
+ InfomobilitaNewsHeadline,
23
+ StationWeather,
24
+ Statistics,
25
+ )
26
+ from viaggiatreno.models.station import (
27
+ StationAutocompleteItem,
28
+ StationDetail,
29
+ StationNTSAutocompleteItem,
30
+ StationSearchResult,
31
+ )
32
+ from viaggiatreno.models.train import (
33
+ TrainAutocompleteItem,
34
+ TrainBoardItem,
35
+ TrainSearchResult,
36
+ TrainStatus,
37
+ TrattaCanvasItem,
38
+ )
39
+
40
+ DEFAULT_BASE_URL = "http://www.viaggiatreno.it/infomobilita/resteasy/viaggiatreno"
41
+ DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
42
+
43
+
44
+ def format_viaggiatreno_datetime(dt: datetime | str | None = None) -> str:
45
+ """Formatta la data/ora nel formato atteso dagli endpoint partenze/arrivi.
46
+
47
+ Esempio formato ViaggiaTreno: Sun Oct 04 2026 08:12:27 GMT+0200 (URL-encoded).
48
+ """
49
+ if dt is None:
50
+ now = datetime.now(UTC).astimezone()
51
+ formatted = now.strftime("%a %b %d %Y %H:%M:%S GMT%z")
52
+ elif isinstance(dt, datetime):
53
+ formatted = dt.strftime("%a %b %d %Y %H:%M:%S GMT%z")
54
+ else:
55
+ # Se è già stringa
56
+ formatted = dt
57
+
58
+ return quote(formatted)
59
+
60
+
61
+ class ViaggiaTrenoClient:
62
+ """Client asincrono per tutte le chiamate REST di ViaggiaTreno."""
63
+
64
+ def __init__(
65
+ self,
66
+ base_url: str = DEFAULT_BASE_URL,
67
+ timeout: float = 20.0,
68
+ client: httpx.AsyncClient | None = None,
69
+ ) -> None:
70
+ self.base_url = base_url.rstrip("/")
71
+ self.timeout = timeout
72
+ self._external_client = client
73
+ self._client: httpx.AsyncClient | None = client
74
+
75
+ async def _get_client(self) -> httpx.AsyncClient:
76
+ if self._client is None or self._client.is_closed:
77
+ self._client = httpx.AsyncClient(
78
+ timeout=self.timeout,
79
+ headers={"User-Agent": DEFAULT_USER_AGENT},
80
+ follow_redirects=True,
81
+ )
82
+ return self._client
83
+
84
+ async def close(self) -> None:
85
+ if self._client and not self._external_client and not self._client.is_closed:
86
+ await self._client.aclose()
87
+
88
+ async def __aenter__(self) -> Self:
89
+ await self._get_client()
90
+ return self
91
+
92
+ async def __aexit__(
93
+ self,
94
+ exc_type: type[BaseException] | None,
95
+ exc_val: BaseException | None,
96
+ exc_tb: TracebackType | None,
97
+ ) -> None:
98
+ await self.close()
99
+
100
+ async def _request(self, path: str) -> httpx.Response:
101
+ url = f"{self.base_url}/{path.lstrip('/')}"
102
+ client = await self._get_client()
103
+ response = await client.get(url)
104
+ return response
105
+
106
+ # -------------------------------------------------------------------------
107
+ # STAZIONI
108
+ # -------------------------------------------------------------------------
109
+
110
+ async def autocompleta_stazione(self, prefisso: str) -> list[StationAutocompleteItem]:
111
+ """Suggerimenti stazioni per prefisso (GET /autocompletaStazione/{prefisso})."""
112
+ r = await self._request(f"autocompletaStazione/{quote(prefisso)}")
113
+ if r.status_code == 204 or not r.text.strip():
114
+ return []
115
+ r.raise_for_status()
116
+ return parse_station_autocomplete(r.text)
117
+
118
+ async def autocompleta_stazione_imposta_viaggio(
119
+ self, prefisso: str
120
+ ) -> list[StationAutocompleteItem]:
121
+ """Suggerimenti stazioni per imposta viaggio (GET /autocompletaStazioneImpostaViaggio/{prefisso})."""
122
+ r = await self._request(f"autocompletaStazioneImpostaViaggio/{quote(prefisso)}")
123
+ if r.status_code == 204 or not r.text.strip():
124
+ return []
125
+ r.raise_for_status()
126
+ return parse_station_autocomplete(r.text)
127
+
128
+ async def autocompleta_stazione_nts(self, prefisso: str) -> list[StationNTSAutocompleteItem]:
129
+ """Suggerimenti stazioni e posti tecnici con codice NTS RICS (GET /autocompletaStazioneNTS/{prefisso})."""
130
+ r = await self._request(f"autocompletaStazioneNTS/{quote(prefisso)}")
131
+ if r.status_code == 204 or not r.text.strip():
132
+ return []
133
+ r.raise_for_status()
134
+ return parse_station_nts_autocomplete(r.text)
135
+
136
+ async def cerca_stazione(self, prefisso: str) -> list[StationSearchResult]:
137
+ """Ricerca stazioni con risposta JSON strutturata (GET /cercaStazione/{prefisso})."""
138
+ r = await self._request(f"cercaStazione/{quote(prefisso)}")
139
+ if r.status_code == 204 or not r.text.strip():
140
+ return []
141
+ r.raise_for_status()
142
+ data = r.json()
143
+ return [StationSearchResult.model_validate(item) for item in data]
144
+
145
+ async def regione(self, codice_stazione: str) -> int | None:
146
+ """Codice regione numerico di una stazione (GET /regione/{codiceStazione})."""
147
+ r = await self._request(f"regione/{quote(codice_stazione)}")
148
+ if r.status_code == 204 or not r.text.strip():
149
+ return None
150
+ r.raise_for_status()
151
+ try:
152
+ return int(r.text.strip())
153
+ except ValueError:
154
+ return None
155
+
156
+ async def dettaglio_stazione(
157
+ self, codice_stazione: str, codice_regione: int
158
+ ) -> StationDetail | None:
159
+ """Dettagli e coordinate di una stazione (GET /dettaglioStazione/{codiceStazione}/{codiceRegione})."""
160
+ r = await self._request(f"dettaglioStazione/{quote(codice_stazione)}/{codice_regione}")
161
+ if r.status_code == 204 or not r.text.strip():
162
+ return None
163
+ r.raise_for_status()
164
+ data = r.json()
165
+ return StationDetail.model_validate(data)
166
+
167
+ async def elenco_stazioni(self, codice_regione: int) -> list[StationDetail]:
168
+ """Elenco completo stazioni di una regione (GET /elencoStazioni/{codiceRegione})."""
169
+ r = await self._request(f"elencoStazioni/{codice_regione}")
170
+ if r.status_code == 204 or not r.text.strip():
171
+ return []
172
+ r.raise_for_status()
173
+ data = r.json()
174
+ return [StationDetail.model_validate(item) for item in data]
175
+
176
+ # -------------------------------------------------------------------------
177
+ # PARTENZE E ARRIVI
178
+ # -------------------------------------------------------------------------
179
+
180
+ async def partenze(
181
+ self,
182
+ codice_stazione: str,
183
+ data_ora: datetime | str | None = None,
184
+ ) -> list[TrainBoardItem]:
185
+ """Tabellone partenze da una stazione (GET /partenze/{codiceStazione}/{dataOra})."""
186
+ encoded_date = format_viaggiatreno_datetime(data_ora)
187
+ r = await self._request(f"partenze/{quote(codice_stazione)}/{encoded_date}")
188
+ if r.status_code == 204 or not r.text.strip():
189
+ return []
190
+ r.raise_for_status()
191
+ data = r.json()
192
+ return [TrainBoardItem.model_validate(item) for item in data]
193
+
194
+ async def arrivi(
195
+ self,
196
+ codice_stazione: str,
197
+ data_ora: datetime | str | None = None,
198
+ ) -> list[TrainBoardItem]:
199
+ """Tabellone arrivi in una stazione (GET /arrivi/{codiceStazione}/{dataOra})."""
200
+ encoded_date = format_viaggiatreno_datetime(data_ora)
201
+ r = await self._request(f"arrivi/{quote(codice_stazione)}/{encoded_date}")
202
+ if r.status_code == 204 or not r.text.strip():
203
+ return []
204
+ r.raise_for_status()
205
+ data = r.json()
206
+ return [TrainBoardItem.model_validate(item) for item in data]
207
+
208
+ # -------------------------------------------------------------------------
209
+ # TRENI
210
+ # -------------------------------------------------------------------------
211
+
212
+ async def cerca_numero_treno_autocomplete(
213
+ self, numero_treno: int | str
214
+ ) -> list[TrainAutocompleteItem]:
215
+ """Corse attive per un numero treno (GET /cercaNumeroTrenoTrenoAutocomplete/{numeroTreno})."""
216
+ r = await self._request(f"cercaNumeroTrenoTrenoAutocomplete/{numero_treno}")
217
+ if r.status_code == 204 or not r.text.strip():
218
+ return []
219
+ r.raise_for_status()
220
+ return parse_train_autocomplete(r.text)
221
+
222
+ async def cerca_numero_treno(self, numero_treno: int | str) -> TrainSearchResult | None:
223
+ """Identificativi corsa corrente per un treno in JSON (GET /cercaNumeroTreno/{numeroTreno})."""
224
+ r = await self._request(f"cercaNumeroTreno/{numero_treno}")
225
+ if r.status_code == 204 or not r.text.strip():
226
+ return None
227
+ r.raise_for_status()
228
+ data = r.json()
229
+ return TrainSearchResult.model_validate(data)
230
+
231
+ async def andamento_treno(
232
+ self,
233
+ codice_stazione_origine: str,
234
+ numero_treno: int | str,
235
+ data_partenza: int | str,
236
+ ) -> TrainStatus | None:
237
+ """Stato dettagliato treno e fermate (GET /andamentoTreno/{codiceStazioneOrigine}/{numeroTreno}/{dataPartenza})."""
238
+ r = await self._request(
239
+ f"andamentoTreno/{quote(codice_stazione_origine)}/{numero_treno}/{data_partenza}"
240
+ )
241
+ if r.status_code == 204 or not r.text.strip():
242
+ return None
243
+ r.raise_for_status()
244
+ data = r.json()
245
+ return TrainStatus.model_validate(data)
246
+
247
+ async def tratte_canvas(
248
+ self,
249
+ codice_stazione_origine: str,
250
+ numero_treno: int | str,
251
+ data_partenza: int | str,
252
+ ) -> list[TrattaCanvasItem]:
253
+ """Itinerario visuale ad albero (GET /tratteCanvas/{codiceStazioneOrigine}/{numeroTreno}/{dataPartenza})."""
254
+ r = await self._request(
255
+ f"tratteCanvas/{quote(codice_stazione_origine)}/{numero_treno}/{data_partenza}"
256
+ )
257
+ if r.status_code == 204 or not r.text.strip():
258
+ return []
259
+ r.raise_for_status()
260
+ data = r.json()
261
+ return [TrattaCanvasItem.model_validate(item) for item in data]
262
+
263
+ # -------------------------------------------------------------------------
264
+ # TRATTE E RETE
265
+ # -------------------------------------------------------------------------
266
+
267
+ async def elenco_tratte(
268
+ self,
269
+ categorie: str = "ES*,IC,EXP,EC,EN,REG",
270
+ timestamp: int | None = None,
271
+ ) -> list[TrattaSegment]:
272
+ """Segmenti della rete e occupazione da treni (GET /elencoTratte/0/6/{categorie}/null/{timestamp})."""
273
+ ts = timestamp if timestamp is not None else int(time.time() * 1000)
274
+ r = await self._request(f"elencoTratte/0/6/{quote(categorie)}/null/{ts}")
275
+ if r.status_code == 204 or not r.text.strip():
276
+ return []
277
+ r.raise_for_status()
278
+ data = r.json()
279
+ return [TrattaSegment.model_validate(item) for item in data]
280
+
281
+ async def dettagli_tratta(
282
+ self,
283
+ tratta_ab: int,
284
+ tratta_ba: int,
285
+ categorie: str = "ES*,IC,EXP,EC,EN,REG",
286
+ ) -> list[DettaglioTratta]:
287
+ """Treni presenti su un segmento (GET /dettagliTratta/0/{trattaAB}/{trattaBA}/{categorie}/null)."""
288
+ r = await self._request(f"dettagliTratta/0/{tratta_ab}/{tratta_ba}/{quote(categorie)}/null")
289
+ if r.status_code == 204 or not r.text.strip():
290
+ return []
291
+ r.raise_for_status()
292
+ data = r.json()
293
+ return [DettaglioTratta.model_validate(item) for item in data]
294
+
295
+ # -------------------------------------------------------------------------
296
+ # INFORMAZIONI DI SERVIZIO
297
+ # -------------------------------------------------------------------------
298
+
299
+ async def statistiche(self, timestamp: int | None = None) -> Statistics | None:
300
+ """Statistiche treni circolanti e programmati (GET /statistiche/{timestamp})."""
301
+ ts = timestamp if timestamp is not None else int(time.time() * 1000)
302
+ r = await self._request(f"statistiche/{ts}")
303
+ if r.status_code == 204 or not r.text.strip():
304
+ return None
305
+ r.raise_for_status()
306
+ data = r.json()
307
+ return Statistics.model_validate(data)
308
+
309
+ async def datimeteo(self, codice_regione: int) -> dict[str, StationWeather]:
310
+ """Previsioni meteo per stazioni di una regione (GET /datimeteo/{codiceRegione})."""
311
+ r = await self._request(f"datimeteo/{codice_regione}")
312
+ if r.status_code == 204 or not r.text.strip():
313
+ return {}
314
+ r.raise_for_status()
315
+ data = r.json()
316
+ results: dict[str, StationWeather] = {}
317
+ for station_id, weather_dict in data.items():
318
+ results[station_id] = StationWeather.model_validate(weather_dict)
319
+ return results
320
+
321
+ async def infomobilita_rss(self, is_info_lavori: bool = False) -> list[InfomobilitaNews]:
322
+ """Notizie circolazione o lavori programmati (GET /infomobilitaRSS/{isInfoLavori})."""
323
+ param = "true" if is_info_lavori else "false"
324
+ r = await self._request(f"infomobilitaRSS/{param}")
325
+ if r.status_code == 204 or not r.text.strip():
326
+ return []
327
+ r.raise_for_status()
328
+ return parse_infomobilita_rss(r.text)
329
+
330
+ async def infomobilita_rss_box(
331
+ self, is_info_lavori: bool = False
332
+ ) -> list[InfomobilitaNewsHeadline]:
333
+ """Titoli sintetici delle notizie di infomobilità (GET /infomobilitaRSSBox/{isInfoLavori})."""
334
+ param = "true" if is_info_lavori else "false"
335
+ r = await self._request(f"infomobilitaRSSBox/{param}")
336
+ if r.status_code == 204 or not r.text.strip():
337
+ return []
338
+ r.raise_for_status()
339
+ return parse_infomobilita_rss_box(r.text)
340
+
341
+ async def infomobilita_ticker(self) -> list[str]:
342
+ """Avvisi brevi del ticker scorrevole (GET /infomobilitaTicker)."""
343
+ r = await self._request("infomobilitaTicker")
344
+ if r.status_code == 204 or not r.text.strip():
345
+ return []
346
+ r.raise_for_status()
347
+ return parse_infomobilita_ticker(r.text)
348
+
349
+ async def language(self, id_lingua: str = "it") -> dict[str, str]:
350
+ """Dizionario di traduzione dell'interfaccia (GET /language/{idLingua})."""
351
+ r = await self._request(f"language/{quote(id_lingua)}")
352
+ if r.status_code == 204 or not r.text.strip():
353
+ return {}
354
+ r.raise_for_status()
355
+ return r.json()