|
| 1 | +"""Feeds BLE broadcasts into a stream-shaped ``ingest()`` sink. |
| 2 | +
|
| 3 | +``BleBroadcastStreamGlue`` wires the typed listeners in ``broadcast.py`` to a |
| 4 | +duck-typed sink (structurally: python-teslemetry-stream's |
| 5 | +``TeslemetryStream(Vehicle).ingest``) so a BLE observation reaches the same |
| 6 | +listeners a native stream event does, translated into the identical |
| 7 | +stream-shaped payload. This module never imports ``teslemetry_stream`` - the |
| 8 | +sink contract below is a structural :class:`typing.Protocol`, matching the |
| 9 | +duck-typed ``EnergySite`` composition :class:`~tesla_fleet_api.router.EnergySiteRouter` |
| 10 | +already uses for aiopowerwall. |
| 11 | +""" |
| 12 | + |
| 13 | +from __future__ import annotations |
| 14 | + |
| 15 | +from typing import TYPE_CHECKING, Any, Mapping, Protocol |
| 16 | + |
| 17 | +from tesla_fleet_api.funnel import CLOSURE_STATES, LOCK_STATES |
| 18 | +from tesla_fleet_api.tesla.vehicle.broadcast import Unsubscribe |
| 19 | +from tesla_protocol.command.vcsec_pb2 import ClosureState_E, VehicleLockState_E |
| 20 | + |
| 21 | +if TYPE_CHECKING: |
| 22 | + from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth |
| 23 | + |
| 24 | + |
| 25 | +class StreamSink(Protocol): |
| 26 | + """Structurally identical to ``TeslemetryStream(Vehicle).ingest`` - not imported.""" |
| 27 | + |
| 28 | + def ingest( |
| 29 | + self, data: Mapping[str, Any], metadata: Mapping[str, Any] | None = None |
| 30 | + ) -> Mapping[str, Any]: ... |
| 31 | + |
| 32 | + |
| 33 | +class BleBroadcastStreamGlue: |
| 34 | + """Translates a :class:`VehicleBluetooth`'s broadcasts into ``sink.ingest()`` calls. |
| 35 | +
|
| 36 | + Reuses the same lock/closure decode maps |
| 37 | + :class:`~tesla_fleet_api.funnel.BleBroadcastPublisher` does, so the "any |
| 38 | + unlocked state reads as unlocked" ruling and the deliberate |
| 39 | + UNKNOWN/FAILED_UNLATCH omission carry over unchanged. There is no source |
| 40 | + ranking here or in ``ingest()`` itself - every call reaches listeners in |
| 41 | + arrival order alongside whatever the sink's own stream connection reports. |
| 42 | + """ |
| 43 | + |
| 44 | + def __init__(self, vehicle: "VehicleBluetooth[Any]", sink: StreamSink) -> None: |
| 45 | + self._sink = sink |
| 46 | + self._unsubs: list[Unsubscribe] = [ |
| 47 | + vehicle.listen_vehicle_lock_state(self._on_lock_state), |
| 48 | + vehicle.listen_charge_port(self._on_charge_port), |
| 49 | + vehicle.listen_front_trunk(self._on_front_trunk), |
| 50 | + ] |
| 51 | + |
| 52 | + def stop(self) -> None: |
| 53 | + """Unsubscribe from every broadcast listener; safe to call more than once.""" |
| 54 | + for unsub in self._unsubs: |
| 55 | + unsub() |
| 56 | + self._unsubs = [] |
| 57 | + |
| 58 | + def _on_lock_state(self, raw: int) -> None: |
| 59 | + if raw not in LOCK_STATES: |
| 60 | + return |
| 61 | + self._sink.ingest( |
| 62 | + {"Locked": LOCK_STATES[raw]}, |
| 63 | + {"source": "bluetooth", "raw": VehicleLockState_E.Name(raw)}, |
| 64 | + ) |
| 65 | + |
| 66 | + def _on_charge_port(self, raw: int) -> None: |
| 67 | + if raw not in CLOSURE_STATES: |
| 68 | + return |
| 69 | + self._sink.ingest( |
| 70 | + {"ChargePortDoorOpen": CLOSURE_STATES[raw]}, |
| 71 | + {"source": "bluetooth", "raw": ClosureState_E.Name(raw)}, |
| 72 | + ) |
| 73 | + |
| 74 | + def _on_front_trunk(self, raw: int) -> None: |
| 75 | + if raw not in CLOSURE_STATES: |
| 76 | + return |
| 77 | + self._sink.ingest( |
| 78 | + {"DoorState": {"TrunkFront": CLOSURE_STATES[raw]}}, |
| 79 | + {"source": "bluetooth", "raw": ClosureState_E.Name(raw)}, |
| 80 | + ) |
0 commit comments