Skip to content

Commit 016544d

Browse files
committed
fix: report connection down on failed first connect, deliver TPMS warnings as dict
1 parent 90b47c3 commit 016544d

5 files changed

Lines changed: 337 additions & 8 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Project-intrinsic knowledge that should travel with the code: build, test, relea
2121

2222
- `TeslemetryStream` has no `__aenter__`/`__aexit__` - do not reintroduce `async with TeslemetryStream(...)` in the README or examples. Lifecycle is listener-driven: `async_add_listener` connects on the first public listener, disconnects on the last one removed; `connect()`/`close()`/`listen()` are for callers managing it themselves.
2323
- Exactly one `_listen_task` is owned. A second concurrent `listen()` joins the owner via `await existing_task` rather than racing it; `connect()` serializes the GET behind `_connect_lock` and re-checks `active` after both the lock and the response, discarding a response that arrived after a stop.
24-
- Internal reconnect paths (EOF, `ClientError`, unexpected exceptions) call `_close_response()`, **never** `close()` - `close()` additionally flips `active=False` and cancels the owned task, i.e. a real stop. `listen()`'s `finally` calls `_close_response()` unconditionally so cancellation still releases the connection.
24+
- Internal reconnect paths (EOF, `ClientError`, unexpected exceptions) call `_close_response()` (the error paths via `_connection_failed()`, which reports `False` itself when no connect ever succeeded), **never** `close()` - `close()` additionally flips `active=False` and cancels the owned task, i.e. a real stop. `listen()`'s `finally` calls `_close_response()` unconditionally so cancellation still releases the connection.
2525
- Dispatch iterates a **sorted snapshot** of `_listeners.values()` with internal listeners first, never the live dict: a callback adding a listener mid-dispatch (e.g. `get_vehicle()` for an uncached VIN) must not raise `RuntimeError: dictionary changed size during iteration`, and a public callback must not mutate the event before an internal bookkeeping listener has cached from it. `_update_connection_listeners()` has the same mutation hazard and iterates a plain `list(...)` snapshot (no ordering requirement - connection listeners share no mutable event).
2626
- `__anext__` treats `ClientResponseError` with status 401/403 as terminal: sets `active = False` and raises `TeslemetryStreamAuthenticationError`. Every other `aiohttp.ClientError` keeps backoff-and-reconnect.
2727
- `connect()`'s `sock_read=30` bounds a connection that stops producing bytes (no data, no SSE keepalive) - a read timeout raises `aiohttp.ClientError`, which the existing client-error handler in `__anext__` already logs and reconnects. There is no separate silence mechanism.

‎teslemetry_stream/stream.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,18 @@ def _close_response(self) -> None:
306306
self._response = None
307307
self._update_connection_listeners(False)
308308

