Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions teslemetry_stream/vehicle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}},
)

Expand All @@ -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}},
)

Expand All @@ -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}},
)

Expand All @@ -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}},
)

Expand All @@ -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}},
)

Expand All @@ -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}},
)

Expand Down Expand Up @@ -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]:
Expand Down
91 changes: 91 additions & 0 deletions tests/test_door_state_null.py
Original file line number Diff line number Diff line change
@@ -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())
Loading