pyunipolsai 0.1.0__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,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ .hypothesis/
7
+ .venv/
8
+ venv/
9
+ .DS_Store
10
+
11
+ # reverse engineering working directories
12
+ apk/
13
+ decompiled/
14
+ captures/
15
+ .env.local
16
+ dist/
17
+ *.egg-info/
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyunipolsai
3
+ Version: 0.1.0
4
+ Summary: Async client for the Unipol (UnipolSai) Unibox telematics API
5
+ Project-URL: Homepage, https://github.com/fezvrasta/ha-unipolsai
6
+ Project-URL: Issues, https://github.com/fezvrasta/ha-unipolsai/issues
7
+ Author: Federico Zivolo
8
+ License-Expression: MIT
9
+ Keywords: gps,telematics,unibox,unipol,unipolsai
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: aiohttp>=3.9
18
+ Provides-Extra: test
19
+ Requires-Dist: aioresponses>=0.7; extra == 'test'
20
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
21
+ Requires-Dist: pytest>=8; extra == 'test'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pyunipolsai
25
+
26
+ Async Python client for the Unipol (UnipolSai) **Unibox** telematics API: vehicle position, driving statistics, and alert events for the black box fitted under an Italian Unipol motor policy.
27
+
28
+ Built by reverse engineering the Android app and verifying every call against a live account. The findings behind it are written up in [`docs/FINDINGS.md`](https://github.com/FezVrasta/ha-unipolsai/blob/main/docs/FINDINGS.md).
29
+
30
+ ```python
31
+ import asyncio
32
+ from pyunipolsai import UnipolSaiClient
33
+
34
+
35
+ async def main():
36
+ async with UnipolSaiClient(username="you@example.com", password="...") as client:
37
+ for vehicle in await client.async_get_vehicles():
38
+ position = await client.async_get_position(vehicle)
39
+ print(
40
+ vehicle.plate,
41
+ position.latitude,
42
+ position.longitude,
43
+ position.timestamp,
44
+ f"{position.quota.remaining} refreshes left",
45
+ )
46
+
47
+
48
+ asyncio.run(main())
49
+ ```
50
+
51
+ ## What it does
52
+
53
+ | Method | Returns | Cost |
54
+ |---|---|---|
55
+ | `async_get_vehicles()` | `list[Vehicle]`, usable ones by default | free |
56
+ | `async_get_position(vehicle)` | `Position` | free |
57
+ | `async_get_position(vehicle, force_refresh=True)` | `Position` | **spends daily quota** |
58
+ | `async_get_services(vehicle)` | `dict[str, Service]` | free |
59
+ | `async_get_notifications(vehicle, service)` | `list[Notification]` | free |
60
+ | `async_get_usage(vehicle)` | `UsageStats` | free |
61
+
62
+ ## Things this API does that will surprise you
63
+
64
+ The library handles all of these; they're listed so the behaviour isn't mistaken for a bug.
65
+
66
+ **A bearer token is not enough.** Login also sets F5 BIG-IP session cookies that must ride on every request. The client keeps one cookie-persisting session and treats a 403 as "log in again" rather than "refresh the token". A stored long-lived token is not a workable auth model here.
67
+
68
+ **Plates are country-prefixed** in URLs (`IT-AB123CD`). Pass a `Vehicle` or a bare plate; `normalise_plate` sorts it out.
69
+
70
+ **Declared types lie.** Dates are declared `String` in the app but sent as epoch milliseconds. Distances are metres, times are seconds. `heading` is a cardinal letter (`"N"`), not degrees. `accuracy` is a small quality grade, *not* a radius, which is why this library calls it `Position.quality` — mapping a `1` onto something like Home Assistant's `gps_accuracy` would claim a one-metre fix.
71
+
72
+ **There are two separate budgets.** `Position.quota` is the daily cap on *forced* refreshes (observed limit: 5). Plain reads never touch it. Separately, `Service.credits_available` is a slower pool that pays for having a service switched on. `rangeStatistics` needs no credits at all, so driving statistics are free to poll.
73
+
74
+ **`force_refresh=True` is fire and forget.** It returns immediately with the *old* position and `pending_request` False; the flag goes true a few seconds later and the cycle takes roughly five minutes. It can finish without the position changing, which is what a car parked with the engine off looks like — and that costs no quota. Compare `timestamp` against the value you had before to tell success from give-up.
75
+
76
+ **`async_get_notifications` returns only the most recent event** despite the plural field name, so two events inside one poll interval collapse into one. The app receives these as push; there is no push path here.
77
+
78
+ **`date_range` is a single letter.** `g` is the whole contract period, `t` is a custom range needing epoch-millisecond `start`/`end`. Word-style values like `LAST_MONTH` are rejected.
79
+
80
+ ## Gateway credentials
81
+
82
+ Requests carry the app's IBM API Connect credentials (`x-ibm-client-id`, `x-ibm-client-secret`, `x-unipol-tenant`). Working defaults ship in `const.py`: they are app-global rather than per-user, and travel inside a public Play Store app, so they are no more secret than any baked-in mobile API key.
83
+
84
+ Unipol can rotate them. Override them if that happens, without waiting for a release:
85
+
86
+ ```python
87
+ UnipolSaiClient(
88
+ username=..., password=..., client_id=..., client_secret=..., tenant=...
89
+ )
90
+ ```
91
+
92
+ [`docs/CAPTURE.md`](https://github.com/FezVrasta/ha-unipolsai/blob/main/docs/CAPTURE.md) explains how to capture new ones. It needs no account.
93
+
94
+ ## Install
95
+
96
+ ```bash
97
+ pip install pyunipolsai
98
+ ```
99
+
100
+ Developed in the [`ha-unipolsai`](https://github.com/FezVrasta/ha-unipolsai) repository
101
+ alongside the Home Assistant integration that uses it, and released from a
102
+ `pyunipolsai-vX.Y.Z` tag there.
103
+
104
+ ## Tests
105
+
106
+ ```bash
107
+ pip install -e '.[test]'
108
+ pytest
109
+ ```
110
+
111
+ The fixtures are real captured payloads with identifiers replaced.
112
+
113
+ ## Scope
114
+
115
+ This reads an account holder's own data. It breaks no pinning, defeats no authentication, and circumvents no protection. Respect the quota, don't hammer the gateway, and check Unipol's terms before building anything public on it.
@@ -0,0 +1,92 @@
1
+ # pyunipolsai
2
+
3
+ Async Python client for the Unipol (UnipolSai) **Unibox** telematics API: vehicle position, driving statistics, and alert events for the black box fitted under an Italian Unipol motor policy.
4
+
5
+ Built by reverse engineering the Android app and verifying every call against a live account. The findings behind it are written up in [`docs/FINDINGS.md`](https://github.com/FezVrasta/ha-unipolsai/blob/main/docs/FINDINGS.md).
6
+
7
+ ```python
8
+ import asyncio
9
+ from pyunipolsai import UnipolSaiClient
10
+
11
+
12
+ async def main():
13
+ async with UnipolSaiClient(username="you@example.com", password="...") as client:
14
+ for vehicle in await client.async_get_vehicles():
15
+ position = await client.async_get_position(vehicle)
16
+ print(
17
+ vehicle.plate,
18
+ position.latitude,
19
+ position.longitude,
20
+ position.timestamp,
21
+ f"{position.quota.remaining} refreshes left",
22
+ )
23
+
24
+
25
+ asyncio.run(main())
26
+ ```
27
+
28
+ ## What it does
29
+
30
+ | Method | Returns | Cost |
31
+ |---|---|---|
32
+ | `async_get_vehicles()` | `list[Vehicle]`, usable ones by default | free |
33
+ | `async_get_position(vehicle)` | `Position` | free |
34
+ | `async_get_position(vehicle, force_refresh=True)` | `Position` | **spends daily quota** |
35
+ | `async_get_services(vehicle)` | `dict[str, Service]` | free |
36
+ | `async_get_notifications(vehicle, service)` | `list[Notification]` | free |
37
+ | `async_get_usage(vehicle)` | `UsageStats` | free |
38
+
39
+ ## Things this API does that will surprise you
40
+
41
+ The library handles all of these; they're listed so the behaviour isn't mistaken for a bug.
42
+
43
+ **A bearer token is not enough.** Login also sets F5 BIG-IP session cookies that must ride on every request. The client keeps one cookie-persisting session and treats a 403 as "log in again" rather than "refresh the token". A stored long-lived token is not a workable auth model here.
44
+
45
+ **Plates are country-prefixed** in URLs (`IT-AB123CD`). Pass a `Vehicle` or a bare plate; `normalise_plate` sorts it out.
46
+
47
+ **Declared types lie.** Dates are declared `String` in the app but sent as epoch milliseconds. Distances are metres, times are seconds. `heading` is a cardinal letter (`"N"`), not degrees. `accuracy` is a small quality grade, *not* a radius, which is why this library calls it `Position.quality` — mapping a `1` onto something like Home Assistant's `gps_accuracy` would claim a one-metre fix.
48
+
49
+ **There are two separate budgets.** `Position.quota` is the daily cap on *forced* refreshes (observed limit: 5). Plain reads never touch it. Separately, `Service.credits_available` is a slower pool that pays for having a service switched on. `rangeStatistics` needs no credits at all, so driving statistics are free to poll.
50
+
51
+ **`force_refresh=True` is fire and forget.** It returns immediately with the *old* position and `pending_request` False; the flag goes true a few seconds later and the cycle takes roughly five minutes. It can finish without the position changing, which is what a car parked with the engine off looks like — and that costs no quota. Compare `timestamp` against the value you had before to tell success from give-up.
52
+
53
+ **`async_get_notifications` returns only the most recent event** despite the plural field name, so two events inside one poll interval collapse into one. The app receives these as push; there is no push path here.
54
+
55
+ **`date_range` is a single letter.** `g` is the whole contract period, `t` is a custom range needing epoch-millisecond `start`/`end`. Word-style values like `LAST_MONTH` are rejected.
56
+
57
+ ## Gateway credentials
58
+
59
+ Requests carry the app's IBM API Connect credentials (`x-ibm-client-id`, `x-ibm-client-secret`, `x-unipol-tenant`). Working defaults ship in `const.py`: they are app-global rather than per-user, and travel inside a public Play Store app, so they are no more secret than any baked-in mobile API key.
60
+
61
+ Unipol can rotate them. Override them if that happens, without waiting for a release:
62
+
63
+ ```python
64
+ UnipolSaiClient(
65
+ username=..., password=..., client_id=..., client_secret=..., tenant=...
66
+ )
67
+ ```
68
+
69
+ [`docs/CAPTURE.md`](https://github.com/FezVrasta/ha-unipolsai/blob/main/docs/CAPTURE.md) explains how to capture new ones. It needs no account.
70
+
71
+ ## Install
72
+
73
+ ```bash
74
+ pip install pyunipolsai
75
+ ```
76
+
77
+ Developed in the [`ha-unipolsai`](https://github.com/FezVrasta/ha-unipolsai) repository
78
+ alongside the Home Assistant integration that uses it, and released from a
79
+ `pyunipolsai-vX.Y.Z` tag there.
80
+
81
+ ## Tests
82
+
83
+ ```bash
84
+ pip install -e '.[test]'
85
+ pytest
86
+ ```
87
+
88
+ The fixtures are real captured payloads with identifiers replaced.
89
+
90
+ ## Scope
91
+
92
+ This reads an account holder's own data. It breaks no pinning, defeats no authentication, and circumvents no protection. Respect the quota, don't hammer the gateway, and check Unipol's terms before building anything public on it.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pyunipolsai"
7
+ version = "0.1.0"
8
+ description = "Async client for the Unipol (UnipolSai) Unibox telematics API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ authors = [{ name = "Federico Zivolo" }]
13
+ keywords = ["unipol", "unipolsai", "unibox", "telematics", "gps"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Software Development :: Libraries",
21
+ ]
22
+ dependencies = ["aiohttp>=3.9"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/fezvrasta/ha-unipolsai"
26
+ Issues = "https://github.com/fezvrasta/ha-unipolsai/issues"
27
+
28
+ [project.optional-dependencies]
29
+ test = ["pytest>=8", "pytest-asyncio>=0.23", "aioresponses>=0.7"]
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/pyunipolsai"]
33
+
34
+ [tool.pytest.ini_options]
35
+ asyncio_mode = "auto"
36
+ testpaths = ["tests"]
@@ -0,0 +1,58 @@
1
+ """Async client for the Unipol (UnipolSai) Unibox telematics API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .client import UnipolSaiClient
6
+ from .const import (
7
+ ALERT_SERVICES,
8
+ BASE_URL,
9
+ DEFAULT_CLIENT_ID,
10
+ DEFAULT_CLIENT_SECRET,
11
+ DEFAULT_TENANT,
12
+ DEFAULT_USER_AGENT,
13
+ SERVICE_CAR_FINDER,
14
+ SERVICE_RANGE_STATISTICS,
15
+ )
16
+ from .exceptions import (
17
+ UnipolSaiAuthError,
18
+ UnipolSaiConnectionError,
19
+ UnipolSaiError,
20
+ )
21
+ from .models import (
22
+ HEADING_DEGREES,
23
+ Notification,
24
+ Position,
25
+ Quota,
26
+ Service,
27
+ TelematicDevice,
28
+ UsageStats,
29
+ Vehicle,
30
+ normalise_plate,
31
+ )
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = [
36
+ "ALERT_SERVICES",
37
+ "BASE_URL",
38
+ "DEFAULT_CLIENT_ID",
39
+ "DEFAULT_CLIENT_SECRET",
40
+ "DEFAULT_TENANT",
41
+ "DEFAULT_USER_AGENT",
42
+ "HEADING_DEGREES",
43
+ "SERVICE_CAR_FINDER",
44
+ "SERVICE_RANGE_STATISTICS",
45
+ "Notification",
46
+ "Position",
47
+ "Quota",
48
+ "Service",
49
+ "TelematicDevice",
50
+ "UnipolSaiAuthError",
51
+ "UnipolSaiClient",
52
+ "UnipolSaiConnectionError",
53
+ "UnipolSaiError",
54
+ "UsageStats",
55
+ "Vehicle",
56
+ "__version__",
57
+ "normalise_plate",
58
+ ]
@@ -0,0 +1,284 @@
1
+ """Async client for the Unipol apphub telematics API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import uuid
7
+ from typing import Any, Self
8
+
9
+ import aiohttp
10
+
11
+ from .const import (
12
+ BASE_URL,
13
+ DEFAULT_CLIENT_ID,
14
+ DEFAULT_CLIENT_SECRET,
15
+ DEFAULT_TENANT,
16
+ DEFAULT_USER_AGENT,
17
+ )
18
+ from .exceptions import UnipolSaiAuthError, UnipolSaiConnectionError, UnipolSaiError
19
+ from .models import (
20
+ Notification,
21
+ Position,
22
+ Service,
23
+ UsageStats,
24
+ Vehicle,
25
+ normalise_plate,
26
+ )
27
+
28
+ _LOGGER = logging.getLogger(__name__)
29
+
30
+
31
+ class UnipolSaiClient:
32
+ """Talks to `apphub.unipolsai.it`.
33
+
34
+ Three things about this API are not guessable from the app's code and
35
+ will produce confusing failures if changed:
36
+
37
+ * **The bearer token alone is not enough.** Login also sets F5 BIG-IP
38
+ session cookies (`MRHSession`, `JSESSIONID`, `TS*`) that must ride on
39
+ every subsequent request. Everything therefore shares one
40
+ cookie-persisting session, and a 403 means "log in again", not
41
+ "refresh the token". A stored long-lived token is not a workable
42
+ auth model.
43
+ * **Plates are country-prefixed** in the path: `IT-AB123CD`.
44
+ * **Telematics calls need `service_type` and `company_id` headers.**
45
+
46
+ Usage::
47
+
48
+ async with UnipolSaiClient(username="...", password="...") as client:
49
+ for vehicle in await client.async_get_vehicles():
50
+ print(await client.async_get_position(vehicle))
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ username: str,
56
+ password: str,
57
+ *,
58
+ session: aiohttp.ClientSession | None = None,
59
+ client_id: str = DEFAULT_CLIENT_ID,
60
+ client_secret: str = DEFAULT_CLIENT_SECRET,
61
+ tenant: str = DEFAULT_TENANT,
62
+ user_agent: str = DEFAULT_USER_AGENT,
63
+ ) -> None:
64
+ """Create a client.
65
+
66
+ `session` must have a cookie jar; if you pass one, don't share it with
67
+ unrelated traffic. Omit it and the client owns its own.
68
+ """
69
+ self._username = username
70
+ self._password = password
71
+ self._client_id = client_id
72
+ self._client_secret = client_secret
73
+ self._tenant = tenant
74
+ self._user_agent = user_agent
75
+ self._session = session
76
+ self._owns_session = session is None
77
+ self._token: str | None = None
78
+
79
+ async def __aenter__(self) -> Self:
80
+ """Enter the context manager."""
81
+ return self
82
+
83
+ async def __aexit__(self, *_: object) -> None:
84
+ """Close the session on exit."""
85
+ await self.async_close()
86
+
87
+ async def async_close(self) -> None:
88
+ """Close the session, if this client created it."""
89
+ if self._owns_session and self._session is not None:
90
+ await self._session.close()
91
+ self._session = None
92
+
93
+ # -- plumbing ---------------------------------------------------------
94
+
95
+ @property
96
+ def _http(self) -> aiohttp.ClientSession:
97
+ if self._session is None:
98
+ self._session = aiohttp.ClientSession()
99
+ return self._session
100
+
101
+ def _headers(self, telematics: bool = False) -> dict[str, str]:
102
+ headers = {
103
+ "User-Agent": self._user_agent,
104
+ "source": "mobile",
105
+ "accept": "application/json",
106
+ "x-unipol-canale": "APP",
107
+ "x-unipol-requestid": str(uuid.uuid4()),
108
+ "x-ibm-client-id": self._client_id,
109
+ "x-ibm-client-secret": self._client_secret,
110
+ "x-unipol-tenant": self._tenant,
111
+ }
112
+ if self._token:
113
+ headers["Authorization"] = f"Bearer {self._token}"
114
+ if telematics:
115
+ # Fills the @HeaderMap on every TelematicsAutoService method.
116
+ headers["service_type"] = "Vehicle"
117
+ headers["company_id"] = "unipolsai"
118
+ return headers
119
+
120
+ async def async_login(self) -> None:
121
+ """Authenticate. Also establishes the F5 cookies on the session."""
122
+ self._token = None
123
+ try:
124
+ async with self._http.post(
125
+ f"{BASE_URL}login",
126
+ data={"username": self._username, "password": self._password},
127
+ headers=self._headers(),
128
+ ) as resp:
129
+ if resp.status in (401, 403):
130
+ raise UnipolSaiAuthError("username or password rejected")
131
+ resp.raise_for_status()
132
+ body = await resp.json(content_type=None)
133
+ except aiohttp.ClientError as err:
134
+ raise UnipolSaiConnectionError(f"login failed: {err}") from err
135
+
136
+ # The JSON key is "token", even though the app's field is called value.
137
+ token = ((body or {}).get("JWT") or {}).get("token")
138
+ if not token:
139
+ raise UnipolSaiAuthError("login returned no token")
140
+ self._token = token
141
+
142
+ async def _get(self, path: str, telematics: bool = False, **params: Any) -> dict:
143
+ if self._token is None:
144
+ await self.async_login()
145
+
146
+ async def _do() -> tuple[int, Any]:
147
+ async with self._http.get(
148
+ BASE_URL + path,
149
+ params=params or None,
150
+ headers=self._headers(telematics),
151
+ ) as resp:
152
+ if resp.status in (401, 403):
153
+ return resp.status, None
154
+ resp.raise_for_status()
155
+ return resp.status, await resp.json(content_type=None)
156
+
157
+ try:
158
+ status, body = await _do()
159
+ if status in (401, 403):
160
+ # 403003 "No credential" means the F5 session is gone, not
161
+ # just the JWT, so only a full login fixes it.
162
+ _LOGGER.debug("Re-authenticating after HTTP %s on %s", status, path)
163
+ await self.async_login()
164
+ status, body = await _do()
165
+ if status in (401, 403):
166
+ raise UnipolSaiAuthError(f"still HTTP {status} after re-login")
167
+ except aiohttp.ClientError as err:
168
+ raise UnipolSaiConnectionError(f"{path} failed: {err}") from err
169
+
170
+ if isinstance(body, dict):
171
+ result = body.get("operationResult") or {}
172
+ if result.get("type") not in (0, None):
173
+ raise UnipolSaiError(
174
+ f"{path}: {result.get('code')} {result.get('message')}"
175
+ )
176
+ return body or {}
177
+
178
+ @staticmethod
179
+ def _plate(vehicle: Vehicle | str) -> str:
180
+ return (
181
+ vehicle.api_plate
182
+ if isinstance(vehicle, Vehicle)
183
+ else normalise_plate(vehicle)
184
+ )
185
+
186
+ # -- endpoints --------------------------------------------------------
187
+
188
+ async def async_get_vehicles(self, usable_only: bool = True) -> list[Vehicle]:
189
+ """Vehicles with a telematics box.
190
+
191
+ Empty sections come back as `codice` 404/404404 with "Contratti non
192
+ trovati" inside an HTTP 200; that's a normal empty, not an error.
193
+ """
194
+ body = await self._get("api/priv/contesto-utente/v2/me/contrattiTelematici")
195
+ contracts = (body.get("auto") or {}).get("contrattiAuto") or []
196
+ vehicles = [v for c in contracts if (v := Vehicle.from_api(c))]
197
+ return [v for v in vehicles if v.usable] if usable_only else vehicles
198
+
199
+ async def async_get_position(
200
+ self, vehicle: Vehicle | str, *, force_refresh: bool = False
201
+ ) -> Position:
202
+ """Last known position.
203
+
204
+ `force_refresh` is **fire and forget**: it returns 200 immediately
205
+ with the *old* position and `pending_request` False, then the flag
206
+ goes true a few seconds later and the cycle takes about five minutes
207
+ to resolve. It can resolve without the position changing, which is
208
+ what a car parked with the engine off looks like, and that costs no
209
+ quota. Compare `timestamp` against the pre-request value to tell
210
+ success from give-up.
211
+
212
+ Plain reads are free; only a forced refresh touches the daily quota.
213
+ """
214
+ body = await self._get(
215
+ f"api/priv/telematici/auto/v1/vehicles/{self._plate(vehicle)}/lastPosition",
216
+ telematics=True,
217
+ # Required. Omitting it is HTTP 400 with an empty body.
218
+ update="true" if force_refresh else "false",
219
+ )
220
+ return Position.from_api(body.get("lastPosition"))
221
+
222
+ async def async_get_services(self, vehicle: Vehicle | str) -> dict[str, Service]:
223
+ """Value-added services, keyed by name."""
224
+ body = await self._get(
225
+ f"api/priv/telematici/auto/v1/vehicles/{self._plate(vehicle)}/vehicleVAS",
226
+ telematics=True,
227
+ )
228
+ return {
229
+ s["serviceName"]: Service.from_api(s)
230
+ for s in body.get("vehicleVAS") or []
231
+ if s.get("serviceName")
232
+ }
233
+
234
+ async def async_get_notifications(
235
+ self, vehicle: Vehicle | str, service: str
236
+ ) -> list[Notification]:
237
+ """Recent alert events for one service.
238
+
239
+ In practice the API returns only the single most recent event despite
240
+ the plural field name, so two events inside one poll interval collapse
241
+ into one. Reading is free; a service's credits pay for having it on.
242
+ """
243
+ body = await self._get(
244
+ f"api/priv/telematici/auto/v1/vehicles/"
245
+ f"{self._plate(vehicle)}/lastNotifications",
246
+ telematics=True,
247
+ vehicleVAS=service,
248
+ )
249
+ return [
250
+ Notification.from_api(n)
251
+ for n in body.get("serviceNotifications") or []
252
+ if n.get("id")
253
+ ]
254
+
255
+ async def async_get_usage(
256
+ self,
257
+ vehicle: Vehicle | str,
258
+ *,
259
+ date_range: str = "g",
260
+ start: int | None = None,
261
+ end: int | None = None,
262
+ ) -> UsageStats:
263
+ """Driving statistics. Free: this service needs no credits.
264
+
265
+ `date_range` is a single letter, not a word. `g` is the whole contract
266
+ period; `t` is a custom range and requires `start`/`end` as epoch
267
+ **milliseconds** (ISO dates return HTTP 500). Anything else is
268
+ rejected with `400100`.
269
+ """
270
+ params: dict[str, Any] = {"dateRange": date_range}
271
+ if date_range == "t":
272
+ if start is None or end is None:
273
+ raise ValueError("date_range 't' requires start and end")
274
+ params["startDate"] = start
275
+ params["endDate"] = end
276
+
277
+ body = await self._get(
278
+ f"api/priv/telematici/auto/v1/vehicles/"
279
+ f"{self._plate(vehicle)}/vehicleUsages",
280
+ telematics=True,
281
+ **params,
282
+ )
283
+ usages = body.get("vehicleUsages") or []
284
+ return UsageStats.from_api(usages[0] if usages else {})
@@ -0,0 +1,36 @@
1
+ """Constants for pyunipolsai."""
2
+
3
+ from __future__ import annotations
4
+
5
+ BASE_URL = "https://apphub.unipolsai.it/hub/"
6
+
7
+ APP_VERSION = "6.3.18"
8
+ APP_BUILD = "42642"
9
+
10
+ # The gateway rejects some requests without a plausible app User-Agent; this
11
+ # is the one the verified session used. Override it if you'd rather be honest
12
+ # about who is calling, but test that the gateway still answers.
13
+ DEFAULT_USER_AGENT = (
14
+ f"UnipolSaiApp/{APP_VERSION} Version Code {APP_BUILD} "
15
+ "(Android 14; sdk_gphone64_arm64; google emu64a;)"
16
+ )
17
+
18
+ # The app's IBM API Connect gateway credentials, captured from its cold-start
19
+ # apicConfig call. App-global rather than per-user, and shipped inside a
20
+ # public Play Store app, so no more secret than any baked-in mobile API key.
21
+ # Overridable because Unipol can rotate them.
22
+ DEFAULT_CLIENT_ID = "246a3dc4-9b99-47a1-859c-1840a0adf49a"
23
+ DEFAULT_CLIENT_SECRET = "L6kT5mS5bW0pQ4qD1eT2fV8sL5qI0sD8eI5tK8sF0uI4kK1wM5"
24
+ DEFAULT_TENANT = "e63a8acccacc90d4d4814149523bfe67f09746bf3c9221f3a6ea551e3c804283"
25
+
26
+ # Alert services that emit notifications, and a stable snake_case name for
27
+ # each. Keys are the API's own serviceName values.
28
+ ALERT_SERVICES: dict[str, str] = {
29
+ "engineOn": "engine_on",
30
+ "carMovedEngineOff": "moved_engine_off",
31
+ "speedLimit": "speed_limit_exceeded",
32
+ "targetArea": "target_area",
33
+ }
34
+
35
+ SERVICE_CAR_FINDER = "carFinder"
36
+ SERVICE_RANGE_STATISTICS = "rangeStatistics"
@@ -0,0 +1,15 @@
1
+ """Exceptions raised by pyunipolsai."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class UnipolSaiError(Exception):
7
+ """Base error, also raised for API-level `operationResult` failures."""
8
+
9
+
10
+ class UnipolSaiConnectionError(UnipolSaiError):
11
+ """The API could not be reached."""
12
+
13
+
14
+ class UnipolSaiAuthError(UnipolSaiError):
15
+ """Credentials were rejected, or the session could not be re-established."""
@@ -0,0 +1,322 @@
1
+ """Typed models for the Unipol telematics API.
2
+
3
+ The API's own types are not trustworthy: dates are declared `String` in the
4
+ app but sent as epoch milliseconds, distances are metres, times are seconds,
5
+ `heading` is a cardinal letter rather than degrees, and `accuracy` is a small
6
+ quality grade rather than a radius. Every one of those is normalised here so
7
+ callers never have to know.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime
14
+ from typing import Any
15
+
16
+ # The API returns a cardinal letter. Degrees are offered as a convenience;
17
+ # the resolution loss is the API's, not ours.
18
+ HEADING_DEGREES: dict[str, float] = {
19
+ "N": 0,
20
+ "NNE": 22.5,
21
+ "NE": 45,
22
+ "ENE": 67.5,
23
+ "E": 90,
24
+ "ESE": 112.5,
25
+ "SE": 135,
26
+ "SSE": 157.5,
27
+ "S": 180,
28
+ "SSW": 202.5,
29
+ "SW": 225,
30
+ "WSW": 247.5,
31
+ "W": 270,
32
+ "WNW": 292.5,
33
+ "NW": 315,
34
+ "NNW": 337.5,
35
+ }
36
+
37
+
38
+ def _dt(value: Any) -> datetime | None:
39
+ """Epoch milliseconds to an aware datetime."""
40
+ if not isinstance(value, (int, float)):
41
+ return None
42
+ return datetime.fromtimestamp(value / 1000, tz=UTC)
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Quota:
47
+ """The daily budget for *forced* position refreshes.
48
+
49
+ Plain reads do not count against this; only `update=true` does.
50
+ """
51
+
52
+ used: int | None = None
53
+ limit: int | None = None
54
+
55
+ @property
56
+ def remaining(self) -> int | None:
57
+ """Forced refreshes left today, or None if the API did not say."""
58
+ if self.used is None or self.limit is None:
59
+ return None
60
+ return max(0, self.limit - self.used)
61
+
62
+ @property
63
+ def exhausted(self) -> bool:
64
+ """Whether the daily budget is spent."""
65
+ return self.remaining == 0
66
+
67
+ @classmethod
68
+ def from_api(cls, data: dict | None) -> Quota:
69
+ """Build from a `dailyFruitions` object."""
70
+ data = data or {}
71
+ return cls(used=data.get("current"), limit=data.get("max"))
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class Position:
76
+ """A vehicle position reading."""
77
+
78
+ latitude: float | None = None
79
+ longitude: float | None = None
80
+ speed: int | None = None
81
+ heading: str | None = None
82
+ quality: int | None = None
83
+ timestamp: datetime | None = None
84
+ pending_request: bool = False
85
+ quota: Quota = field(default_factory=Quota)
86
+
87
+ @property
88
+ def heading_degrees(self) -> float | None:
89
+ """Bearing, if the cardinal letter maps to one."""
90
+ return HEADING_DEGREES.get(self.heading or "")
91
+
92
+ @classmethod
93
+ def from_api(cls, data: dict | None) -> Position:
94
+ """Build from a `lastPosition` object."""
95
+ data = data or {}
96
+ return cls(
97
+ latitude=data.get("lat"),
98
+ longitude=data.get("lon"),
99
+ speed=data.get("speed"),
100
+ heading=data.get("heading"),
101
+ # NOT metres. A small integer grade; do not pass it off as a
102
+ # GPS accuracy radius.
103
+ quality=data.get("accuracy"),
104
+ timestamp=_dt(data.get("date")),
105
+ pending_request=bool(data.get("pendingRequest")),
106
+ quota=Quota.from_api(data.get("dailyFruitions")),
107
+ )
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class TelematicDevice:
112
+ """The black box itself."""
113
+
114
+ device_id: str | None = None
115
+ imei: str | None = None
116
+ device_type: str | None = None
117
+
118
+ @classmethod
119
+ def from_api(cls, data: dict | None) -> TelematicDevice:
120
+ """Build from a `dispositivoTelematico` object."""
121
+ data = data or {}
122
+ return cls(
123
+ device_id=data.get("idDispositivo"),
124
+ imei=data.get("imei"),
125
+ device_type=data.get("tipoDispositivo"),
126
+ )
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class Vehicle:
131
+ """A vehicle with a Unibox on it."""
132
+
133
+ plate: str
134
+ make: str | None = None
135
+ model: str | None = None
136
+ contract_id: str | None = None
137
+ contract_open: bool = False
138
+ terminal_active: bool = False
139
+ device: TelematicDevice = field(default_factory=TelematicDevice)
140
+
141
+ @property
142
+ def api_plate(self) -> str:
143
+ """Country-prefixed plate, which is what every path segment wants."""
144
+ return normalise_plate(self.plate)
145
+
146
+ @property
147
+ def usable(self) -> bool:
148
+ """Whether it's worth talking to this vehicle at all."""
149
+ return self.contract_open and self.terminal_active
150
+
151
+ @classmethod
152
+ def from_api(cls, data: dict | None) -> Vehicle | None:
153
+ """Build from one contract, or None if it carries no plate."""
154
+ data = data or {}
155
+ vehicle = data.get("veicolo") or {}
156
+ plate = vehicle.get("targa")
157
+ if not plate:
158
+ return None
159
+ return cls(
160
+ plate=plate,
161
+ make=vehicle.get("marca"),
162
+ model=vehicle.get("modello"),
163
+ contract_id=data.get("identificativoTelematico"),
164
+ contract_open=data.get("statoContratto") == "open",
165
+ terminal_active=data.get("statoTerminale") == "active",
166
+ device=TelematicDevice.from_api(data.get("dispositivoTelematico")),
167
+ )
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class Service:
172
+ """One value-added service on the box.
173
+
174
+ `credits_available` is a slower, separate budget from `Quota`: it pays for
175
+ having the service switched on, not for each read.
176
+ """
177
+
178
+ name: str
179
+ enabled: bool = False
180
+ activated: bool = False
181
+ requires_credits: bool = False
182
+ credits_available: int | None = None
183
+ credits_used: int | None = None
184
+ recharge_owner: str | None = None
185
+
186
+ @classmethod
187
+ def from_api(cls, data: dict) -> Service:
188
+ """Build from one `vehicleVAS` entry."""
189
+ return cls(
190
+ name=data["serviceName"],
191
+ enabled=bool(data.get("isServiceEnabled")),
192
+ activated=bool(data.get("isServiceActivated")),
193
+ requires_credits=bool(data.get("serviceRequiredCredits")),
194
+ credits_available=data.get("serviceAvailableCredits"),
195
+ credits_used=data.get("serviceCreditUsed"),
196
+ recharge_owner=data.get("rechargeOwnership"),
197
+ )
198
+
199
+
200
+ @dataclass(frozen=True, slots=True)
201
+ class Notification:
202
+ """An alert event produced by one service.
203
+
204
+ Carries its own coordinates, which are where the event happened and are
205
+ independent of the vehicle's current position.
206
+ """
207
+
208
+ id: str
209
+ occurred_at: datetime | None = None
210
+ latitude: float | None = None
211
+ longitude: float | None = None
212
+ speed: int | None = None
213
+ speed_limit: int | None = None
214
+ plate: str | None = None
215
+ provider: str | None = None
216
+
217
+ @classmethod
218
+ def from_api(cls, data: dict) -> Notification:
219
+ """Build from one `serviceNotifications` entry."""
220
+ return cls(
221
+ id=data["id"],
222
+ occurred_at=_dt(data.get("eventDate")),
223
+ latitude=data.get("latitude"),
224
+ longitude=data.get("longitude"),
225
+ speed=data.get("speed"),
226
+ # Zeroed for services where it has no meaning, e.g. engineOn.
227
+ speed_limit=data.get("speedLimitValue") or None,
228
+ plate=data.get("vehiclePlate"),
229
+ provider=data.get("tspName"),
230
+ )
231
+
232
+
233
+ @dataclass(frozen=True, slots=True)
234
+ class UsageStats:
235
+ """Driving statistics.
236
+
237
+ The API sends **metres** and **seconds**. Raw values are kept as sent and
238
+ the convenience properties convert, so nothing is lost to rounding.
239
+ """
240
+
241
+ raw: dict[str, Any] = field(default_factory=dict)
242
+
243
+ @property
244
+ def period_start(self) -> datetime | None:
245
+ """Start of the window these figures cover."""
246
+ return _dt(self.raw.get("fromDate"))
247
+
248
+ @property
249
+ def period_end(self) -> datetime | None:
250
+ """End of the window these figures cover."""
251
+ return _dt(self.raw.get("toDate"))
252
+
253
+ @property
254
+ def days_analysed(self) -> int | None:
255
+ """How many days went into the analysis."""
256
+ return self.raw.get("totalDaysUsedForAnalysis")
257
+
258
+ @property
259
+ def top_province(self) -> str | None:
260
+ """Province with the most distance driven in it."""
261
+ return self.raw.get("higherMileageProvinceFullName")
262
+
263
+ @property
264
+ def top_province_share(self) -> float | None:
265
+ """Percentage of distance driven in that province."""
266
+ return self.raw.get("higherMileageProvinceDrivingPerc")
267
+
268
+ def distance_m(self, kind: str = "total") -> int | None:
269
+ """Distance in metres.
270
+
271
+ `kind` is total, city, extraUrban, highway, other, daylight, or a
272
+ weekday name such as monday.
273
+ """
274
+ return self.raw.get(_DISTANCE_FIELDS.get(kind, kind))
275
+
276
+ def distance_km(self, kind: str = "total") -> float | None:
277
+ """Distance in kilometres, rounded to one decimal."""
278
+ value = self.distance_m(kind)
279
+ return None if value is None else round(value / 1000, 1)
280
+
281
+ def time_s(self, kind: str = "total") -> int | None:
282
+ """Driving time in seconds, same `kind` vocabulary as distance."""
283
+ return self.raw.get(_TIME_FIELDS.get(kind, kind))
284
+
285
+ def time_h(self, kind: str = "total") -> float | None:
286
+ """Driving time in hours, rounded to two decimals."""
287
+ value = self.time_s(kind)
288
+ return None if value is None else round(value / 3600, 2)
289
+
290
+ @classmethod
291
+ def from_api(cls, data: dict | None) -> UsageStats:
292
+ """Build from one `vehicleUsages` entry."""
293
+ return cls(raw=data or {})
294
+
295
+
296
+ _KINDS = (
297
+ "total",
298
+ "city",
299
+ "extraUrban",
300
+ "highway",
301
+ "other",
302
+ "daylight",
303
+ "monday",
304
+ "tuesday",
305
+ "wednesday",
306
+ "thursday",
307
+ "friday",
308
+ "saturday",
309
+ "sunday",
310
+ )
311
+ _DISTANCE_FIELDS = {
312
+ k: ("totalDistance" if k == "total" else f"{k}DrivingDistance") for k in _KINDS
313
+ }
314
+ _TIME_FIELDS = {
315
+ k: ("totalDrivingTime" if k == "total" else f"{k}DrivingTime") for k in _KINDS
316
+ }
317
+
318
+
319
+ def normalise_plate(plate: str) -> str:
320
+ """`AB123CD` to `IT-AB123CD`. A bare plate is rejected by the API."""
321
+ plate = plate.strip().upper().replace(" ", "")
322
+ return plate if "-" in plate else f"IT-{plate}"
@@ -0,0 +1,13 @@
1
+ {"auto": {"contrattiAuto": [
2
+ {"identificativoTelematico": "27778190", "statoContratto": "open",
3
+ "statoTerminale": "active",
4
+ "veicolo": {"tipo": 1, "marca": "VOLKSWAGEN", "modello": "T-ROC", "targa": "AB123CD"},
5
+ "dispositivoTelematico": {"idDispositivo": "999", "tipoDispositivo": "F",
6
+ "imei": "000000000000000"}},
7
+ {"identificativoTelematico": "111", "statoContratto": "open",
8
+ "statoTerminale": "inactive",
9
+ "veicolo": {"marca": "FIAT", "modello": "PANDA", "targa": "ZZ999ZZ"},
10
+ "dispositivoTelematico": {}}],
11
+ "codice": 200200},
12
+ "immobili": {"contrattiImmobili": [], "codice": 404404, "message": "Contratti non trovati"},
13
+ "pet": {"contrattiPet": [], "codice": 404, "message": "Contratti non trovati"}}
@@ -0,0 +1,5 @@
1
+ {"operationResult": {"type": 0},
2
+ "lastPosition": {"date": 1789889311000, "lat": 45.4642, "lon": 9.19,
3
+ "speed": 0, "heading": "N", "timeZone": 0, "daylightSavingTime": 0,
4
+ "accuracy": 1, "pendingRequest": false,
5
+ "dailyFruitions": {"current": 0, "max": 5}}}
@@ -0,0 +1,7 @@
1
+ {"operationResult": {"type": 0},
2
+ "serviceNotifications": [
3
+ {"id": "68b1e071-0000-0000-0000-000000000000", "voucherId": "27778190",
4
+ "eventDate": 1789885957000, "userCode": "XXXXXX00X00X000X",
5
+ "vehiclePlate": "IT-AB123CD", "tspName": "Alfa", "accuracy": 1,
6
+ "latitude": 45.48, "longitude": 9.2, "speed": 0, "speedLimitValue": 0,
7
+ "targetAreaLatitude": 0.0, "targetAreaLongitude": 0.0, "targetAreaRadius": 0}]}
@@ -0,0 +1,7 @@
1
+ {"operationResult": {"type": 0},
2
+ "vehicleUsages": [{"statisticsDate": 1789689600000, "fromDate": 1781913600000,
3
+ "toDate": 1789689600000, "totalDistance": 5431730, "totalDrivingTime": 420215,
4
+ "cityDrivingDistance": 1378360, "highwayDrivingDistance": 1888260,
5
+ "cityDrivingTime": 149951, "mondayDrivingDistance": 749450,
6
+ "higherMileageProvince": "XX", "higherMileageProvinceFullName": "Province",
7
+ "higherMileageProvinceDrivingPerc": 41.58, "totalDaysUsedForAnalysis": 91}]}
@@ -0,0 +1,8 @@
1
+ {"operationResult": {"type": 0},
2
+ "vehicleVAS": [
3
+ {"serviceName": "carFinder", "isServiceEnabled": true, "isServiceActivated": true,
4
+ "serviceRequiredCredits": true, "serviceAvailableCredits": 9,
5
+ "serviceCreditUsed": 1, "rechargeOwnership": "Unipol"},
6
+ {"serviceName": "rangeStatistics", "isServiceEnabled": true,
7
+ "isServiceActivated": true, "serviceRequiredCredits": false,
8
+ "rechargeOwnership": "Unipol"}]}
@@ -0,0 +1,190 @@
1
+ """Model tests.
2
+
3
+ Fixtures are real payloads captured from the live API, with identifiers
4
+ replaced. They exist mainly to pin the parts of this API that are
5
+ counter-intuitive: epoch-millisecond dates declared as strings, distances in
6
+ metres, a cardinal-letter heading, and an `accuracy` that is a grade rather
7
+ than a radius.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+ from pyunipolsai.models import normalise_plate
18
+
19
+ from pyunipolsai import Notification, Position, Service, UsageStats, Vehicle
20
+
21
+ FIXTURES = Path(__file__).parent / "fixtures"
22
+
23
+
24
+ def load(name: str) -> dict:
25
+ return json.loads((FIXTURES / f"{name}.json").read_text())
26
+
27
+
28
+ @pytest.mark.parametrize(
29
+ ("raw", "expected"),
30
+ [
31
+ ("AB123CD", "IT-AB123CD"),
32
+ ("ab123cd", "IT-AB123CD"),
33
+ (" ab 123 cd ", "IT-AB123CD"),
34
+ ("IT-AB123CD", "IT-AB123CD"),
35
+ ],
36
+ )
37
+ def test_normalise_plate(raw: str, expected: str) -> None:
38
+ assert normalise_plate(raw) == expected
39
+
40
+
41
+ class TestPosition:
42
+ def test_parses_real_payload(self) -> None:
43
+ position = Position.from_api(load("last_position")["lastPosition"])
44
+ assert position.latitude == 45.4642
45
+ assert position.longitude == 9.19
46
+ assert position.speed == 0
47
+ assert position.pending_request is False
48
+
49
+ def test_date_is_epoch_millis_not_a_string(self) -> None:
50
+ """The app declares this field String; the wire sends a number."""
51
+ position = Position.from_api(load("last_position")["lastPosition"])
52
+ assert position.timestamp == datetime(2026, 9, 20, 7, 28, 31, tzinfo=UTC)
53
+
54
+ def test_heading_is_cardinal_with_degrees_derived(self) -> None:
55
+ position = Position.from_api({"heading": "NE"})
56
+ assert position.heading == "NE"
57
+ assert position.heading_degrees == 45
58
+
59
+ def test_unknown_heading_has_no_degrees(self) -> None:
60
+ assert Position.from_api({"heading": "??"}).heading_degrees is None
61
+
62
+ def test_accuracy_is_exposed_as_quality_not_metres(self) -> None:
63
+ """Naming matters here: 1 is a grade, not a one-metre fix."""
64
+ position = Position.from_api(load("last_position")["lastPosition"])
65
+ assert position.quality == 1
66
+ assert not hasattr(position, "accuracy")
67
+
68
+ def test_empty_payload_is_survivable(self) -> None:
69
+ position = Position.from_api(None)
70
+ assert position.latitude is None
71
+ assert position.quota.remaining is None
72
+
73
+
74
+ class TestQuota:
75
+ def test_remaining_and_exhausted(self) -> None:
76
+ quota = Position.from_api(load("last_position")["lastPosition"]).quota
77
+ assert (quota.used, quota.limit) == (0, 5)
78
+ assert quota.remaining == 5
79
+ assert quota.exhausted is False
80
+
81
+ def test_exhausted_when_spent(self) -> None:
82
+ quota = Position.from_api({"dailyFruitions": {"current": 5, "max": 5}}).quota
83
+ assert quota.remaining == 0
84
+ assert quota.exhausted is True
85
+
86
+ def test_never_goes_negative(self) -> None:
87
+ quota = Position.from_api({"dailyFruitions": {"current": 9, "max": 5}}).quota
88
+ assert quota.remaining == 0
89
+
90
+
91
+ class TestVehicle:
92
+ def test_parses_and_prefixes_plate(self) -> None:
93
+ contracts = load("contracts")["auto"]["contrattiAuto"]
94
+ vehicle = Vehicle.from_api(contracts[0])
95
+ assert vehicle is not None
96
+ assert vehicle.plate == "AB123CD"
97
+ assert vehicle.api_plate == "IT-AB123CD"
98
+ assert vehicle.make == "VOLKSWAGEN"
99
+ assert vehicle.device.imei == "000000000000000"
100
+ assert vehicle.usable is True
101
+
102
+ def test_inactive_terminal_is_not_usable(self) -> None:
103
+ contracts = load("contracts")["auto"]["contrattiAuto"]
104
+ vehicle = Vehicle.from_api(contracts[1])
105
+ assert vehicle is not None
106
+ assert vehicle.terminal_active is False
107
+ assert vehicle.usable is False
108
+
109
+ def test_missing_plate_yields_nothing(self) -> None:
110
+ assert Vehicle.from_api({"veicolo": {}}) is None
111
+
112
+
113
+ class TestService:
114
+ def test_parses_credit_pool(self) -> None:
115
+ services = {
116
+ s["serviceName"]: Service.from_api(s)
117
+ for s in load("vehicle_vas")["vehicleVAS"]
118
+ }
119
+ car_finder = services["carFinder"]
120
+ assert car_finder.activated is True
121
+ assert car_finder.requires_credits is True
122
+ assert car_finder.credits_available == 9
123
+
124
+ def test_statistics_needs_no_credits(self) -> None:
125
+ services = {
126
+ s["serviceName"]: Service.from_api(s)
127
+ for s in load("vehicle_vas")["vehicleVAS"]
128
+ }
129
+ assert services["rangeStatistics"].requires_credits is False
130
+
131
+
132
+ class TestNotification:
133
+ def test_parses_real_payload(self) -> None:
134
+ raw = load("notifications")["serviceNotifications"][0]
135
+ notification = Notification.from_api(raw)
136
+ assert notification.occurred_at == datetime(2026, 9, 20, 6, 32, 37, tzinfo=UTC)
137
+ assert notification.latitude == 45.48
138
+ assert notification.provider == "Alfa"
139
+
140
+ def test_zero_speed_limit_becomes_none(self) -> None:
141
+ """Zeroed fields become None.
142
+
143
+ `speedLimitValue` is shared across alert types and comes back zero
144
+ where it has no meaning, e.g. for engineOn.
145
+ """
146
+ raw = load("notifications")["serviceNotifications"][0]
147
+ assert Notification.from_api(raw).speed_limit is None
148
+
149
+
150
+ class TestUsageStats:
151
+ def test_converts_metres_and_seconds(self) -> None:
152
+ usage = UsageStats.from_api(load("usage")["vehicleUsages"][0])
153
+ assert usage.distance_m("total") == 5431730
154
+ assert usage.distance_km("total") == 5431.7
155
+ assert usage.time_s("total") == 420215
156
+ assert usage.time_h("total") == 116.73
157
+
158
+ def test_average_speed_is_plausible(self) -> None:
159
+ """Sanity-check the units.
160
+
161
+ Metres over seconds should land in a believable range for mixed
162
+ driving. Treating distance as kilometres would put this at about
163
+ 46,000 km/h.
164
+ """
165
+ usage = UsageStats.from_api(load("usage")["vehicleUsages"][0])
166
+ kmh = usage.distance_km() / usage.time_h()
167
+ assert 20 < kmh < 90
168
+
169
+ def test_breakdowns(self) -> None:
170
+ usage = UsageStats.from_api(load("usage")["vehicleUsages"][0])
171
+ assert usage.distance_km("city") == 1378.4
172
+ assert usage.distance_km("highway") == 1888.3
173
+ assert usage.distance_km("monday") == 749.5
174
+ assert usage.time_h("city") == 41.65
175
+
176
+ def test_metadata(self) -> None:
177
+ usage = UsageStats.from_api(load("usage")["vehicleUsages"][0])
178
+ assert usage.days_analysed == 91
179
+ assert usage.top_province == "Province"
180
+ assert usage.top_province_share == 41.58
181
+ # Equals the contract's dataInizio: dateRange 'g' really is
182
+ # "everything since the policy started".
183
+ assert usage.period_start == datetime(2026, 6, 20, 0, 0, tzinfo=UTC)
184
+
185
+ def test_unknown_kind_is_none_not_a_crash(self) -> None:
186
+ usage = UsageStats.from_api(load("usage")["vehicleUsages"][0])
187
+ assert usage.distance_km("spaceship") is None
188
+
189
+ def test_empty_payload(self) -> None:
190
+ assert UsageStats.from_api(None).distance_km() is None