309+
def _connection_failed(self) -> None:
310+
"""
311+
Mark the stream down after a failed connect or read.
312+
"""
313+
if self._response is None:
314+
# A connect that never succeeded has no response to close, so
315+
# _close_response() stays silent - without this a stream that is
316+
# down from the start would never tell its listeners so.
317+
self._update_connection_listeners(False)
318+
else:
319+
self._close_response()
320+
309321
def close(self) -> None:
310322
"""
311323
Stop the stream: closes the response and cancels the owned listen
@@ -367,17 +379,17 @@ async def __anext__(self) -> dict[str, Any]:
367379
# a bad credential as an indefinitely quiet stream.
368380
LOGGER.error("Authentication failed, not retrying: %s", repr(error))
369381
self.active = False
370-
self._close_response()
382+
self._connection_failed()
371383
raise TeslemetryStreamAuthenticationError() from error
372384
LOGGER.warning("Client error: %s", repr(error))
373-
self._close_response()
385+
self._connection_failed()
374386
delay = min(2**self.retries, 600)
375387
LOGGER.debug("Reconnecting in %s seconds", delay)
376388
await asyncio.sleep(delay)
377389
self.retries += 1
378390
except Exception as error:
379391
LOGGER.error("Unexpected error: %s", repr(error))
380-
self._close_response()
392+
self._connection_failed()
381393
LOGGER.debug("Reconnecting in %s seconds", 1)
382394
await asyncio.sleep(1)
383395

‎teslemetry_stream/vehicle.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2497,12 +2497,12 @@ def listen_TonneauTentMode(
24972497
)
24982498

24992499
def listen_TpmsHardWarnings(
2500-
self, callback: Callable[[int | None], None]
2500+
self, callback: Callable[[dict[str, Any] | None], None]
25012501
) -> Callable[[], None]:
25022502
"""Listen for TPMS Hard Warnings."""
25032503
self._enable_field(Signal.TPMS_HARD_WARNINGS)
25042504
return self.stream.async_add_listener(
2505-
make_int(Signal.TPMS_HARD_WARNINGS, callback),
2505+
make_dict(Signal.TPMS_HARD_WARNINGS, callback),
25062506
{"vin": self.vin, "data": {Signal.TPMS_HARD_WARNINGS: None}},
25072507
)
25082508

@@ -2587,12 +2587,12 @@ def listen_TpmsPressureRr(
25872587
)
25882588

25892589
def listen_TpmsSoftWarnings(
2590-
self, callback: Callable[[int | None], None]
2590+
self, callback: Callable[[dict[str, Any] | None], None]
25912591
) -> Callable[[], None]:
25922592
"""Listen for TPMS Soft Warnings."""
25932593
self._enable_field(Signal.TPMS_SOFT_WARNINGS)
25942594
return self.stream.async_add_listener(
2595-
make_int(Signal.TPMS_SOFT_WARNINGS, callback),
2595+
make_dict(Signal.TPMS_SOFT_WARNINGS, callback),
25962596
{"vin": self.vin, "data": {Signal.TPMS_SOFT_WARNINGS: None}},
25972597
)
25982598

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""Regression tests for connection listeners when the stream cannot connect.
2+
3+
A connect that fails before the stream has ever connected leaves no response
4+
to close, so the reconnect path used to say nothing to connection listeners.
5+
A consumer that marks its entities unavailable on `False` then kept showing
6+
setup-time values as live for as long as the outage lasted. Every failed
7+
attempt must report `False`, while a stream that is already connected must
8+
still report exactly one `False` when it drops.
9+
"""
10+
from __future__ import annotations
11+
12+
import asyncio
13+
import contextlib
14+
from typing import Any
15+
16+
import aiohttp
17+
18+
from teslemetry_stream.exception import TeslemetryStreamAuthenticationError
19+
from teslemetry_stream.stream import TeslemetryStream
20+
21+
REQUEST_INFO = aiohttp.RequestInfo(
22+
url="https://fake.teslemetry.com/sse",
23+
method="GET",
24+
headers={}, # type: ignore[arg-type]
25+
real_url="https://fake.teslemetry.com/sse", # type: ignore[arg-type]
26+
)
27+
28+
29+
class FakeContent:
30+
"""Async-iterable response body that blocks until failed."""
31+
32+
def __init__(self) -> None:
33+
self._blocker: asyncio.Future[None] = asyncio.get_running_loop().create_future()
34+
35+
def __aiter__(self) -> FakeContent:
36+
return self
37+
38+
async def __anext__(self) -> bytes:
39+
await self._blocker
40+
raise AssertionError("unreachable - blocker only resolves via an exception")
41+
42+
def fail(self, exc: BaseException) -> None:
43+
self._blocker.set_exception(exc)
44+
45+
46+
class FakeResponse:
47+
def __init__(self) -> None:
48+
self.url = "https://fake.teslemetry.com/sse"
49+
self.status = 200
50+
self.content = FakeContent()
51+
52+
def close(self) -> None:
53+
pass
54+
55+
56+
class FakeSession:
57+
"""Each `get()` raises or returns the next queued result; once they run
58+
out, `exhausted` resolves and the call blocks, so a test observes the
59+
first retry instead of spinning through backoff."""
60+
61+
def __init__(self, get_results: list[Any]) -> None:
62+
self._get_results = list(get_results)
63+
self.exhausted: asyncio.Future[None] = asyncio.get_running_loop().create_future()
64+
65+
async def get(self, url: str, **kwargs: Any) -> Any:
66+
if not self._get_results:
67+
self.exhausted.set_result(None)
68+
await asyncio.Future()
69+
result = self._get_results.pop(0)
70+
if isinstance(result, BaseException):
71+
raise result
72+
return result
73+
74+
75+
def make_stream(session: FakeSession) -> TeslemetryStream:
76+
return TeslemetryStream(
77+
session=session, # type: ignore[arg-type]
78+
access_token="token",
79+
server="api.teslemetry.com",
80+
manual=True,
81+
)
82+
83+
84+
def check(label: str, ok: bool, detail: str = "") -> bool:
85+
print(f"{label:<72} {'PASS' if ok else 'FAIL'}{' ' + detail if detail else ''}")
86+
return ok
87+
88+
89+
async def next_connection_event(
90+
stream: TeslemetryStream, session: FakeSession, events: list[bool], count: int
91+
) -> asyncio.Task[Any]:
92+
"""Run `__anext__` until `count` connection events have arrived or the
93+
stream starts a retry the session has no answer for, then return the
94+
still-pending task."""
95+
reached = asyncio.get_running_loop().create_future()
96+
97+
def record(value: bool) -> None:
98+
events.append(value)
99+
if len(events) >= count and not reached.done():
100+
reached.set_result(None)
101+
102+
stream.async_add_connection_listener(record)
103+
stream.active = True
104+
task = asyncio.create_task(stream.__anext__())
105+
await asyncio.wait(
106+
{reached, session.exhausted, task}, return_when=asyncio.FIRST_COMPLETED
107+
)
108+
return task
109+
110+
111+
async def drain(task: asyncio.Task[Any]) -> None:
112+
task.cancel()
113+
with contextlib.suppress(asyncio.CancelledError, Exception):
114+
await task
115+
116+
117+
async def test_failed_first_connect_reports_down(results: list[bool]) -> None:
118+
session = FakeSession([aiohttp.ClientConnectionError("refused")])
119+
stream = make_stream(session)
120+
events: list[bool] = []
121+
122+
task = await next_connection_event(stream, session, events, 1)
123+
124+
results.append(
125+
check(
126+
"a failed first connect notifies connection listeners with False",
127+
events == [False],
128+
f"got {events}",
129+
)
130+
)
131+
results.append(check("the stream is still retrying", not task.done() and stream.active))
132+
await drain(task)
133+
134+
135+
async def test_unexpected_first_connect_error_reports_down(results: list[bool]) -> None:
136+
session = FakeSession([RuntimeError("boom")])
137+
stream = make_stream(session)
138+
events: list[bool] = []
139+
140+
task = await next_connection_event(stream, session, events, 1)
141+
142+
results.append(
143+
check(
144+
"an unexpected error on first connect notifies False",
145+
events == [False],
146+
f"got {events}",
147+
)
148+
)
149+
await drain(task)
150+
151+
152+
async def test_auth_failure_on_first_connect_reports_down(results: list[bool]) -> None:
153+
error = aiohttp.ClientResponseError(
154+
request_info=REQUEST_INFO, history=(), status=401, message="Unauthorized"
155+
)
156+
stream = make_stream(FakeSession([error]))
157+
events: list[bool] = []
158+
stream.async_add_connection_listener(events.append)
159+
stream.active = True
160+
161+
raised = False
162+
try:
163+
await stream.__anext__()
164+
except TeslemetryStreamAuthenticationError:
165+
raised = True
166+
167+
results.append(check("a 401 on first connect still raises", raised))
168+
results.append(
169+
check("a 401 on first connect notifies False", events == [False], f"got {events}")
170+
)
171+
172+
173+
async def test_drop_after_connect_reports_one_down(results: list[bool]) -> None:
174+
response = FakeResponse()
175+
session = FakeSession([response])
176+
stream = make_stream(session)
177+
events: list[bool] = []
178+
179+
task = await next_connection_event(stream, session, events, 1)
180+
response.content.fail(aiohttp.ClientPayloadError("reset"))
181+
reached = asyncio.get_running_loop().create_future()
182+
stream.async_add_connection_listener(
183+
lambda value: reached.done() or reached.set_result(None)
184+
)
185+
await asyncio.wait(
186+
{reached, session.exhausted, task}, return_when=asyncio.FIRST_COMPLETED
187+
)
188+
189+
results.append(
190+
check(
191+
"a connected stream that drops reports True then a single False",
192+
events == [True, False],
193+
f"got {events}",
194+
)
195+
)
196+
await drain(task)
197+
198+
199+
async def main() -> None:
200+
results: list[bool] = []
201+
await test_failed_first_connect_reports_down(results)
202+
await test_unexpected_first_connect_error_reports_down(results)
203+
await test_auth_failure_on_first_connect_reports_down(results)
204+
await test_drop_after_connect_reports_one_down(results)
205+
206+
print("-" * 72)
207+
print("ALL PASS" if all(results) else "FAILURES PRESENT")
208+
if not all(results):
209+
raise SystemExit(1)
210+
211+
212+
if __name__ == "__main__":
213+
asyncio.run(main())

