From 2d01b901aaaa060bfab399693b3cfccefab60431 Mon Sep 17 00:00:00 2001 From: Brett Date: Fri, 3 Jul 2026 10:10:34 +1000 Subject: [PATCH 1/4] Fix seat-cooling field types to int and bump to 0.9.1 listen_ClimateSeatCoolingFrontLeft and listen_ClimateSeatCoolingFrontRight are declared `integer` (nullable) in fields.json, but were typed as `Callable[[str | None], None]` and passed the raw value straight through. Type them as `Callable[[int | None], None]` and route through make_int so the value delivered to the callback genuinely matches, matching the canonical integer-field pattern (e.g. the SeatHeater* listeners). Remove the stale "should be enum" comment. Bump version 0.9.0 -> 0.9.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- teslemetry_stream/vehicle.py | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) 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..fed9903 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}}, ) From e1147f98bab520a9654793f37f048fe555f9d108 Mon Sep 17 00:00:00 2001 From: Brett Date: Fri, 3 Jul 2026 10:41:39 +1000 Subject: [PATCH 2/4] Correct field types validated against real telemetry (na_cache) Validated the streaming field types against real-world values in the Teslemetry na_cache KV store, which is the ground truth for what each field actually streams: - CurrentLimitMph: real values are fractional (e.g. 74.4367, 80.6504), so make_int was silently truncating them. Retype to float | None via make_float. fields.json (float) is correct here. - SoftwareUpdateScheduledStartTime: real values are integer Unix epochs (e.g. 1783072740) delivered as-is (str). Retype to int | None via make_int. fields.json (integer) is correct here. - CruiseSetSpeed, DiTorquemotor, ExpectedEnergyPercentAtTripArrival: fields.json declares these "real" (float), but every real-world value observed is a whole number. Keep them as int and add a comment noting the deliberate divergence from fields.json. DoorState was also checked: it streams a JSON object, so the library's existing make_dict handling is correct (fields.json's "string" is the inaccurate one) and is left unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- teslemetry_stream/vehicle.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/teslemetry_stream/vehicle.py b/teslemetry_stream/vehicle.py index fed9903..c1f7c43 100644 --- a/teslemetry_stream/vehicle.py +++ b/teslemetry_stream/vehicle.py @@ -616,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}}, ) @@ -1008,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}}, @@ -1224,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), { @@ -2204,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}, From 95b546d068c08ca9fad1f92dd8e18ecf04b1760f Mon Sep 17 00:00:00 2001 From: Brett Date: Fri, 3 Jul 2026 10:48:14 +1000 Subject: [PATCH 3/4] no-mistakes(document): docs already in sync with stream field type fixes --- tests/test_field_type_coercion.py | 142 ++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 tests/test_field_type_coercion.py diff --git a/tests/test_field_type_coercion.py b/tests/test_field_type_coercion.py new file mode 100644 index 0000000..e1e7486 --- /dev/null +++ b/tests/test_field_type_coercion.py @@ -0,0 +1,142 @@ +"""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 + +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, filters=None): + # filters carries {"vin": ..., "data": {Signal: None}} — grab the field + 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, signal, register, raw, expected, expected_type): + 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()) From 114105712d616964b5be182390060f0819fe2110 Mon Sep 17 00:00:00 2001 From: Brett Date: Fri, 3 Jul 2026 10:49:50 +1000 Subject: [PATCH 4/4] no-mistakes(lint): annotate coercion test to satisfy strict mypy --- tests/test_field_type_coercion.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/test_field_type_coercion.py b/tests/test_field_type_coercion.py index e1e7486..42921f2 100644 --- a/tests/test_field_type_coercion.py +++ b/tests/test_field_type_coercion.py @@ -9,7 +9,7 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, Callable from teslemetry_stream.const import Signal from teslemetry_stream.vehicle import TeslemetryStreamVehicle @@ -26,8 +26,13 @@ def __init__(self) -> None: # maps Signal value -> wrapped listener callback self.captured: dict[str, Any] = {} - def async_add_listener(self, callback, filters=None): + 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 @@ -54,7 +59,16 @@ def deliver(stream: FakeStream, signal: Signal, raw: Any) -> Any: async def main() -> None: results: list[tuple[str, Any, str, Any, str, bool]] = [] - def check(label, signal, register, raw, expected, expected_type): + 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))