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.
- pyunipolsai-0.1.0/.gitignore +17 -0
- pyunipolsai-0.1.0/PKG-INFO +115 -0
- pyunipolsai-0.1.0/README.md +92 -0
- pyunipolsai-0.1.0/pyproject.toml +36 -0
- pyunipolsai-0.1.0/src/pyunipolsai/__init__.py +58 -0
- pyunipolsai-0.1.0/src/pyunipolsai/client.py +284 -0
- pyunipolsai-0.1.0/src/pyunipolsai/const.py +36 -0
- pyunipolsai-0.1.0/src/pyunipolsai/exceptions.py +15 -0
- pyunipolsai-0.1.0/src/pyunipolsai/models.py +322 -0
- pyunipolsai-0.1.0/tests/fixtures/contracts.json +13 -0
- pyunipolsai-0.1.0/tests/fixtures/last_position.json +5 -0
- pyunipolsai-0.1.0/tests/fixtures/notifications.json +7 -0
- pyunipolsai-0.1.0/tests/fixtures/usage.json +7 -0
- pyunipolsai-0.1.0/tests/fixtures/vehicle_vas.json +8 -0
- pyunipolsai-0.1.0/tests/test_models.py +190 -0
|
@@ -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,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
|