‎tests/test_tpms_warnings.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Regression tests for the TPMS soft/hard warning listeners.
2+
3+
Both fields stream as a per-tire object of booleans, never a number. They
4+
were typed and parsed as ints, so a consumer trusting the annotation treated
5+
the object as a scalar and rendered its repr as a sensor state.
6+
"""
7+
from __future__ import annotations
8+
9+
import asyncio
10+
import inspect
11+
from typing import Any, Callable
12+
13+
from teslemetry_stream.const import Signal
14+
from teslemetry_stream.vehicle import TeslemetryStreamVehicle
15+
16+
VIN = "TESTVIN0000000001"
17+
18+
# The real wire shape, as streamed by a Model 3 on 2026.26.6.
19+
WARNINGS = {
20+
"frontLeft": False,
21+
"frontRight": True,
22+
"rearLeft": False,
23+
"rearRight": False,
24+
"semiMiddleAxleLeft2": False,
25+
"semiMiddleAxleRight2": False,
26+
"semiRearAxleLeft": False,
27+
"semiRearAxleLeft2": False,
28+
"semiRearAxleRight": False,
29+
"semiRearAxleRight2": False,
30+
}
31+
32+
33+
class FakeStream:
34+
"""Minimal stand-in for TeslemetryStream that captures listeners."""
35+
36+
manual = True
37+
38+
def __init__(self) -> None:
39+
self.captured: dict[str, Any] = {}
40+
41+
def async_add_listener(
42+
self,
43+
callback: Callable[[dict[str, Any]], None],
44+
filters: dict[str, Any] | None = None,
45+
internal: bool = False,
46+
) -> Callable[[], None]:
47+
assert filters is not None
48+
if "data" in filters:
49+
self.captured[next(iter(filters["data"]))] = callback
50+
return lambda: None
51+
52+
def async_add_connection_listener(
53+
self, callback: Callable[[bool], None]
54+
) -> Callable[[], None]:
55+
return lambda: None
56+
57+
58+
def check(label: str, ok: bool, detail: str = "") -> bool:
59+
print(f"{label:<72} {'PASS' if ok else 'FAIL'}{' ' + detail if detail else ''}")
60+
return ok
61+
62+
63+
def deliver(name: str, signal: Signal, raw: Any) -> Any:
64+
stream = FakeStream()
65+
vehicle = TeslemetryStreamVehicle(stream, VIN) # type: ignore[arg-type]
66+
vehicle.fields = {signal.value: {}}
67+
vehicle._populated = True
68+
delivered: list[Any] = []
69+
getattr(vehicle, name)(delivered.append)
70+
stream.captured[signal.value]({"vin": VIN, "data": {signal.value: raw}})
71+
return delivered[0]
72+
73+
74+
async def main() -> None:
75+
results: list[bool] = []
76+
for name, signal in (
77+
("listen_TpmsSoftWarnings", Signal.TPMS_SOFT_WARNINGS),
78+
("listen_TpmsHardWarnings", Signal.TPMS_HARD_WARNINGS),
79+
):
80+
annotation = inspect.signature(
81+
getattr(TeslemetryStreamVehicle, name)
82+
).parameters["callback"].annotation
83+
results.append(
84+
check(
85+
f"{name} callback is typed as a dict",
86+
annotation == "Callable[[dict[str, Any] | None], None]",
87+
f"got {annotation}",
88+
)
89+
)
90+
got = deliver(name, signal, WARNINGS)
91+
results.append(
92+
check(f"{name} delivers the per-tire object", got == WARNINGS, f"got {got!r}")
93+
)
94+
got = deliver(name, signal, None)
95+
results.append(check(f"{name} delivers None for null", got is None, f"got {got!r}"))
96+
97+
print("-" * 72)
98+
print("ALL PASS" if all(results) else "FAILURES PRESENT")
99+
if not all(results):
100+
raise SystemExit(1)
101+
102+
103+
if __name__ == "__main__":
104+
asyncio.run(main())

0 commit comments

Comments
 (0)