diff --git a/pyproject.toml b/pyproject.toml index f99a09e..0830c82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["setuptools>=77.0"] [project] name = "teslemetry_stream" -version = "0.9.0" +version = "0.9.1" license = "Apache-2.0" description = "Teslemetry Streaming API library for Python" readme = "README.md" diff --git a/teslemetry_stream/vehicle.py b/teslemetry_stream/vehicle.py index da23358..c1f7c43 100644 --- a/teslemetry_stream/vehicle.py +++ b/teslemetry_stream/vehicle.py @@ -580,24 +580,22 @@ def listen_ClimateKeeperMode( ) def listen_ClimateSeatCoolingFrontLeft( - self, callback: Callable[[str | None], None] + self, callback: Callable[[int | None], None] ) -> Callable[[], None]: """Listen for Climate Seat Cooling Front Left.""" self._enable_field(Signal.CLIMATE_SEAT_COOLING_FRONT_LEFT) return self.stream.async_add_listener( - lambda x: callback( - x["data"][Signal.CLIMATE_SEAT_COOLING_FRONT_LEFT] - ), # This should enum but I dont know what + make_int(Signal.CLIMATE_SEAT_COOLING_FRONT_LEFT, callback), {"vin": self.vin, "data": {Signal.CLIMATE_SEAT_COOLING_FRONT_LEFT: None}}, ) def listen_ClimateSeatCoolingFrontRight( - self, callback: Callable[[str | None], None] + self, callback: Callable[[int | None], None] ) -> Callable[[], None]: """Listen for Climate Seat Cooling Front Right.""" self._enable_field(Signal.CLIMATE_SEAT_COOLING_FRONT_RIGHT) return self.stream.async_add_listener( - lambda x: callback(x["data"][Signal.CLIMATE_SEAT_COOLING_FRONT_RIGHT]), + make_int(Signal.CLIMATE_SEAT_COOLING_FRONT_RIGHT, callback), {"vin": self.vin, "data": {Signal.CLIMATE_SEAT_COOLING_FRONT_RIGHT: None}}, ) @@ -618,18 +616,20 @@ def listen_CruiseSetSpeed( ) -> Callable[[], None]: """Listen for Cruise Set Speed.""" self._enable_field(Signal.CRUISE_SET_SPEED) + # fields.json declares this "real" (float), but real-world streamed + # values are always whole numbers, so we deliver it as int. return self.stream.async_add_listener( make_int(Signal.CRUISE_SET_SPEED, callback), {"vin": self.vin, "data": {Signal.CRUISE_SET_SPEED: None}}, ) def listen_CurrentLimitMph( - self, callback: Callable[[int | None], None] + self, callback: Callable[[float | None], None] ) -> Callable[[], None]: """Listen for Current Limit MPH.""" self._enable_field(Signal.CURRENT_LIMIT_MPH) return self.stream.async_add_listener( - make_int(Signal.CURRENT_LIMIT_MPH, callback), + make_float(Signal.CURRENT_LIMIT_MPH, callback), {"vin": self.vin, "data": {Signal.CURRENT_LIMIT_MPH: None}}, ) @@ -1010,6 +1010,8 @@ def listen_DiTorquemotor( ) -> Callable[[], None]: """Listen for Drive Inverter Torque Motor.""" self._enable_field(Signal.DI_TORQUEMOTOR) + # fields.json declares this "real" (float), but real-world streamed + # values are always whole numbers, so we deliver it as int. return self.stream.async_add_listener( make_int(Signal.DI_TORQUEMOTOR, callback), {"vin": self.vin, "data": {Signal.DI_TORQUEMOTOR: None}}, @@ -1226,6 +1228,8 @@ def listen_ExpectedEnergyPercentAtTripArrival( ) -> Callable[[], None]: """Listen for Expected Energy Percent at Trip Arrival.""" self._enable_field(Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL) + # fields.json declares this "real" (float), but real-world streamed + # values are always whole numbers, so we deliver it as int. return self.stream.async_add_listener( make_int(Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL, callback), { @@ -2206,12 +2210,12 @@ def listen_SoftwareUpdateInstallationPercentComplete( ) def listen_SoftwareUpdateScheduledStartTime( - self, callback: Callable[[str | None], None] + self, callback: Callable[[int | None], None] ) -> Callable[[], None]: """Listen for Software Update Scheduled Start Time.""" self._enable_field(Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME) return self.stream.async_add_listener( - lambda x: callback(x["data"][Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME]), + make_int(Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME, callback), { "vin": self.vin, "data": {Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None}, diff --git a/tests/test_field_type_coercion.py b/tests/test_field_type_coercion.py new file mode 100644 index 0000000..42921f2 --- /dev/null +++ b/tests/test_field_type_coercion.py @@ -0,0 +1,156 @@ +"""End-to-end typing checks for the field-type fixes in v0.9.1. + +Exercises the real public ``listen_*`` methods on ``TeslemetryStreamVehicle`` +exactly as a library consumer would, then pushes representative *real-world* +streamed values (as sampled from the Teslemetry na_cache NATS KV store, per the +PR description) through the registered listener and asserts the value and the +Python type delivered to the consumer callback. +""" +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" + + +class FakeStream: + """Minimal stand-in for TeslemetryStream that just captures listeners.""" + + manual = True + + def __init__(self) -> None: + # maps Signal value -> wrapped listener callback + self.captured: dict[str, Any] = {} + + def async_add_listener( + self, + callback: Callable[[dict[str, Any]], None], + filters: dict[str, Any] | None = None, + ) -> Callable[[], None]: + # filters carries {"vin": ..., "data": {Signal: None}} — grab the field + assert filters is not None + signal = next(iter(filters["data"])) + self.captured[signal] = callback + return lambda: None + + +def build_vehicle() -> tuple[TeslemetryStreamVehicle, FakeStream]: + stream = FakeStream() + vehicle = TeslemetryStreamVehicle(stream, VIN) # type: ignore[arg-type] + # Pre-populate config so _enable_field/add_field short-circuits without HTTP. + vehicle.fields = {s.value: {} for s in Signal} + return vehicle, stream + + +def deliver(stream: FakeStream, signal: Signal, raw: Any) -> Any: + """Feed a raw streamed value through the captured listener, return delivered.""" + box: dict[str, Any] = {} + # The captured callback was registered by listen_* with the consumer callback + # already baked in; re-register a fresh consumer to capture the delivered value. + event = {"vin": VIN, "data": {signal.value: raw}} + stream.captured[signal.value](event) + return box # unused; delivered value captured by the consumer closure + + +async def main() -> None: + results: list[tuple[str, Any, str, Any, str, bool]] = [] + + def check( + label: str, + signal: Signal, + register: Callable[ + [TeslemetryStreamVehicle, Callable[[Any], None]], Any + ], + raw: Any, + expected: Any, + expected_type: type, + ) -> None: + vehicle, stream = build_vehicle() + delivered: dict[str, Any] = {} + register(vehicle, lambda v: delivered.__setitem__("v", v)) + stream.captured[signal.value]({"vin": VIN, "data": {signal.value: raw}}) + got = delivered.get("v") + ok = got == expected and type(got) is expected_type + results.append( + (label, raw, type(raw).__name__, got, type(got).__name__, ok) + ) + + # --- BATCH 1: seat cooling, previously passed raw str through (wrong) --- + check( + "ClimateSeatCoolingFrontLeft", + Signal.CLIMATE_SEAT_COOLING_FRONT_LEFT, + lambda v, cb: v.listen_ClimateSeatCoolingFrontLeft(cb), + "3", 3, int, + ) + check( + "ClimateSeatCoolingFrontRight", + Signal.CLIMATE_SEAT_COOLING_FRONT_RIGHT, + lambda v, cb: v.listen_ClimateSeatCoolingFrontRight(cb), + "0", 0, int, + ) + + # --- BATCH 2a: CurrentLimitMph streams fractional values; make_int used to + # truncate 74.4367 -> 74. Now make_float preserves the fraction. --- + check( + "CurrentLimitMph", + Signal.CURRENT_LIMIT_MPH, + lambda v, cb: v.listen_CurrentLimitMph(cb), + "74.4367", 74.4367, float, + ) + check( + "CurrentLimitMph", + Signal.CURRENT_LIMIT_MPH, + lambda v, cb: v.listen_CurrentLimitMph(cb), + "80.6504", 80.6504, float, + ) + + # --- BATCH 2b: SoftwareUpdateScheduledStartTime streams an int epoch as str; + # previously delivered raw str, now coerced to int. --- + check( + "SoftwareUpdateScheduledStartTime", + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME, + lambda v, cb: v.listen_SoftwareUpdateScheduledStartTime(cb), + "1783072740", 1783072740, int, + ) + + # --- Deliberate int divergence (kept as int despite fields.json "real") --- + check( + "CruiseSetSpeed (kept int)", + Signal.CRUISE_SET_SPEED, + lambda v, cb: v.listen_CruiseSetSpeed(cb), + "65", 65, int, + ) + check( + "DiTorquemotor (kept int)", + Signal.DI_TORQUEMOTOR, + lambda v, cb: v.listen_DiTorquemotor(cb), + "120", 120, int, + ) + check( + "ExpectedEnergyPercentAtTripArrival (kept int)", + Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL, + lambda v, cb: v.listen_ExpectedEnergyPercentAtTripArrival(cb), + "82", 82, int, + ) + + print(f"{'field':<42} {'raw (stream)':<16} {'delivered':<14} {'type':<7} ok") + print("-" * 88) + all_ok = True + for label, raw, raw_t, got, got_t, ok in results: + all_ok = all_ok and ok + print( + f"{label:<42} {repr(raw):<16} {repr(got):<14} {got_t:<7} " + f"{'PASS' if ok else 'FAIL'}" + ) + print("-" * 88) + print("ALL PASS" if all_ok else "FAILURES PRESENT") + if not all_ok: + raise SystemExit(1) + + +if __name__ == "__main__": + asyncio.run(main())