aioabrp 0.1.0__py3-none-any.whl
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/__init__.py +39 -0
- aioabrp/_clock.py +44 -0
- aioabrp/_extract.py +295 -0
- aioabrp/_sse.py +97 -0
- aioabrp/_wire_types.py +246 -0
- aioabrp/auth.py +33 -0
- aioabrp/client.py +365 -0
- aioabrp/const.py +47 -0
- aioabrp/exceptions.py +13 -0
- aioabrp/models.py +155 -0
- aioabrp/py.typed +0 -0
- aioabrp/stream.py +522 -0
- aioabrp-0.1.0.dist-info/METADATA +218 -0
- aioabrp-0.1.0.dist-info/RECORD +16 -0
- aioabrp-0.1.0.dist-info/WHEEL +4 -0
- aioabrp-0.1.0.dist-info/licenses/LICENSE +21 -0
aioabrp/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Async Python client for the A Better Routeplanner (ABRP) / Iternio telemetry API."""
|
|
2
|
+
|
|
3
|
+
from .auth import AbstractAuth, StaticAuth
|
|
4
|
+
from .client import AbrpClient
|
|
5
|
+
from .exceptions import AbrpApiError, AbrpAuthError, AbrpError
|
|
6
|
+
from .models import (
|
|
7
|
+
AbrpVehicle,
|
|
8
|
+
CatalogEntry,
|
|
9
|
+
ChargingState,
|
|
10
|
+
ConnectionEvent,
|
|
11
|
+
ConnectionState,
|
|
12
|
+
Location,
|
|
13
|
+
Metric,
|
|
14
|
+
MetricValue,
|
|
15
|
+
Telemetry,
|
|
16
|
+
)
|
|
17
|
+
from .stream import TelemetryStream
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AbrpApiError",
|
|
23
|
+
"AbrpAuthError",
|
|
24
|
+
"AbrpClient",
|
|
25
|
+
"AbrpError",
|
|
26
|
+
"AbrpVehicle",
|
|
27
|
+
"AbstractAuth",
|
|
28
|
+
"CatalogEntry",
|
|
29
|
+
"ChargingState",
|
|
30
|
+
"ConnectionEvent",
|
|
31
|
+
"ConnectionState",
|
|
32
|
+
"Location",
|
|
33
|
+
"Metric",
|
|
34
|
+
"MetricValue",
|
|
35
|
+
"StaticAuth",
|
|
36
|
+
"Telemetry",
|
|
37
|
+
"TelemetryStream",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
aioabrp/_clock.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Wall-clock seam and pure future-time clamp helpers.
|
|
2
|
+
|
|
3
|
+
Shared by the stream gate, the one-shot getter, and seed validation so a
|
|
4
|
+
future-dated wire ``time`` can never escape the library or poison the
|
|
5
|
+
monotonicity gate. The clock is a single module-level seam (mirroring
|
|
6
|
+
``stream._sleep``): tests monkeypatch ``aioabrp._clock._now`` once and it
|
|
7
|
+
governs every clamp site. The clamp helpers are PURE — callers pass the
|
|
8
|
+
``now`` snapshot in — so exactly one ``_now()`` read happens per SSE frame,
|
|
9
|
+
per construction, and per one-shot call.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any, overload
|
|
15
|
+
|
|
16
|
+
from .models import Metric, MetricValue
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Single monkeypatch target for all clock-dependent behaviour. Returns a
|
|
20
|
+
# tz-aware UTC datetime to match the extractor's tz-aware wire times.
|
|
21
|
+
def _now() -> datetime:
|
|
22
|
+
return datetime.now(UTC)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@overload
|
|
26
|
+
def _clamp_time(t: datetime, now: datetime) -> datetime: ...
|
|
27
|
+
@overload
|
|
28
|
+
def _clamp_time(t: None, now: datetime) -> None: ...
|
|
29
|
+
def _clamp_time(t: datetime | None, now: datetime) -> datetime | None:
|
|
30
|
+
"""Return ``min(t, now)``; ``None`` passes through (no ordering claim)."""
|
|
31
|
+
if t is None or t <= now:
|
|
32
|
+
return t
|
|
33
|
+
return now
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clamp_future_times(
|
|
37
|
+
extracted: dict[Metric, MetricValue[Any]], now: datetime
|
|
38
|
+
) -> dict[Metric, MetricValue[Any]]:
|
|
39
|
+
"""Rewrite any future block ``time`` to ``now``; everything else untouched."""
|
|
40
|
+
out: dict[Metric, MetricValue[Any]] = {}
|
|
41
|
+
for metric, v in extracted.items():
|
|
42
|
+
clamped = _clamp_time(v.time, now)
|
|
43
|
+
out[metric] = v if clamped is v.time else replace(v, time=clamped)
|
|
44
|
+
return out
|
aioabrp/_extract.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Wire frame -> typed metric extraction. Internal module.
|
|
2
|
+
|
|
3
|
+
Converts one telemetry wire frame (a one-shot ``GET /2/tlm/{id}`` payload
|
|
4
|
+
or an SSE frame body — see :mod:`aioabrp._wire_types`) into the library's
|
|
5
|
+
typed event shape ``dict[Metric, MetricValue]`` (internal; the public
|
|
6
|
+
``on_update`` / ``async_get_current_telemetry`` boundary packs this into a
|
|
7
|
+
:class:`~aioabrp.models.Telemetry`).
|
|
8
|
+
|
|
9
|
+
Tolerance matrix shared by every metric: the extractors tolerate every
|
|
10
|
+
shape the server might emit for an unavailable metric — missing key,
|
|
11
|
+
``null`` block, empty dict, ``null`` leaf, non-numeric leaf, and
|
|
12
|
+
bool-as-number (bool is a subclass of ``int`` in Python) — by omitting
|
|
13
|
+
the metric. Extraction never raises on frame content. Display rounding
|
|
14
|
+
is the consumer's concern.
|
|
15
|
+
|
|
16
|
+
Per-metric load-bearing notes (ported with the extractors):
|
|
17
|
+
|
|
18
|
+
* soc / soh — ``frac`` arrives as a 0.0-1.0 fraction; surfaced x100 on
|
|
19
|
+
the familiar 0-100 % scale. soh is intentionally NOT clamped at 100 —
|
|
20
|
+
a post-recalibration overshoot (``frac > 1.0``) is meaningful drift
|
|
21
|
+
downstream statistics are meant to capture, so flattening it would
|
|
22
|
+
lose signal.
|
|
23
|
+
* battery_temperature — °C with NO lower-bound filter: winter operation
|
|
24
|
+
(sub-zero pack temps) is a real wire shape, not a degenerate one.
|
|
25
|
+
Distinct from any cabin or external temperature ABRP might surface in
|
|
26
|
+
future fields.
|
|
27
|
+
* odometer / range — native meters: a canonical, unit-flip-safe scale;
|
|
28
|
+
km rendering is display policy, not extraction policy.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
import math
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from datetime import datetime
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from .models import ChargingState, Location, Metric, MetricValue
|
|
38
|
+
|
|
39
|
+
_LOGGER = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# Wire key for each metric. Most metrics are named identically on the
|
|
42
|
+
# wire, but a handful of fields camelCase on the wire while the Metric
|
|
43
|
+
# enum stays snake_case. Pinned by tests/test_extract.py against the
|
|
44
|
+
# keep-set in _wire_types.py so the column cannot drift from the wire.
|
|
45
|
+
WIRE_KEYS: dict[Metric, str] = {
|
|
46
|
+
Metric.SOC: "soc",
|
|
47
|
+
Metric.POWER: "power",
|
|
48
|
+
Metric.VOLTAGE: "voltage",
|
|
49
|
+
Metric.SOE: "soe",
|
|
50
|
+
Metric.ODOMETER: "odometer",
|
|
51
|
+
Metric.CALIBRATED_REF_CONS: "calibratedRefCons",
|
|
52
|
+
Metric.BATTERY_CAPACITY: "batteryCapacity",
|
|
53
|
+
Metric.SOH: "soh",
|
|
54
|
+
Metric.RANGE: "estimatedBatteryRange",
|
|
55
|
+
Metric.BATTERY_TEMPERATURE: "batteryTemperature",
|
|
56
|
+
Metric.CHARGING_STATE: "chargingState",
|
|
57
|
+
Metric.LOCATION: "location",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Numeric leaf key under each metric's wire block (units in the module
|
|
61
|
+
# docstring: percent-after-x100, W, V, Wh, m, Wh/km, °C).
|
|
62
|
+
_NUMERIC_LEAF_KEYS: dict[Metric, str] = {
|
|
63
|
+
Metric.SOC: "frac",
|
|
64
|
+
Metric.POWER: "w",
|
|
65
|
+
Metric.VOLTAGE: "v",
|
|
66
|
+
Metric.SOE: "wh",
|
|
67
|
+
Metric.ODOMETER: "m",
|
|
68
|
+
Metric.CALIBRATED_REF_CONS: "wh_per_km",
|
|
69
|
+
Metric.BATTERY_CAPACITY: "wh",
|
|
70
|
+
Metric.SOH: "frac",
|
|
71
|
+
Metric.RANGE: "m",
|
|
72
|
+
Metric.BATTERY_TEMPERATURE: "c",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Metrics whose wire leaf is a 0.0-1.0 fraction surfaced x100 (soh
|
|
76
|
+
# deliberately unclamped — see the module docstring).
|
|
77
|
+
_FRACTION_METRICS = frozenset({Metric.SOC, Metric.SOH})
|
|
78
|
+
|
|
79
|
+
# Wire enum member -> ChargingState for the categorical ``chargingState``
|
|
80
|
+
# field. Closed-enum: every member ABRP's v2 spec emits has an entry
|
|
81
|
+
# (mirrors the ``ChargingStateValue`` Literal in _wire_types.py). An
|
|
82
|
+
# unrecognized/future member maps to None — the metric is omitted, never
|
|
83
|
+
# surfaced as a raw string (see :func:`_charging_state`).
|
|
84
|
+
_CHARGING_STATES: dict[str, ChargingState] = {
|
|
85
|
+
"CHARGING_AC": ChargingState.CHARGING_AC,
|
|
86
|
+
"CHARGING_DC": ChargingState.CHARGING_DC,
|
|
87
|
+
"CHARGING_UNKNOWN": ChargingState.CHARGING_UNKNOWN,
|
|
88
|
+
"NOT_CHARGING": ChargingState.NOT_CHARGING,
|
|
89
|
+
"PLUGGED_IN": ChargingState.PLUGGED_IN,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_block_time(block: Mapping[str, Any]) -> datetime | None:
|
|
94
|
+
"""Return the block's ``time`` as a tz-aware datetime, or ``None``.
|
|
95
|
+
|
|
96
|
+
Returns ``None`` whenever:
|
|
97
|
+
|
|
98
|
+
* the block has no ``time`` key — defense; every keep-set block
|
|
99
|
+
carries one in production but absence MUST NOT crash extraction;
|
|
100
|
+
* ``time`` is not a string (e.g. an opaque ``int`` marker);
|
|
101
|
+
* the string is malformed, or structurally well-formed but invalid
|
|
102
|
+
(e.g. ``2026-13-01T00:00:00Z``, ``2026-02-30T...``) —
|
|
103
|
+
:meth:`datetime.fromisoformat` raises :class:`ValueError` for
|
|
104
|
+
both shapes; caught here to keep call sites branch-free;
|
|
105
|
+
* the parsed datetime is naive (no tz suffix in the string) — naive
|
|
106
|
+
vs. aware comparison would raise :class:`TypeError` at the
|
|
107
|
+
monotonicity-gate comparison site. ABRP's wire format always
|
|
108
|
+
carries a ``Z`` suffix per the captured rollup sample
|
|
109
|
+
(2026-05-25), so rejecting naive strings filters wire-shape
|
|
110
|
+
regressions only.
|
|
111
|
+
|
|
112
|
+
Defensive return-``None`` (rather than fail-loud) is load-bearing:
|
|
113
|
+
callers rely on ``None`` to bypass the monotonicity gate and fall
|
|
114
|
+
through to "adopt incoming" — today's contract.
|
|
115
|
+
"""
|
|
116
|
+
raw = block.get("time")
|
|
117
|
+
if not isinstance(raw, str):
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
parsed = datetime.fromisoformat(raw)
|
|
121
|
+
except ValueError:
|
|
122
|
+
return None
|
|
123
|
+
if parsed.tzinfo is None:
|
|
124
|
+
return None
|
|
125
|
+
return parsed
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_clean_provider_str(value: object) -> bool:
|
|
129
|
+
r"""Return True iff ``value`` is a non-empty, unpadded string.
|
|
130
|
+
|
|
131
|
+
Single REJECT-ONLY contract for the provider-rejection guard, shared
|
|
132
|
+
by :func:`extract_metrics`'s per-block provider gate and any
|
|
133
|
+
consumer-side restore/persistence guard that needs the symmetric
|
|
134
|
+
semantics. An upstream that pads its enum strings is a wire-shape
|
|
135
|
+
regression we want loud, not silently normalised — so the guard
|
|
136
|
+
rejects padding rather than stripping.
|
|
137
|
+
|
|
138
|
+
**ASCII-whitespace contract.** The
|
|
139
|
+
``value == value.strip()`` check uses :meth:`str.strip` with no
|
|
140
|
+
argument, which only strips characters for which
|
|
141
|
+
``str.isspace()`` returns True. Several Unicode characters
|
|
142
|
+
commonly used as padding — ``U+200B`` (ZWS), ``U+200C`` (ZWNJ),
|
|
143
|
+
``U+200D`` (ZWJ), ``U+FEFF`` (BOM) — return False from
|
|
144
|
+
``isspace()`` and therefore survive both this guard and
|
|
145
|
+
``.strip()``: a ``"\u200bDERIVED"`` value would slip through as
|
|
146
|
+
"clean". That gap is intentional. ABRP's ``Provider`` enum is
|
|
147
|
+
closed and ASCII-only (see the spec at
|
|
148
|
+
https://api.iternio.com/swagger-ui/spec/prod/IternioPlanning.out.yaml);
|
|
149
|
+
a Unicode-whitespace-padded provider value would be an upstream
|
|
150
|
+
regression we want surfaced as a downstream mismatch / loud
|
|
151
|
+
failure of the matching ``Provider`` literal, not silently
|
|
152
|
+
sanitised at the boundary. ``NBSP`` (``U+00A0``) and other
|
|
153
|
+
in-``isspace`` Unicode whitespace at edges behave differently:
|
|
154
|
+
``.strip()`` removes them, so ``value != value.strip()`` and the
|
|
155
|
+
guard REJECTS them. That asymmetry vs. ZWS-family codepoints is
|
|
156
|
+
also acceptable given the closed-ASCII contract — both shapes
|
|
157
|
+
(slip-through-then-mismatch-downstream for ZWS-family, loud-
|
|
158
|
+
rejection-at-boundary for NBSP-family) surface upstream regressions.
|
|
159
|
+
"""
|
|
160
|
+
return isinstance(value, str) and bool(value) and value == value.strip()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _clean_provider(block: Mapping[str, Any]) -> str | None:
|
|
164
|
+
"""Return the block's upstream provider string, or ``None``.
|
|
165
|
+
|
|
166
|
+
Symmetric-reject boundary: every non-string AND the empty string AND
|
|
167
|
+
any whitespace-only or leading-/trailing-padded string map to
|
|
168
|
+
``None`` via :func:`is_clean_provider_str`. Per-metric ``provider``
|
|
169
|
+
is a ``NotRequired`` claim on each ``WithTimeAndProvider`` block
|
|
170
|
+
(see :mod:`aioabrp._wire_types`); absent / null / non-string /
|
|
171
|
+
empty / whitespace-padded are all treated as "no usable provider on
|
|
172
|
+
this block". Consumers that stamp providers may keep their prior
|
|
173
|
+
value on ``None`` (sticky-on-omission — providers don't flip
|
|
174
|
+
mid-stream in normal operation).
|
|
175
|
+
"""
|
|
176
|
+
provider = block.get("provider")
|
|
177
|
+
if is_clean_provider_str(provider):
|
|
178
|
+
return provider
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _float_leaf(block: Mapping[str, Any], leaf_key: str) -> float | None:
|
|
183
|
+
"""Return the numeric leaf ``block[leaf_key]`` as a float, or ``None``.
|
|
184
|
+
|
|
185
|
+
Shared numeric tolerance matrix: missing leaf, ``null`` leaf,
|
|
186
|
+
non-numeric leaf, bool (a subclass of ``int`` in Python — the
|
|
187
|
+
explicit check matters), and non-finite values (``json.loads``
|
|
188
|
+
accepts the non-standard ``NaN``/``Infinity`` tokens; neither is a
|
|
189
|
+
usable metric value) all map to ``None``. Never raises. Accepted
|
|
190
|
+
leaves are coerced so :class:`MetricValue.value` is always a runtime
|
|
191
|
+
``float`` regardless of wire int/float spelling.
|
|
192
|
+
"""
|
|
193
|
+
value = block.get(leaf_key)
|
|
194
|
+
if (
|
|
195
|
+
not isinstance(value, int | float)
|
|
196
|
+
or isinstance(value, bool)
|
|
197
|
+
or not math.isfinite(value)
|
|
198
|
+
):
|
|
199
|
+
return None
|
|
200
|
+
return float(value)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _charging_state(
|
|
204
|
+
block: Mapping[str, Any],
|
|
205
|
+
unknown_charging_states_seen: set[str],
|
|
206
|
+
log_name: str | None,
|
|
207
|
+
) -> ChargingState | None:
|
|
208
|
+
"""Map the categorical ``chargingState`` block to a :class:`ChargingState`.
|
|
209
|
+
|
|
210
|
+
Tolerates every degenerate leaf shape (missing / null / non-string
|
|
211
|
+
``state``) by returning ``None`` — consistent with the
|
|
212
|
+
absent/malformed -> omitted contract the numeric extractors share
|
|
213
|
+
(non-dict blocks are already rejected by :func:`extract_metrics`).
|
|
214
|
+
An unrecognized non-empty member also maps to ``None`` (the enum is
|
|
215
|
+
closed; a raw string must never leak into the typed event) and logs
|
|
216
|
+
a WARNING once per ``unknown_charging_states_seen`` set so upstream
|
|
217
|
+
enum drift leaves a runtime breadcrumb. The dedup set is
|
|
218
|
+
caller-owned — one per client/stream instance, never module-global —
|
|
219
|
+
so warning state cannot leak across accounts.
|
|
220
|
+
"""
|
|
221
|
+
state = block.get("state")
|
|
222
|
+
if not isinstance(state, str):
|
|
223
|
+
return None
|
|
224
|
+
member = _CHARGING_STATES.get(state)
|
|
225
|
+
if member is None and state and state not in unknown_charging_states_seen:
|
|
226
|
+
unknown_charging_states_seen.add(state)
|
|
227
|
+
_LOGGER.warning(
|
|
228
|
+
"%sUnrecognized ABRP chargingState %r; the charging_state metric "
|
|
229
|
+
"will be omitted for this state until aioabrp adds it",
|
|
230
|
+
f"{log_name}: " if log_name else "",
|
|
231
|
+
state,
|
|
232
|
+
)
|
|
233
|
+
return member
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _location(block: Mapping[str, Any]) -> Location | None:
|
|
237
|
+
"""Extract a GPS coordinate pair, or ``None`` when unavailable.
|
|
238
|
+
|
|
239
|
+
The wire spells longitude ``long`` (spec ``Location`` block — see
|
|
240
|
+
:mod:`aioabrp._wire_types`); the model field is ``lon``. Tolerance
|
|
241
|
+
mirrors the numeric matrix: either leaf missing / null / non-numeric
|
|
242
|
+
/ bool omits the whole metric (a half-coordinate is meaningless).
|
|
243
|
+
"""
|
|
244
|
+
lat = _float_leaf(block, "lat")
|
|
245
|
+
long = _float_leaf(block, "long")
|
|
246
|
+
if lat is None or long is None:
|
|
247
|
+
return None
|
|
248
|
+
return Location(lat=lat, lon=long)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def extract_metrics(
|
|
252
|
+
frame: Mapping[str, Any],
|
|
253
|
+
*,
|
|
254
|
+
unknown_charging_states_seen: set[str],
|
|
255
|
+
log_name: str | None = None,
|
|
256
|
+
) -> dict[Metric, MetricValue[Any]]:
|
|
257
|
+
"""Extract every present, well-formed metric from one wire frame.
|
|
258
|
+
|
|
259
|
+
Iterates :data:`WIRE_KEYS`; a metric appears in the result only when
|
|
260
|
+
its wire block is a dict and its value survives the tolerance matrix
|
|
261
|
+
(module docstring). Each emitted :class:`MetricValue` carries the
|
|
262
|
+
block's tz-aware ``time`` (or ``None`` — see
|
|
263
|
+
:func:`parse_block_time`) and clean ``provider`` (or ``None`` — see
|
|
264
|
+
:func:`is_clean_provider_str`).
|
|
265
|
+
|
|
266
|
+
``unknown_charging_states_seen`` is the caller-owned dedup set for
|
|
267
|
+
the unrecognized-chargingState warning: one warning per state per
|
|
268
|
+
set. Keep one set per client/stream instance so multi-account
|
|
269
|
+
consumers do not share dedup state. ``log_name`` prefixes that
|
|
270
|
+
warning when given; the warning contains nothing from the frame but
|
|
271
|
+
the state string itself.
|
|
272
|
+
"""
|
|
273
|
+
result: dict[Metric, MetricValue[Any]] = {}
|
|
274
|
+
for metric, wire_key in WIRE_KEYS.items():
|
|
275
|
+
block = frame.get(wire_key)
|
|
276
|
+
if not isinstance(block, dict):
|
|
277
|
+
# Missing key / null block / non-dict block: metric absent.
|
|
278
|
+
continue
|
|
279
|
+
value: float | ChargingState | Location | None
|
|
280
|
+
if metric is Metric.CHARGING_STATE:
|
|
281
|
+
value = _charging_state(block, unknown_charging_states_seen, log_name)
|
|
282
|
+
elif metric is Metric.LOCATION:
|
|
283
|
+
value = _location(block)
|
|
284
|
+
else:
|
|
285
|
+
value = _float_leaf(block, _NUMERIC_LEAF_KEYS[metric])
|
|
286
|
+
if value is not None and metric in _FRACTION_METRICS:
|
|
287
|
+
value = value * 100
|
|
288
|
+
if value is None:
|
|
289
|
+
continue
|
|
290
|
+
result[metric] = MetricValue(
|
|
291
|
+
value=value,
|
|
292
|
+
time=parse_block_time(block),
|
|
293
|
+
provider=_clean_provider(block),
|
|
294
|
+
)
|
|
295
|
+
return result
|
aioabrp/_sse.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""SSE byte-stream parsing internals.
|
|
2
|
+
|
|
3
|
+
Internal module: converts the raw ``text/event-stream`` bytes of the ABRP
|
|
4
|
+
v2 telemetry endpoint into raw SSE event blocks (:func:`iter_sse_events`)
|
|
5
|
+
and one event block into a JSON frame dict (:func:`parse_sse_event`).
|
|
6
|
+
|
|
7
|
+
PII contract: nothing in this module ever logs a frame body, header value,
|
|
8
|
+
or token — frame key names only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from codecs import getincrementaldecoder
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from aiohttp import StreamReader
|
|
18
|
+
|
|
19
|
+
from .exceptions import AbrpApiError
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def iter_sse_events(content: StreamReader) -> AsyncIterator[str]:
|
|
25
|
+
r"""Yield raw ``\n\n``-terminated SSE event blocks from a byte stream.
|
|
26
|
+
|
|
27
|
+
An incremental UTF-8 decoder preserves multi-byte sequences that span
|
|
28
|
+
chunk boundaries — naive per-chunk decode would mojibake on Unicode
|
|
29
|
+
vehicle names / location strings and the JSON parser would then reject
|
|
30
|
+
the otherwise-valid frame.
|
|
31
|
+
"""
|
|
32
|
+
decoder = getincrementaldecoder("utf-8")(errors="replace")
|
|
33
|
+
buffer = ""
|
|
34
|
+
async for chunk in content.iter_any():
|
|
35
|
+
buffer += decoder.decode(chunk)
|
|
36
|
+
# SSE spec accepts ``\n``, ``\r\n`` and ``\r`` as line
|
|
37
|
+
# terminators. Normalize before splitting on ``\n\n``.
|
|
38
|
+
# A trailing lone ``\r`` may be the first half of a ``\r\n``
|
|
39
|
+
# pair that arrives in the next chunk — hold it back so the
|
|
40
|
+
# pair-rewrite below sees the whole sequence and we don't
|
|
41
|
+
# mistake a CR for an extra blank line. The re-appended ``\r``
|
|
42
|
+
# stays un-normalized in the buffer until the next chunk's pass
|
|
43
|
+
# — or the final flush — normalizes it, which is why the
|
|
44
|
+
# flush-path normalization below is not redundant.
|
|
45
|
+
if buffer.endswith("\r"):
|
|
46
|
+
buffer, held_cr = buffer[:-1], "\r"
|
|
47
|
+
else:
|
|
48
|
+
held_cr = ""
|
|
49
|
+
buffer = buffer.replace("\r\n", "\n").replace("\r", "\n")
|
|
50
|
+
buffer += held_cr
|
|
51
|
+
while "\n\n" in buffer:
|
|
52
|
+
event, _, buffer = buffer.partition("\n\n")
|
|
53
|
+
yield event
|
|
54
|
+
# Flush the incremental decoder and any residual buffer in case the
|
|
55
|
+
# server closed the stream gracefully without a trailing blank line.
|
|
56
|
+
# Rare but observed on cold reconnects.
|
|
57
|
+
buffer += decoder.decode(b"", final=True)
|
|
58
|
+
buffer = buffer.replace("\r\n", "\n").replace("\r", "\n")
|
|
59
|
+
if buffer.strip():
|
|
60
|
+
yield buffer
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_sse_event(event: str) -> dict[str, Any] | None:
|
|
64
|
+
r"""Parse one ``\n\n``-terminated SSE event block into a JSON frame dict.
|
|
65
|
+
|
|
66
|
+
Returns ``None`` for events that carry only comments / keepalives, or
|
|
67
|
+
for frames missing the required ``vehicleId`` key (probable upstream
|
|
68
|
+
drift — drop quietly with a keys-only debug log rather than killing
|
|
69
|
+
the SSE consumer). Raises :exc:`AbrpApiError` on malformed JSON or a
|
|
70
|
+
decoded payload that is not a JSON object.
|
|
71
|
+
"""
|
|
72
|
+
data_parts: list[str] = []
|
|
73
|
+
for line in event.split("\n"):
|
|
74
|
+
if not line or line.startswith(":"):
|
|
75
|
+
# blank line (intra-event whitespace) or SSE comment (``: heartbeat``)
|
|
76
|
+
continue
|
|
77
|
+
if line.startswith("data:"):
|
|
78
|
+
data_parts.append(line[5:].removeprefix(" "))
|
|
79
|
+
if not data_parts:
|
|
80
|
+
return None
|
|
81
|
+
payload = "\n".join(data_parts)
|
|
82
|
+
try:
|
|
83
|
+
decoded = json.loads(payload)
|
|
84
|
+
except json.JSONDecodeError as err:
|
|
85
|
+
raise AbrpApiError(f"malformed SSE frame: {err}") from err
|
|
86
|
+
if not isinstance(decoded, dict):
|
|
87
|
+
raise AbrpApiError(f"unexpected SSE frame shape: {type(decoded).__name__}")
|
|
88
|
+
if "vehicleId" not in decoded:
|
|
89
|
+
# PII contract: key names only — never the frame body.
|
|
90
|
+
_LOGGER.debug(
|
|
91
|
+
"Dropping SSE frame missing 'vehicleId': keys=%s",
|
|
92
|
+
sorted(decoded.keys()),
|
|
93
|
+
)
|
|
94
|
+
return None
|
|
95
|
+
# Per-metric shape validation lives at the consumer (the extraction
|
|
96
|
+
# layer) which tolerates missing/null keys.
|
|
97
|
+
return decoded
|