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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 14 additions & 10 deletions teslemetry_stream/vehicle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}},
)

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

Expand Down Expand Up @@ -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}},
Expand Down Expand Up @@ -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),
{
Expand Down Expand Up @@ -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},
Expand Down
156 changes: 156 additions & 0 deletions tests/test_field_type_coercion.py
Original file line number Diff line number Diff line change
@@ -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())
Loading