From a17bb677cf3e51a5f2ea078fb615445f12e213e5 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 29 Aug 2026 06:17:53 +1000 Subject: [PATCH 1/2] feat: add typed listeners for streamable fields 260-269 Adds listen_GpsAccuracyMeters, listen_LifetimeEnergyChargedKwh, listen_BrickSocMinPercent, listen_NominalFullPackEnergyKwh, listen_GradeEstimatePercent, listen_MaxSpeedToReachDestinationMph, listen_SoftwareUpdateAvailable, listen_SoftwareUpdateInProgress, listen_RemoteStartActive, and listen_SemiCruiseSpeedLimitMph, matching upstream teslamotors/fleet-telemetry#546 (first available firmware 2026.32). Claude-Session: https://claude.ai/code/session_01MHXTGKr4FcJ18HT4nrfN14 --- teslemetry_stream/const.py | 10 ++++ teslemetry_stream/vehicle.py | 103 +++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/teslemetry_stream/const.py b/teslemetry_stream/const.py index f882afc..67b5707 100644 --- a/teslemetry_stream/const.py +++ b/teslemetry_stream/const.py @@ -71,6 +71,7 @@ class Signal(StrEnum): BMS_FULL_CHARGE_COMPLETE = "BmsFullchargecomplete" BRAKE_PEDAL = "BrakePedal" BRAKE_PEDAL_POS = "BrakePedalPos" + BRICK_SOC_MIN_PERCENT = "BrickSocMinPercent" BRICK_VOLTAGE_MAX = "BrickVoltageMax" BRICK_VOLTAGE_MIN = "BrickVoltageMin" CABIN_OVERHEAT_PROTECTION_MODE = "CabinOverheatProtectionMode" @@ -159,8 +160,10 @@ class Signal(StrEnum): FORWARD_COLLISION_WARNING = "ForwardCollisionWarning" FP_WINDOW = "FpWindow" GEAR = "Gear" + GPS_ACCURACY_METERS = "GpsAccuracyMeters" GPS_HEADING = "GpsHeading" GPS_STATE = "GpsState" + GRADE_ESTIMATE_PERCENT = "GradeEstimatePercent" GUEST_MODE_ENABLED = "GuestModeEnabled" GUEST_MODE_MOBILE_ACCESS_STATE = "GuestModeMobileAccessState" HOMELINK_DEVICE_COUNT = "HomelinkDeviceCount" @@ -180,6 +183,7 @@ class Signal(StrEnum): ISOLATION_RESISTANCE = "IsolationResistance" LANE_DEPARTURE_AVOIDANCE = "LaneDepartureAvoidance" LATERAL_ACCELERATION = "LateralAcceleration" + LIFETIME_ENERGY_CHARGED_KWH = "LifetimeEnergyChargedKwh" LIFETIME_ENERGY_GAINED_REGEN = "LifetimeEnergyGainedRegen" LIFETIME_ENERGY_USED = "LifetimeEnergyUsed" LIFETIME_ENERGY_USED_DRIVE = "LifetimeEnergyUsedDrive" @@ -192,6 +196,7 @@ class Signal(StrEnum): LOCATION = "Location" LOCKED = "Locked" LONGITUDINAL_ACCELERATION = "LongitudinalAcceleration" + MAX_SPEED_TO_REACH_DESTINATION_MPH = "MaxSpeedToReachDestinationMph" MEDIA_AUDIO_VOLUME = "MediaAudioVolume" MEDIA_AUDIO_VOLUME_INCREMENT = "MediaAudioVolumeIncrement" MEDIA_AUDIO_VOLUME_MAX = "MediaAudioVolumeMax" @@ -207,6 +212,7 @@ class Signal(StrEnum): MINUTES_TO_ARRIVAL = "MinutesToArrival" MODULE_TEMP_MAX = "ModuleTempMax" MODULE_TEMP_MIN = "ModuleTempMin" + NOMINAL_FULL_PACK_ENERGY_KWH = "NominalFullPackEnergyKwh" NOT_ENOUGH_POWER_TO_HEAT = "NotEnoughPowerToHeat" NUM_BRICK_VOLTAGE_MAX = "NumBrickVoltageMax" NUM_BRICK_VOLTAGE_MIN = "NumBrickVoltageMin" @@ -233,6 +239,7 @@ class Signal(StrEnum): REAR_DEFROST_ENABLED = "RearDefrostEnabled" REAR_DISPLAY_HVAC_ENABLED = "RearDisplayHvacEnabled" REAR_SEAT_HEATERS = "RearSeatHeaters" + REMOTE_START_ACTIVE = "RemoteStartActive" REMOTE_START_ENABLED = "RemoteStartEnabled" RIGHT_HAND_DRIVE = "RightHandDrive" ROOF_COLOR = "RoofColor" @@ -262,6 +269,7 @@ class Signal(StrEnum): SEMITRUCK_TPMS_PRESSURE_RE2R1 = "SemitruckTpmsPressureRe2R1" SEMITRUCK_TRACTOR_PARK_BRAKE_STATUS = "SemitruckTractorParkBrakeStatus" SEMITRUCK_TRAILER_PARK_BRAKE_STATUS = "SemitruckTrailerParkBrakeStatus" + SEMI_CRUISE_SPEED_LIMIT_MPH = "SemiCruiseSpeedLimitMph" SENTRY_MODE = "SentryMode" SERVICE_MODE = "ServiceMode" SETTING_24_HOUR_TIME = "Setting24HourTime" @@ -270,11 +278,13 @@ class Signal(StrEnum): SETTING_TEMPERATURE_UNIT = "SettingTemperatureUnit" SETTING_TIRE_PRESSURE_UNIT = "SettingTirePressureUnit" SOC = "Soc" + SOFTWARE_UPDATE_AVAILABLE = "SoftwareUpdateAvailable" SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE = "SoftwareUpdateDownloadPercentComplete" SOFTWARE_UPDATE_EXPECTED_DURATION_MINUTES = "SoftwareUpdateExpectedDurationMinutes" SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE = ( "SoftwareUpdateInstallationPercentComplete" ) + SOFTWARE_UPDATE_IN_PROGRESS = "SoftwareUpdateInProgress" SOFTWARE_UPDATE_SCHEDULED_START_TIME = "SoftwareUpdateScheduledStartTime" SOFTWARE_UPDATE_VERSION = "SoftwareUpdateVersion" SPEED_LIMIT_MODE = "SpeedLimitMode" diff --git a/teslemetry_stream/vehicle.py b/teslemetry_stream/vehicle.py index 4182358..b5923f1 100644 --- a/teslemetry_stream/vehicle.py +++ b/teslemetry_stream/vehicle.py @@ -2979,6 +2979,109 @@ def listen_ChargeRateMilePerHour( {"vin": self.vin, "data": {Signal.CHARGE_RATE_MILE_PER_HOUR: None}}, ) + def listen_GpsAccuracyMeters( + self, callback: Callable[[float | None], None] + ) -> Callable[[], None]: + """Listen for GPS Accuracy Meters.""" + self._enable_field(Signal.GPS_ACCURACY_METERS) + return self.stream.async_add_listener( + make_float(Signal.GPS_ACCURACY_METERS, callback), + {"vin": self.vin, "data": {Signal.GPS_ACCURACY_METERS: None}}, + ) + + def listen_LifetimeEnergyChargedKwh( + self, callback: Callable[[float | None], None] + ) -> Callable[[], None]: + """Listen for Lifetime Energy Charged.""" + self._enable_field(Signal.LIFETIME_ENERGY_CHARGED_KWH) + return self.stream.async_add_listener( + make_float(Signal.LIFETIME_ENERGY_CHARGED_KWH, callback), + {"vin": self.vin, "data": {Signal.LIFETIME_ENERGY_CHARGED_KWH: None}}, + ) + + def listen_BrickSocMinPercent( + self, callback: Callable[[float | None], None] + ) -> Callable[[], None]: + """Listen for Brick State of Charge Minimum Percent.""" + self._enable_field(Signal.BRICK_SOC_MIN_PERCENT) + return self.stream.async_add_listener( + make_float(Signal.BRICK_SOC_MIN_PERCENT, callback), + {"vin": self.vin, "data": {Signal.BRICK_SOC_MIN_PERCENT: None}}, + ) + + def listen_NominalFullPackEnergyKwh( + self, callback: Callable[[float | None], None] + ) -> Callable[[], None]: + """Listen for Nominal Full Pack Energy.""" + self._enable_field(Signal.NOMINAL_FULL_PACK_ENERGY_KWH) + return self.stream.async_add_listener( + make_float(Signal.NOMINAL_FULL_PACK_ENERGY_KWH, callback), + {"vin": self.vin, "data": {Signal.NOMINAL_FULL_PACK_ENERGY_KWH: None}}, + ) + + def listen_GradeEstimatePercent( + self, callback: Callable[[float | None], None] + ) -> Callable[[], None]: + """Listen for Grade Estimate Percent.""" + self._enable_field(Signal.GRADE_ESTIMATE_PERCENT) + return self.stream.async_add_listener( + make_float(Signal.GRADE_ESTIMATE_PERCENT, callback), + {"vin": self.vin, "data": {Signal.GRADE_ESTIMATE_PERCENT: None}}, + ) + + def listen_MaxSpeedToReachDestinationMph( + self, callback: Callable[[float | None], None] + ) -> Callable[[], None]: + """Listen for Max Speed to Reach Destination MPH.""" + self._enable_field(Signal.MAX_SPEED_TO_REACH_DESTINATION_MPH) + return self.stream.async_add_listener( + make_float(Signal.MAX_SPEED_TO_REACH_DESTINATION_MPH, callback), + { + "vin": self.vin, + "data": {Signal.MAX_SPEED_TO_REACH_DESTINATION_MPH: None}, + }, + ) + + def listen_SoftwareUpdateAvailable( + self, callback: Callable[[bool | None], None] + ) -> Callable[[], None]: + """Listen for Software Update Available.""" + self._enable_field(Signal.SOFTWARE_UPDATE_AVAILABLE) + return self.stream.async_add_listener( + make_bool(Signal.SOFTWARE_UPDATE_AVAILABLE, callback), + {"vin": self.vin, "data": {Signal.SOFTWARE_UPDATE_AVAILABLE: None}}, + ) + + def listen_SoftwareUpdateInProgress( + self, callback: Callable[[bool | None], None] + ) -> Callable[[], None]: + """Listen for Software Update In Progress.""" + self._enable_field(Signal.SOFTWARE_UPDATE_IN_PROGRESS) + return self.stream.async_add_listener( + make_bool(Signal.SOFTWARE_UPDATE_IN_PROGRESS, callback), + {"vin": self.vin, "data": {Signal.SOFTWARE_UPDATE_IN_PROGRESS: None}}, + ) + + def listen_RemoteStartActive( + self, callback: Callable[[bool | None], None] + ) -> Callable[[], None]: + """Listen for Remote Start Active.""" + self._enable_field(Signal.REMOTE_START_ACTIVE) + return self.stream.async_add_listener( + make_bool(Signal.REMOTE_START_ACTIVE, callback), + {"vin": self.vin, "data": {Signal.REMOTE_START_ACTIVE: None}}, + ) + + def listen_SemiCruiseSpeedLimitMph( + self, callback: Callable[[float | None], None] + ) -> Callable[[], None]: + """Listen for Semi-truck Cruise Speed Limit MPH.""" + self._enable_field(Signal.SEMI_CRUISE_SPEED_LIMIT_MPH) + return self.stream.async_add_listener( + make_float(Signal.SEMI_CRUISE_SPEED_LIMIT_MPH, callback), + {"vin": self.vin, "data": {Signal.SEMI_CRUISE_SPEED_LIMIT_MPH: None}}, + ) + def make_int( signal: Signal, callback: Callable[[int | None], None] From a841478ac00b8d1e347f736b83cf0790d9dc6bf4 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 29 Aug 2026 06:18:12 +1000 Subject: [PATCH 2/2] docs: record the new-field addition pattern in AGENTS.md Claude-Session: https://claude.ai/code/session_01MHXTGKr4FcJ18HT4nrfN14 --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 2c98f75..930c109 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - `tests/` files are plain scripts (`if __name__ == "__main__"`), not pytest-based - pytest would collect zero tests here. Run each directly, e.g. `uv run python tests/test_config_events.py`. - `pyproject.toml` has a `[tool.mypy]` config but no dev-dependency group declares mypy, so `uv sync` alone won't install it. CI installs it ephemerally via `uv run --with mypy mypy teslemetry_stream`. - `Signal` in `const.py` tracks ; the config route rejects names it does not know with `fst_err_validation`. Fields the API has retired are not rejected - it accepts the request and names them in a top-level `ignoredFields` list - so the library can lag the published list without breaking. +- Adding a new streamable field (e.g. from an upstream `teslamotors/fleet-telemetry` `Field` enum addition): give it a `Signal` entry in `const.py` in alphabetical order by the Python constant name, but append its `listen_` method to the *end* of `TeslemetryStreamVehicle` in `vehicle.py`, not alphabetically re-inserted - the method order there is chronological-by-addition (see the tail of the class), not sorted. Pick `make_int`/`make_float`/`make_bool`/`make_dict` by matching the closest existing field of the same shape (unit suffix, boolean vs measurement, etc.); there is no per-field firmware-version metadata tracked anywhere in the library, so omit it. - Config responses are shaped inconsistently: success is flat, `{"updated_vehicles": n}` plus `ignoredFields` when some were dropped, while errors are wrapped, `{"response": null, "error": ...}`. Do not look for `updated_vehicles` under `response`; that lookup silently never matches. - `update_config` funnels every caller through one per-vehicle single-flight flush (`TeslemetryStreamVehicle._flush`): the first caller starts it, later callers merge into the same pending config and await it rather than starting their own PATCH. This exists because a batch of listeners scheduled at once (e.g. HA integration setup) must produce one PATCH, not one per listener - see `tests/test_batch_retry_storm.py`. A body-shaped error (`{"error": ...}`) is terminal for that batch: it is not replayed, but the pending config is kept for the next explicit `update_config` call. A transport-level failure (`aiohttp.ClientError`/timeout) gets one bounded retry inside the same flush. `tests/test_config_update.py` covers the response-shape handling. - Energy site events (`teslemetry_stream/energysite.py`) are shaped differently from vehicle signals: `live_status`/`site_info` are flat top-level envelopes (`{createdAt, site_id, isCache?, live_status|site_info}`), not nested under `data`, and the payload is a full opaque document rather than a field delta - there is no per-field config to enable, the server auto-polls subscribed sites. `tests/test_energysite_events.py` fixtures mirror the server schemas.