aioabrp 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.
- aioabrp-0.1.0/.github/workflows/ci.yml +36 -0
- aioabrp-0.1.0/.github/workflows/release.yml +63 -0
- aioabrp-0.1.0/.gitignore +25 -0
- aioabrp-0.1.0/LICENSE +21 -0
- aioabrp-0.1.0/PKG-INFO +218 -0
- aioabrp-0.1.0/README.md +196 -0
- aioabrp-0.1.0/cliff.toml +35 -0
- aioabrp-0.1.0/pyproject.toml +100 -0
- aioabrp-0.1.0/src/aioabrp/__init__.py +39 -0
- aioabrp-0.1.0/src/aioabrp/_clock.py +44 -0
- aioabrp-0.1.0/src/aioabrp/_extract.py +295 -0
- aioabrp-0.1.0/src/aioabrp/_sse.py +97 -0
- aioabrp-0.1.0/src/aioabrp/_wire_types.py +246 -0
- aioabrp-0.1.0/src/aioabrp/auth.py +33 -0
- aioabrp-0.1.0/src/aioabrp/client.py +365 -0
- aioabrp-0.1.0/src/aioabrp/const.py +47 -0
- aioabrp-0.1.0/src/aioabrp/exceptions.py +13 -0
- aioabrp-0.1.0/src/aioabrp/models.py +155 -0
- aioabrp-0.1.0/src/aioabrp/py.typed +0 -0
- aioabrp-0.1.0/src/aioabrp/stream.py +522 -0
- aioabrp-0.1.0/tests/conftest.py +314 -0
- aioabrp-0.1.0/tests/test_client.py +912 -0
- aioabrp-0.1.0/tests/test_clock.py +39 -0
- aioabrp-0.1.0/tests/test_extract.py +411 -0
- aioabrp-0.1.0/tests/test_models.py +135 -0
- aioabrp-0.1.0/tests/test_public_api.py +57 -0
- aioabrp-0.1.0/tests/test_smoke.py +25 -0
- aioabrp-0.1.0/tests/test_sse_parser.py +235 -0
- aioabrp-0.1.0/tests/test_stream.py +466 -0
- aioabrp-0.1.0/tests/test_stream_isolation.py +256 -0
- aioabrp-0.1.0/tests/test_stream_resilience.py +741 -0
- aioabrp-0.1.0/tests/test_wire_types.py +136 -0
- aioabrp-0.1.0/uv.lock +547 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ci-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
ci:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v5
|
|
17
|
+
|
|
18
|
+
- name: Install uv
|
|
19
|
+
uses: astral-sh/setup-uv@v7
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.14"
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: uv sync --locked
|
|
25
|
+
|
|
26
|
+
- name: Check formatting
|
|
27
|
+
run: uv run ruff format --check .
|
|
28
|
+
|
|
29
|
+
- name: Lint
|
|
30
|
+
run: uv run ruff check .
|
|
31
|
+
|
|
32
|
+
- name: Type check
|
|
33
|
+
run: uv run mypy
|
|
34
|
+
|
|
35
|
+
- name: Test
|
|
36
|
+
run: uv run pytest
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
release:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
# Required for PyPI Trusted Publishing (OIDC).
|
|
13
|
+
id-token: write
|
|
14
|
+
# Required to create the GitHub release.
|
|
15
|
+
contents: write
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v5
|
|
18
|
+
with:
|
|
19
|
+
persist-credentials: false
|
|
20
|
+
# Full history + tags so git-cliff can compute release notes.
|
|
21
|
+
fetch-depth: 0
|
|
22
|
+
|
|
23
|
+
- name: Install uv
|
|
24
|
+
uses: astral-sh/setup-uv@v7
|
|
25
|
+
with:
|
|
26
|
+
python-version: "3.14"
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: uv sync --locked
|
|
30
|
+
|
|
31
|
+
- name: Check formatting
|
|
32
|
+
run: uv run ruff format --check .
|
|
33
|
+
|
|
34
|
+
- name: Lint
|
|
35
|
+
run: uv run ruff check .
|
|
36
|
+
|
|
37
|
+
- name: Type check
|
|
38
|
+
run: uv run mypy
|
|
39
|
+
|
|
40
|
+
- name: Test
|
|
41
|
+
run: uv run pytest
|
|
42
|
+
|
|
43
|
+
- name: Build sdist and wheel
|
|
44
|
+
run: uv build
|
|
45
|
+
|
|
46
|
+
# `uv version --short` cannot read hatchling dynamic versions, so ask
|
|
47
|
+
# the package itself (src/aioabrp/__init__.py is the single source).
|
|
48
|
+
- name: Check tag matches project version
|
|
49
|
+
run: test "v$(uv run python -c 'import aioabrp; print(aioabrp.__version__)')" = "${GITHUB_REF_NAME}"
|
|
50
|
+
|
|
51
|
+
- name: Publish to PyPI
|
|
52
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
53
|
+
|
|
54
|
+
- name: Generate release notes from conventional commits
|
|
55
|
+
run: uv tool run git-cliff --latest --strip all -o "${RUNNER_TEMP}/RELEASE_NOTES.md"
|
|
56
|
+
|
|
57
|
+
- name: Create GitHub release
|
|
58
|
+
env:
|
|
59
|
+
GH_TOKEN: ${{ github.token }}
|
|
60
|
+
run: >-
|
|
61
|
+
gh release create "${GITHUB_REF_NAME}" dist/*
|
|
62
|
+
--title "${GITHUB_REF_NAME}"
|
|
63
|
+
--notes-file "${RUNNER_TEMP}/RELEASE_NOTES.md"
|
aioabrp-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Byte-compiled / caches
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
|
|
8
|
+
# Virtual environments
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
|
|
12
|
+
# Build artifacts
|
|
13
|
+
build/
|
|
14
|
+
dist/
|
|
15
|
+
*.egg-info/
|
|
16
|
+
|
|
17
|
+
# Coverage
|
|
18
|
+
.coverage
|
|
19
|
+
.coverage.*
|
|
20
|
+
htmlcov/
|
|
21
|
+
|
|
22
|
+
# Editors / OS
|
|
23
|
+
.DS_Store
|
|
24
|
+
.idea/
|
|
25
|
+
.vscode/
|
aioabrp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Martin Andersson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
aioabrp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aioabrp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for the A Better Routeplanner (ABRP) / Iternio telemetry API
|
|
5
|
+
Project-URL: Homepage, https://github.com/mtandersson/aioabrp
|
|
6
|
+
Project-URL: Repository, https://github.com/mtandersson/aioabrp
|
|
7
|
+
Project-URL: Changelog, https://github.com/mtandersson/aioabrp/releases
|
|
8
|
+
Project-URL: Issues, https://github.com/mtandersson/aioabrp/issues
|
|
9
|
+
Author: Martin Andersson
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.14
|
|
20
|
+
Requires-Dist: aiohttp
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# aioabrp
|
|
24
|
+
|
|
25
|
+
Async Python client for the [A Better Routeplanner](https://abetterrouteplanner.com)
|
|
26
|
+
(ABRP) / Iternio telemetry API. The library mirrors ABRP's API points 1:1 and
|
|
27
|
+
has no Home Assistant dependency: a stateless request/response `AbrpClient`
|
|
28
|
+
(garage, vehicle catalog, one-shot telemetry snapshot) and a resilient
|
|
29
|
+
`TelemetryStream` (server-sent events with reconnect/backoff/watchdog) that
|
|
30
|
+
delivers extracted, typed metric values — never raw wire dicts — to consumer
|
|
31
|
+
callbacks. Authentication is injected: the consumer owns the token lifecycle
|
|
32
|
+
and hands the library a fresh access token via `AbstractAuth` (or the
|
|
33
|
+
fixed-token `StaticAuth` convenience).
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pip install aioabrp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Python ≥ 3.14. The only runtime dependency is `aiohttp`.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
A runnable standalone example (fill in a real partner API key and access
|
|
46
|
+
token before running — the calls below hit the live API):
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
|
|
51
|
+
import aiohttp
|
|
52
|
+
|
|
53
|
+
from aioabrp import (
|
|
54
|
+
AbrpClient,
|
|
55
|
+
ConnectionEvent,
|
|
56
|
+
StaticAuth,
|
|
57
|
+
Telemetry,
|
|
58
|
+
TelemetryStream,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
API_KEY = "your-iternio-partner-api-key"
|
|
62
|
+
ACCESS_TOKEN = "your-abrp-access-token"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def on_update(vehicle_id: int, telemetry: Telemetry) -> None:
|
|
66
|
+
for metric, mv in telemetry.items():
|
|
67
|
+
print(f"vehicle {vehicle_id}: {metric} = {mv.value!r} (time={mv.time})")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def on_connection_change(event: ConnectionEvent) -> None:
|
|
71
|
+
print(f"connection: {event.state.name} (reason={event.reason})")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def main() -> None:
|
|
75
|
+
async with aiohttp.ClientSession() as session:
|
|
76
|
+
auth = StaticAuth(ACCESS_TOKEN)
|
|
77
|
+
client = AbrpClient(session, API_KEY, auth)
|
|
78
|
+
|
|
79
|
+
vehicles = await client.async_get_vehicles()
|
|
80
|
+
for vehicle in vehicles:
|
|
81
|
+
print(f"{vehicle.vehicle_id}: {vehicle.name or vehicle.vehicle_model}")
|
|
82
|
+
|
|
83
|
+
stream = TelemetryStream(
|
|
84
|
+
session,
|
|
85
|
+
API_KEY,
|
|
86
|
+
auth,
|
|
87
|
+
vehicle_ids=[v.vehicle_id for v in vehicles],
|
|
88
|
+
on_update=on_update,
|
|
89
|
+
on_connection_change=on_connection_change,
|
|
90
|
+
)
|
|
91
|
+
await stream.start()
|
|
92
|
+
try:
|
|
93
|
+
await asyncio.sleep(600) # stream telemetry for ten minutes
|
|
94
|
+
finally:
|
|
95
|
+
await stream.stop()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
asyncio.run(main())
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Get your own API key
|
|
103
|
+
|
|
104
|
+
The `api_key` constructor argument is an Iternio **partner API key**, not a
|
|
105
|
+
per-user credential. If you are building your own consumer, obtain your own
|
|
106
|
+
key from Iternio — see the
|
|
107
|
+
[Iternio Telemetry API documentation](https://documenter.getpostman.com/view/7396339/SWTK5a8w)
|
|
108
|
+
or contact Iternio for a partner API key. The per-user access token comes
|
|
109
|
+
from your own auth flow and is handed to the library through `AbstractAuth`.
|
|
110
|
+
|
|
111
|
+
## Consumer contracts
|
|
112
|
+
|
|
113
|
+
These behaviors are pinned by the test suite; consumers may rely on them.
|
|
114
|
+
|
|
115
|
+
### Callbacks
|
|
116
|
+
|
|
117
|
+
- `on_update` and `on_connection_change` are **synchronous** callbacks,
|
|
118
|
+
delivered on the event loop that ran `start()`. They must be
|
|
119
|
+
non-blocking — a slow callback stalls the stream (and every other stream
|
|
120
|
+
on the same loop).
|
|
121
|
+
- A raising callback is logged with its traceback and swallowed; the stream
|
|
122
|
+
continues (frame loss beats stream death).
|
|
123
|
+
|
|
124
|
+
### Connection events
|
|
125
|
+
|
|
126
|
+
- `CONNECTED` fires on the **first frame** of a connection, not on the HTTP
|
|
127
|
+
connect — a connection that opens but never produces a frame keeps
|
|
128
|
+
reading as down until proven healthy.
|
|
129
|
+
- `DISCONNECTED` is **steady-state, not exceptional**: the ABRP server
|
|
130
|
+
unilaterally closes idle streams at roughly 200 s and the stream
|
|
131
|
+
reconnects with backoff. Do not treat a `DISCONNECTED` event as an
|
|
132
|
+
outage. Events are status reports, not strict state transitions — a
|
|
133
|
+
`DISCONNECTED` MAY arrive before the first `CONNECTED` (for example when
|
|
134
|
+
the very first connection attempt fails).
|
|
135
|
+
- `AUTH_FAILED` is **terminal**: the stream stops itself and will not
|
|
136
|
+
retry. The consumer decides whether/when to restart with fresh
|
|
137
|
+
credentials.
|
|
138
|
+
- A transient (non-`AbrpAuthError`) failure from the token getter emits
|
|
139
|
+
**no** `ConnectionEvent` at all (debug log only) — no connection attempt
|
|
140
|
+
was made, so consumers keep seeing the last known state during a
|
|
141
|
+
token-endpoint outage.
|
|
142
|
+
|
|
143
|
+
### Lifecycle
|
|
144
|
+
|
|
145
|
+
- `stop()` is idempotent and cancel-based (never a graceful join). No
|
|
146
|
+
callbacks fire after `stop()` returns.
|
|
147
|
+
- `stop()` propagates the **caller's own** cancellation: if you wrap it in
|
|
148
|
+
`asyncio.wait_for(stream.stop(), timeout)` and that times out, the
|
|
149
|
+
resulting `TimeoutError` means the stream **may still be running** — the
|
|
150
|
+
stop was interrupted, not completed.
|
|
151
|
+
- `start()` after `stop()` restarts the stream.
|
|
152
|
+
|
|
153
|
+
### Monotonicity gate
|
|
154
|
+
|
|
155
|
+
Each stream keeps one piece of state: a per-`(vehicle_id, Metric)` map of
|
|
156
|
+
the last adopted block timestamp.
|
|
157
|
+
|
|
158
|
+
- A block whose `time` is **strictly older** than the last adopted time for
|
|
159
|
+
that `(vehicle, metric)` is dropped.
|
|
160
|
+
- An **equal-time** block re-emits **by design**: every reconnect
|
|
161
|
+
re-delivers a full-state snapshot with unchanged block times, and
|
|
162
|
+
consumers rely on that backfill.
|
|
163
|
+
- A **time-less** block (missing/malformed/naive `time`) is adopted and
|
|
164
|
+
**clears** the gate for that metric — it carries no ordering claim, so it
|
|
165
|
+
also stops gating subsequent values.
|
|
166
|
+
- A block whose `time` is in the **future** is rewritten to "now" before
|
|
167
|
+
delivery (and before gating), so a clock-skewed upstream stamp can neither
|
|
168
|
+
be delivered to the consumer nor become an unreachable high-water mark that
|
|
169
|
+
silently stalls the metric. `AbrpClient.async_get_current_telemetry` applies
|
|
170
|
+
the same clamp (it does not gate).
|
|
171
|
+
- **Known limitation:** a legitimately backdated server correction (an
|
|
172
|
+
older timestamp that really is a newer truth) is suppressed for the
|
|
173
|
+
stream's lifetime.
|
|
174
|
+
|
|
175
|
+
The gate can be **pre-warmed** across process restarts: pass
|
|
176
|
+
`TelemetryStream(..., seed=Mapping[int, Mapping[Metric, datetime]])` — a
|
|
177
|
+
per-vehicle map of metric to its last wire-block time (e.g. derived from the
|
|
178
|
+
consumer's last persisted snapshot) — and the stream seeds its high-water marks
|
|
179
|
+
from those times, each clamped not-future. Only times are needed; no typed
|
|
180
|
+
values. The clock is the `aioabrp._clock._now` seam (the monkeypatch target in
|
|
181
|
+
tests).
|
|
182
|
+
|
|
183
|
+
### Logging
|
|
184
|
+
|
|
185
|
+
Enable the `aioabrp` logger at `DEBUG` for triage. Connect/disconnect and
|
|
186
|
+
reasons log at `INFO`, watchdog stalls at `WARNING`, per-frame activity at
|
|
187
|
+
`DEBUG`. Frames are logged as keys and sizes only — frame bodies, header
|
|
188
|
+
values, and tokens (including GPS coordinates and other PII) are never
|
|
189
|
+
logged. Pass `name=` to `TelemetryStream` to prefix its log lines when
|
|
190
|
+
running multiple streams.
|
|
191
|
+
|
|
192
|
+
## Development
|
|
193
|
+
|
|
194
|
+
This project uses [uv](https://docs.astral.sh/uv/):
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
uv sync
|
|
198
|
+
uv run ruff format . && uv run ruff check . && uv run mypy && uv run pytest
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Note: the locked dev environment constrains `aiohttp<3.14` because the
|
|
202
|
+
latest `aioresponses` release is incompatible with aiohttp ≥ 3.14
|
|
203
|
+
([aioresponses#289](https://github.com/pnuckowski/aioresponses/issues/289));
|
|
204
|
+
the published runtime dependency stays unpinned.
|
|
205
|
+
|
|
206
|
+
### Releases
|
|
207
|
+
|
|
208
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org/);
|
|
209
|
+
there is no CHANGELOG file — release notes are generated from the commit
|
|
210
|
+
history by [git-cliff](https://git-cliff.org/) (`cliff.toml`) and published
|
|
211
|
+
as GitHub Releases. Pushing a `v*` tag runs the quality gate, builds the
|
|
212
|
+
sdist + wheel, verifies the tag matches `aioabrp.__version__`, publishes to
|
|
213
|
+
PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
|
|
214
|
+
(OIDC), and creates the GitHub Release.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT — see [LICENSE](LICENSE).
|
aioabrp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# aioabrp
|
|
2
|
+
|
|
3
|
+
Async Python client for the [A Better Routeplanner](https://abetterrouteplanner.com)
|
|
4
|
+
(ABRP) / Iternio telemetry API. The library mirrors ABRP's API points 1:1 and
|
|
5
|
+
has no Home Assistant dependency: a stateless request/response `AbrpClient`
|
|
6
|
+
(garage, vehicle catalog, one-shot telemetry snapshot) and a resilient
|
|
7
|
+
`TelemetryStream` (server-sent events with reconnect/backoff/watchdog) that
|
|
8
|
+
delivers extracted, typed metric values — never raw wire dicts — to consumer
|
|
9
|
+
callbacks. Authentication is injected: the consumer owns the token lifecycle
|
|
10
|
+
and hands the library a fresh access token via `AbstractAuth` (or the
|
|
11
|
+
fixed-token `StaticAuth` convenience).
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install aioabrp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Python ≥ 3.14. The only runtime dependency is `aiohttp`.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
A runnable standalone example (fill in a real partner API key and access
|
|
24
|
+
token before running — the calls below hit the live API):
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import asyncio
|
|
28
|
+
|
|
29
|
+
import aiohttp
|
|
30
|
+
|
|
31
|
+
from aioabrp import (
|
|
32
|
+
AbrpClient,
|
|
33
|
+
ConnectionEvent,
|
|
34
|
+
StaticAuth,
|
|
35
|
+
Telemetry,
|
|
36
|
+
TelemetryStream,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
API_KEY = "your-iternio-partner-api-key"
|
|
40
|
+
ACCESS_TOKEN = "your-abrp-access-token"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def on_update(vehicle_id: int, telemetry: Telemetry) -> None:
|
|
44
|
+
for metric, mv in telemetry.items():
|
|
45
|
+
print(f"vehicle {vehicle_id}: {metric} = {mv.value!r} (time={mv.time})")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def on_connection_change(event: ConnectionEvent) -> None:
|
|
49
|
+
print(f"connection: {event.state.name} (reason={event.reason})")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def main() -> None:
|
|
53
|
+
async with aiohttp.ClientSession() as session:
|
|
54
|
+
auth = StaticAuth(ACCESS_TOKEN)
|
|
55
|
+
client = AbrpClient(session, API_KEY, auth)
|
|
56
|
+
|
|
57
|
+
vehicles = await client.async_get_vehicles()
|
|
58
|
+
for vehicle in vehicles:
|
|
59
|
+
print(f"{vehicle.vehicle_id}: {vehicle.name or vehicle.vehicle_model}")
|
|
60
|
+
|
|
61
|
+
stream = TelemetryStream(
|
|
62
|
+
session,
|
|
63
|
+
API_KEY,
|
|
64
|
+
auth,
|
|
65
|
+
vehicle_ids=[v.vehicle_id for v in vehicles],
|
|
66
|
+
on_update=on_update,
|
|
67
|
+
on_connection_change=on_connection_change,
|
|
68
|
+
)
|
|
69
|
+
await stream.start()
|
|
70
|
+
try:
|
|
71
|
+
await asyncio.sleep(600) # stream telemetry for ten minutes
|
|
72
|
+
finally:
|
|
73
|
+
await stream.stop()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
asyncio.run(main())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Get your own API key
|
|
81
|
+
|
|
82
|
+
The `api_key` constructor argument is an Iternio **partner API key**, not a
|
|
83
|
+
per-user credential. If you are building your own consumer, obtain your own
|
|
84
|
+
key from Iternio — see the
|
|
85
|
+
[Iternio Telemetry API documentation](https://documenter.getpostman.com/view/7396339/SWTK5a8w)
|
|
86
|
+
or contact Iternio for a partner API key. The per-user access token comes
|
|
87
|
+
from your own auth flow and is handed to the library through `AbstractAuth`.
|
|
88
|
+
|
|
89
|
+
## Consumer contracts
|
|
90
|
+
|
|
91
|
+
These behaviors are pinned by the test suite; consumers may rely on them.
|
|
92
|
+
|
|
93
|
+
### Callbacks
|
|
94
|
+
|
|
95
|
+
- `on_update` and `on_connection_change` are **synchronous** callbacks,
|
|
96
|
+
delivered on the event loop that ran `start()`. They must be
|
|
97
|
+
non-blocking — a slow callback stalls the stream (and every other stream
|
|
98
|
+
on the same loop).
|
|
99
|
+
- A raising callback is logged with its traceback and swallowed; the stream
|
|
100
|
+
continues (frame loss beats stream death).
|
|
101
|
+
|
|
102
|
+
### Connection events
|
|
103
|
+
|
|
104
|
+
- `CONNECTED` fires on the **first frame** of a connection, not on the HTTP
|
|
105
|
+
connect — a connection that opens but never produces a frame keeps
|
|
106
|
+
reading as down until proven healthy.
|
|
107
|
+
- `DISCONNECTED` is **steady-state, not exceptional**: the ABRP server
|
|
108
|
+
unilaterally closes idle streams at roughly 200 s and the stream
|
|
109
|
+
reconnects with backoff. Do not treat a `DISCONNECTED` event as an
|
|
110
|
+
outage. Events are status reports, not strict state transitions — a
|
|
111
|
+
`DISCONNECTED` MAY arrive before the first `CONNECTED` (for example when
|
|
112
|
+
the very first connection attempt fails).
|
|
113
|
+
- `AUTH_FAILED` is **terminal**: the stream stops itself and will not
|
|
114
|
+
retry. The consumer decides whether/when to restart with fresh
|
|
115
|
+
credentials.
|
|
116
|
+
- A transient (non-`AbrpAuthError`) failure from the token getter emits
|
|
117
|
+
**no** `ConnectionEvent` at all (debug log only) — no connection attempt
|
|
118
|
+
was made, so consumers keep seeing the last known state during a
|
|
119
|
+
token-endpoint outage.
|
|
120
|
+
|
|
121
|
+
### Lifecycle
|
|
122
|
+
|
|
123
|
+
- `stop()` is idempotent and cancel-based (never a graceful join). No
|
|
124
|
+
callbacks fire after `stop()` returns.
|
|
125
|
+
- `stop()` propagates the **caller's own** cancellation: if you wrap it in
|
|
126
|
+
`asyncio.wait_for(stream.stop(), timeout)` and that times out, the
|
|
127
|
+
resulting `TimeoutError` means the stream **may still be running** — the
|
|
128
|
+
stop was interrupted, not completed.
|
|
129
|
+
- `start()` after `stop()` restarts the stream.
|
|
130
|
+
|
|
131
|
+
### Monotonicity gate
|
|
132
|
+
|
|
133
|
+
Each stream keeps one piece of state: a per-`(vehicle_id, Metric)` map of
|
|
134
|
+
the last adopted block timestamp.
|
|
135
|
+
|
|
136
|
+
- A block whose `time` is **strictly older** than the last adopted time for
|
|
137
|
+
that `(vehicle, metric)` is dropped.
|
|
138
|
+
- An **equal-time** block re-emits **by design**: every reconnect
|
|
139
|
+
re-delivers a full-state snapshot with unchanged block times, and
|
|
140
|
+
consumers rely on that backfill.
|
|
141
|
+
- A **time-less** block (missing/malformed/naive `time`) is adopted and
|
|
142
|
+
**clears** the gate for that metric — it carries no ordering claim, so it
|
|
143
|
+
also stops gating subsequent values.
|
|
144
|
+
- A block whose `time` is in the **future** is rewritten to "now" before
|
|
145
|
+
delivery (and before gating), so a clock-skewed upstream stamp can neither
|
|
146
|
+
be delivered to the consumer nor become an unreachable high-water mark that
|
|
147
|
+
silently stalls the metric. `AbrpClient.async_get_current_telemetry` applies
|
|
148
|
+
the same clamp (it does not gate).
|
|
149
|
+
- **Known limitation:** a legitimately backdated server correction (an
|
|
150
|
+
older timestamp that really is a newer truth) is suppressed for the
|
|
151
|
+
stream's lifetime.
|
|
152
|
+
|
|
153
|
+
The gate can be **pre-warmed** across process restarts: pass
|
|
154
|
+
`TelemetryStream(..., seed=Mapping[int, Mapping[Metric, datetime]])` — a
|
|
155
|
+
per-vehicle map of metric to its last wire-block time (e.g. derived from the
|
|
156
|
+
consumer's last persisted snapshot) — and the stream seeds its high-water marks
|
|
157
|
+
from those times, each clamped not-future. Only times are needed; no typed
|
|
158
|
+
values. The clock is the `aioabrp._clock._now` seam (the monkeypatch target in
|
|
159
|
+
tests).
|
|
160
|
+
|
|
161
|
+
### Logging
|
|
162
|
+
|
|
163
|
+
Enable the `aioabrp` logger at `DEBUG` for triage. Connect/disconnect and
|
|
164
|
+
reasons log at `INFO`, watchdog stalls at `WARNING`, per-frame activity at
|
|
165
|
+
`DEBUG`. Frames are logged as keys and sizes only — frame bodies, header
|
|
166
|
+
values, and tokens (including GPS coordinates and other PII) are never
|
|
167
|
+
logged. Pass `name=` to `TelemetryStream` to prefix its log lines when
|
|
168
|
+
running multiple streams.
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
This project uses [uv](https://docs.astral.sh/uv/):
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
uv sync
|
|
176
|
+
uv run ruff format . && uv run ruff check . && uv run mypy && uv run pytest
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Note: the locked dev environment constrains `aiohttp<3.14` because the
|
|
180
|
+
latest `aioresponses` release is incompatible with aiohttp ≥ 3.14
|
|
181
|
+
([aioresponses#289](https://github.com/pnuckowski/aioresponses/issues/289));
|
|
182
|
+
the published runtime dependency stays unpinned.
|
|
183
|
+
|
|
184
|
+
### Releases
|
|
185
|
+
|
|
186
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org/);
|
|
187
|
+
there is no CHANGELOG file — release notes are generated from the commit
|
|
188
|
+
history by [git-cliff](https://git-cliff.org/) (`cliff.toml`) and published
|
|
189
|
+
as GitHub Releases. Pushing a `v*` tag runs the quality gate, builds the
|
|
190
|
+
sdist + wheel, verifies the tag matches `aioabrp.__version__`, publishes to
|
|
191
|
+
PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
|
|
192
|
+
(OIDC), and creates the GitHub Release.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT — see [LICENSE](LICENSE).
|
aioabrp-0.1.0/cliff.toml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# git-cliff configuration — release notes are generated from Conventional
|
|
2
|
+
# Commits (https://www.conventionalcommits.org) by the release workflow.
|
|
3
|
+
# https://git-cliff.org/docs/configuration
|
|
4
|
+
|
|
5
|
+
[changelog]
|
|
6
|
+
header = ""
|
|
7
|
+
body = """
|
|
8
|
+
{% for group, commits in commits | group_by(attribute="group") %}
|
|
9
|
+
### {{ group | striptags | trim | upper_first }}
|
|
10
|
+
{% for commit in commits %}
|
|
11
|
+
- {{ commit.message | split(pat="\n") | first | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
|
|
12
|
+
{%- endfor %}
|
|
13
|
+
{% endfor %}
|
|
14
|
+
"""
|
|
15
|
+
footer = ""
|
|
16
|
+
trim = true
|
|
17
|
+
|
|
18
|
+
[git]
|
|
19
|
+
conventional_commits = true
|
|
20
|
+
# Unconventional commits still land in release notes (under "Other") rather
|
|
21
|
+
# than vanishing silently.
|
|
22
|
+
filter_unconventional = false
|
|
23
|
+
protect_breaking_commits = true
|
|
24
|
+
tag_pattern = "v[0-9].*"
|
|
25
|
+
sort_commits = "oldest"
|
|
26
|
+
commit_parsers = [
|
|
27
|
+
{ message = "^feat", group = "<!-- 0 -->Features" },
|
|
28
|
+
{ message = "^fix", group = "<!-- 1 -->Bug fixes" },
|
|
29
|
+
{ message = "^perf", group = "<!-- 2 -->Performance" },
|
|
30
|
+
{ message = "^docs?", group = "<!-- 3 -->Documentation" },
|
|
31
|
+
{ message = "^refactor", group = "<!-- 4 -->Refactoring" },
|
|
32
|
+
{ message = "^test", group = "<!-- 5 -->Testing" },
|
|
33
|
+
{ message = "^(build|ci|chore)", group = "<!-- 6 -->Maintenance" },
|
|
34
|
+
{ message = ".*", group = "<!-- 7 -->Other" },
|
|
35
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aioabrp"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Async Python client for the A Better Routeplanner (ABRP) / Iternio telemetry API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Martin Andersson" }]
|
|
13
|
+
requires-python = ">=3.14"
|
|
14
|
+
dependencies = ["aiohttp"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/mtandersson/aioabrp"
|
|
27
|
+
Repository = "https://github.com/mtandersson/aioabrp"
|
|
28
|
+
Changelog = "https://github.com/mtandersson/aioabrp/releases"
|
|
29
|
+
Issues = "https://github.com/mtandersson/aioabrp/issues"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"aioresponses",
|
|
34
|
+
"mypy",
|
|
35
|
+
"pytest",
|
|
36
|
+
"pytest-asyncio",
|
|
37
|
+
"ruff",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.uv]
|
|
41
|
+
# aioresponses 0.7.8 (latest) is incompatible with aiohttp >= 3.14, which made
|
|
42
|
+
# ClientResponse.__init__'s `stream_writer` argument required
|
|
43
|
+
# (https://github.com/pnuckowski/aioresponses/issues/289). Constrain the locked
|
|
44
|
+
# dev environment until an aioresponses release supports aiohttp 3.14; the
|
|
45
|
+
# published runtime dependency stays unpinned.
|
|
46
|
+
constraint-dependencies = ["aiohttp<3.14"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.version]
|
|
49
|
+
path = "src/aioabrp/__init__.py"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["src/aioabrp"]
|
|
53
|
+
|
|
54
|
+
[tool.hatch.build.targets.sdist]
|
|
55
|
+
# Local-only planning files are untracked via .git/info/exclude, which
|
|
56
|
+
# hatchling (unlike .gitignore) does not honor — exclude them explicitly
|
|
57
|
+
# so they never ship in the sdist.
|
|
58
|
+
exclude = ["PLAN.md", "docs/"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
line-length = 88
|
|
62
|
+
target-version = "py314"
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = [
|
|
66
|
+
"A", # flake8-builtins
|
|
67
|
+
"ASYNC", # flake8-async
|
|
68
|
+
"B", # flake8-bugbear
|
|
69
|
+
"C4", # flake8-comprehensions
|
|
70
|
+
"D", # pydocstyle
|
|
71
|
+
"E", # pycodestyle errors
|
|
72
|
+
"F", # pyflakes
|
|
73
|
+
"I", # isort
|
|
74
|
+
"ISC", # flake8-implicit-str-concat
|
|
75
|
+
"N", # pep8-naming
|
|
76
|
+
"PIE", # flake8-pie
|
|
77
|
+
"PT", # flake8-pytest-style
|
|
78
|
+
"RET", # flake8-return
|
|
79
|
+
"RUF", # ruff-specific rules
|
|
80
|
+
"SIM", # flake8-simplify
|
|
81
|
+
"T20", # flake8-print
|
|
82
|
+
"UP", # pyupgrade
|
|
83
|
+
"W", # pycodestyle warnings
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[tool.ruff.lint.per-file-ignores]
|
|
87
|
+
"tests/**" = ["D1"]
|
|
88
|
+
|
|
89
|
+
[tool.ruff.lint.pydocstyle]
|
|
90
|
+
convention = "pep257"
|
|
91
|
+
|
|
92
|
+
[tool.mypy]
|
|
93
|
+
python_version = "3.14"
|
|
94
|
+
strict = true
|
|
95
|
+
files = ["src", "tests"]
|
|
96
|
+
|
|
97
|
+
[tool.pytest.ini_options]
|
|
98
|
+
testpaths = ["tests"]
|
|
99
|
+
asyncio_mode = "auto"
|
|
100
|
+
asyncio_default_fixture_loop_scope = "function"
|