From 4c7dfbf5478008901532f02ca5b177531a3ea0ed Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 21 Oct 2026 19:58:29 +1000 Subject: [PATCH] fix: per-door listeners deliver None for a null or partial DoorState --- teslemetry_stream/vehicle.py | 24 ++++++--- tests/test_door_state_null.py | 91 +++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 tests/test_door_state_null.py diff --git a/teslemetry_stream/vehicle.py b/teslemetry_stream/vehicle.py index c54f02b..0d1eef1 100644 --- a/teslemetry_stream/vehicle.py +++ b/teslemetry_stream/vehicle.py @@ -1254,7 +1254,7 @@ def listen_FrontDriverDoor( """Listen for Front Driver Door State.""" self._enable_field(Signal.DOOR_STATE) return self.stream.async_add_listener( - lambda x: callback(x["data"][Signal.DOOR_STATE].get("DriverFront")), + make_door("DriverFront", callback), {"vin": self.vin, "data": {Signal.DOOR_STATE: None}}, ) @@ -1264,7 +1264,7 @@ def listen_RearDriverDoor( """Listen for Rear Driver Door State.""" self._enable_field(Signal.DOOR_STATE) return self.stream.async_add_listener( - lambda x: callback(x["data"][Signal.DOOR_STATE].get("DriverRear")), + make_door("DriverRear", callback), {"vin": self.vin, "data": {Signal.DOOR_STATE: None}}, ) @@ -1274,7 +1274,7 @@ def listen_FrontPassengerDoor( """Listen for Front Passenger Door State.""" self._enable_field(Signal.DOOR_STATE) return self.stream.async_add_listener( - lambda x: callback(x["data"][Signal.DOOR_STATE].get("PassengerFront")), + make_door("PassengerFront", callback), {"vin": self.vin, "data": {Signal.DOOR_STATE: None}}, ) @@ -1284,7 +1284,7 @@ def listen_RearPassengerDoor( """Listen for Rear Passenger Door State.""" self._enable_field(Signal.DOOR_STATE) return self.stream.async_add_listener( - lambda x: callback(x["data"][Signal.DOOR_STATE].get("PassengerRear")), + make_door("PassengerRear", callback), {"vin": self.vin, "data": {Signal.DOOR_STATE: None}}, ) @@ -1294,7 +1294,7 @@ def listen_TrunkFront( """Listen for Front Trunk Door State.""" self._enable_field(Signal.DOOR_STATE) return self.stream.async_add_listener( - lambda x: callback(x["data"][Signal.DOOR_STATE].get("TrunkFront")), + make_door("TrunkFront", callback), {"vin": self.vin, "data": {Signal.DOOR_STATE: None}}, ) @@ -1304,7 +1304,7 @@ def listen_TrunkRear( """Listen for Rear Trunk Door State.""" self._enable_field(Signal.DOOR_STATE) return self.stream.async_add_listener( - lambda x: callback(x["data"][Signal.DOOR_STATE].get("TrunkRear")), + make_door("TrunkRear", callback), {"vin": self.vin, "data": {Signal.DOOR_STATE: None}}, ) @@ -3277,6 +3277,18 @@ def typer(event: dict[str, Any]) -> None: return typer +def make_door( + key: str, callback: Callable[[bool | None], None] +) -> Callable[[dict[str, Any]], None]: + """Listener factory for one door within DoorState; a null or non-dict DoorState yields None.""" + + def typer(event: dict[str, Any]) -> None: + data = event["data"][Signal.DOOR_STATE] + callback(data.get(key) if isinstance(data, dict) else None) + + return typer + + def make_location( signal: Signal, callback: Callable[[TeslaLocation | None], None] ) -> Callable[[dict[str, Any]], None]: diff --git a/tests/test_door_state_null.py b/tests/test_door_state_null.py new file mode 100644 index 0000000..af8de47 --- /dev/null +++ b/tests/test_door_state_null.py @@ -0,0 +1,91 @@ +"""Regression test: per-door listeners tolerate a null or partial DoorState. + +BLE broadcast ingest can emit DoorState as null or with only some doors. The +per-door listeners called .get on the raw value, so a null DoorState raised +AttributeError instead of delivering None. +""" +from __future__ import annotations + +import asyncio +from typing import Any, Callable + +from teslemetry_stream.const import Signal +from teslemetry_stream.vehicle import TeslemetryStreamVehicle + +VIN = "TESTVIN0000000001" + +DOORS = { + "listen_FrontDriverDoor": "DriverFront", + "listen_RearDriverDoor": "DriverRear", + "listen_FrontPassengerDoor": "PassengerFront", + "listen_RearPassengerDoor": "PassengerRear", + "listen_TrunkFront": "TrunkFront", + "listen_TrunkRear": "TrunkRear", +} + + +class FakeStream: + """Minimal stand-in for TeslemetryStream that captures listeners.""" + + manual = True + + def __init__(self) -> None: + self.captured: dict[str, Any] = {} + + def async_add_listener( + self, + callback: Callable[[dict[str, Any]], None], + filters: dict[str, Any] | None = None, + internal: bool = False, + ) -> Callable[[], None]: + assert filters is not None + if "data" in filters: + self.captured[next(iter(filters["data"]))] = callback + return lambda: None + + def async_add_connection_listener( + self, callback: Callable[[bool], None] + ) -> Callable[[], None]: + return lambda: None + + +def check(label: str, ok: bool, detail: str = "") -> bool: + print(f"{label:<72} {'PASS' if ok else 'FAIL'}{' ' + detail if detail else ''}") + return ok + + +def deliver(name: str, raw: Any) -> Any: + stream = FakeStream() + vehicle = TeslemetryStreamVehicle(stream, VIN) # type: ignore[arg-type] + vehicle.fields = {Signal.DOOR_STATE.value: {}} + vehicle._populated = True + delivered: list[Any] = [] + getattr(vehicle, name)(delivered.append) + stream.captured[Signal.DOOR_STATE.value]( + {"vin": VIN, "data": {Signal.DOOR_STATE.value: raw}} + ) + return delivered[0] + + +async def main() -> None: + results: list[bool] = [] + for name, key in DOORS.items(): + got = deliver(name, {key: True, "Other": False}) + results.append(check(f"{name} delivers the door value", got is True, f"got {got!r}")) + got = deliver(name, {"Unrelated": True}) + results.append( + check(f"{name} delivers None for partial DoorState", got is None, f"got {got!r}") + ) + got = deliver(name, None) + results.append( + check(f"{name} delivers None for null DoorState", got is None, f"got {got!r}") + ) + + print("-" * 72) + print("ALL PASS" if all(results) else "FAILURES PRESENT") + if not all(results): + raise SystemExit(1) + + +if __name__ == "__main__": + asyncio.run(main())