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 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