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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://api.teslemetry.com/fields.json>; 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_<Field>` 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.
Expand Down
10 changes: 10 additions & 0 deletions teslemetry_stream/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
103 changes: 103 additions & 0 deletions teslemetry_stream/vehicle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading