Skip to content

Commit ffdc03a

Browse files
committed
fix(vehicle): keep config state per vehicle and surface ignored fields
Pending config, known fields and prefer_typed were class attributes, so every vehicle shared one dict and one vehicle's pending change was sent for another. The success branch looked for updated_vehicles under a response key, but the config API reports success flat, so it never ran: fields were never recorded and the pending batch was never cleared. Read the real shape, and warn naming anything the API reports in ignoredFields. Also record prefer_typed by value - it was assigned the result of an 'in' test, so setting it False set it True.
1 parent 9daaeec commit ffdc03a

3 files changed

Lines changed: 198 additions & 14 deletions

File tree

‎AGENTS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ This file is the project's committed home for project-intrinsic agent knowledge:
66
- CI runs on push/PR: `.github/workflows/ci.yml`. Uses `uv` (see `uv.lock`).
77
- `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_field_type_coercion.py`.
88
- `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`.
9+
- `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.
10+
- 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.
11+
- `update_config` debounces changes for 1s into one batch, and the API merges that batch into the vehicle's *full* config before forwarding it to Tesla. `tests/test_config_update.py` covers the response handling.
912

1013
## Maintaining this file
1114

‎teslemetry_stream/vehicle.py‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,19 @@
6464
class TeslemetryStreamVehicle:
6565
"""Handle streaming field updates."""
6666

67-
fields: dict[str, dict[str, int]] = {}
68-
preferTyped: bool | None = None
69-
_config: dict[str, Any] = {}
67+
fields: dict[str, dict[str, int]]
68+
preferTyped: bool | None
69+
_config: dict[str, Any]
7070

7171
def __init__(self, stream: TeslemetryStream, vin: str):
7272
# A dictionary of TelemetryField keys and null values
7373
self.stream = stream
7474
self.vin: str = vin
7575
self.lock = asyncio.Lock()
76+
# Per-instance: class-level dicts would share pending config between vehicles
77+
self.fields = {}
78+
self.preferTyped = None
79+
self._config = {}
7680

7781
@property
7882
def config(self) -> dict[str, Any]:
@@ -121,17 +125,23 @@ async def update_config(self, config: dict[str, Any]) -> None:
121125
"Error updating streaming config for %s: %s", self.vin, error
122126
)
123127
return
124-
elif data.get("response", {}).get("updated_vehicles"):
125-
LOGGER.info("Updated vehicle streaming config for %s", self.vin)
126-
if fields := self._config.get("fields"):
127-
LOGGER.debug(
128-
"Configured streaming fields %s", ", ".join(fields.keys())
129-
)
130-
self.fields = {**self.fields, **fields}
131-
if prefer_typed := self._config.get("prefer_typed") in [True, False]:
132-
LOGGER.debug("Configured streaming typed to %s", prefer_typed)
133-
self.preferTyped = prefer_typed
134-
self._config.clear()
128+
129+
if ignored := data.get("ignoredFields"):
130+
LOGGER.warning(
131+
"Streaming fields not available for %s and were ignored: %s",
132+
self.vin,
133+
", ".join(ignored),
134+
)
135+
136+
LOGGER.info("Updated vehicle streaming config for %s", self.vin)
137+
if fields := self._config.get("fields"):
138+
LOGGER.debug("Configured streaming fields %s", ", ".join(fields.keys()))
139+
self.fields = {**self.fields, **fields}
140+
prefer_typed = self._config.get("prefer_typed")
141+
if isinstance(prefer_typed, bool):
142+
LOGGER.debug("Configured streaming typed to %s", prefer_typed)
143+
self.preferTyped = prefer_typed
144+
self._config.clear()
135145

136146
async def patch_config(self, config: dict[str, Any]) -> dict[str, Any]:
137147
"""Modify the configuration for the vehicle."""

‎tests/test_config_update.py‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Checks ``update_config`` against the shapes the config API really returns.
2+
3+
Success is reported flat as ``{"updated_vehicles": n}``, optionally with an
4+
``ignoredFields`` list naming fields the API dropped, and errors arrive as
5+
``{"response": null, "error": ...}``.
6+
"""
7+
from __future__ import annotations
8+
9+
import asyncio
10+
import logging
11+
from typing import Any
12+
13+
from teslemetry_stream.vehicle import TeslemetryStreamVehicle
14+
15+
VIN_A = "TESTVIN0000000001"
16+
VIN_B = "TESTVIN0000000002"
17+
18+
# Response bodies as observed against the live API
19+
ACCEPTED: dict[str, Any] = {"updated_vehicles": 1}
20+
IGNORED: dict[str, Any] = {"updated_vehicles": 1, "ignoredFields": ["RouteLastUpdated"]}
21+
ALL_IGNORED: dict[str, Any] = {
22+
"updated_vehicles": 0,
23+
"ignoredFields": ["LifetimeEnergyUsedDrive", "RouteLastUpdated"],
24+
}
25+
FAILED: dict[str, Any] = {"response": None, "error": "upstream internal error"}
26+
27+
28+
class FakeStream:
29+
"""Minimal stand-in for TeslemetryStream."""
30+
31+
manual = True
32+
33+
34+
def make_vehicle(vin: str, responses: list[Any]) -> TeslemetryStreamVehicle:
35+
"""Build a vehicle that records payloads and replays canned responses."""
36+
vehicle = TeslemetryStreamVehicle(FakeStream(), vin) # type: ignore[arg-type]
37+
vehicle.sent = [] # type: ignore[attr-defined]
38+
39+
async def patch_config(config: dict[str, Any]) -> dict[str, Any]:
40+
vehicle.sent.append(dict(config)) # type: ignore[attr-defined]
41+
return responses.pop(0) if responses else ACCEPTED
42+
43+
vehicle.patch_config = patch_config # type: ignore[assignment,method-assign]
44+
return vehicle
45+
46+
47+
class CaptureWarnings(logging.Handler):
48+
"""Collect formatted WARNING records emitted by the library."""
49+
50+
def __init__(self) -> None:
51+
super().__init__(level=logging.WARNING)
52+
self.messages: list[str] = []
53+
54+
def emit(self, record: logging.LogRecord) -> None:
55+
self.messages.append(record.getMessage())
56+
57+
58+
def check(label: str, ok: bool, detail: str = "") -> bool:
59+
print(f"{label:<56} {'PASS' if ok else 'FAIL'}{' ' + detail if detail else ''}")
60+
return ok
61+
62+
63+
async def main() -> None:
64+
results = []
65+
66+
# A confirmed update records its fields and leaves nothing pending.
67+
vehicle = make_vehicle(VIN_A, [ACCEPTED])
68+
await vehicle.update_config({"fields": {"BatteryLevel": {"interval_seconds": 60}}})
69+
results.append(
70+
check(
71+
"a confirmed update records its fields",
72+
"BatteryLevel" in vehicle.fields and vehicle._config == {},
73+
f"fields {sorted(vehicle.fields)}, pending {vehicle._config}",
74+
)
75+
)
76+
77+
# Ignored fields are named in a warning.
78+
handler = CaptureWarnings()
79+
logger = logging.getLogger("teslemetry_stream")
80+
logger.addHandler(handler)
81+
try:
82+
vehicle = make_vehicle(VIN_A, [IGNORED])
83+
await vehicle.update_config(
84+
{"fields": {"BatteryLevel": None, "RouteLastUpdated": None}}
85+
)
86+
results.append(
87+
check(
88+
"ignored fields are named in a warning",
89+
any("RouteLastUpdated" in m for m in handler.messages),
90+
f"warnings {handler.messages}",
91+
)
92+
)
93+
94+
handler.messages.clear()
95+
vehicle = make_vehicle(VIN_A, [ALL_IGNORED])
96+
await vehicle.update_config({"fields": {"RouteLastUpdated": None}})
97+
results.append(
98+
check(
99+
"every ignored field is named",
100+
any(
101+
"RouteLastUpdated" in m and "LifetimeEnergyUsedDrive" in m
102+
for m in handler.messages
103+
),
104+
f"warnings {handler.messages}",
105+
)
106+
)
107+
108+
handler.messages.clear()
109+
vehicle = make_vehicle(VIN_A, [ACCEPTED])
110+
await vehicle.update_config({"fields": {"BatteryLevel": None}})
111+
results.append(
112+
check(
113+
"no warning when nothing was ignored",
114+
not handler.messages,
115+
f"warnings {handler.messages}",
116+
)
117+
)
118+
finally:
119+
logger.removeHandler(handler)
120+
121+
# A failed update does not record its fields as configured.
122+
vehicle = make_vehicle(VIN_A, [FAILED])
123+
await vehicle.update_config({"fields": {"BatteryLevel": None}})
124+
results.append(
125+
check(
126+
"a failed update records no fields",
127+
vehicle.fields == {},
128+
f"fields {sorted(vehicle.fields)}",
129+
)
130+
)
131+
132+
# prefer_typed=False must be recorded as False, not coerced to True.
133+
vehicle = make_vehicle(VIN_A, [ACCEPTED])
134+
await vehicle.prefer_typed(False)
135+
results.append(
136+
check(
137+
"prefer_typed(False) recorded as False",
138+
vehicle.preferTyped is False,
139+
f"got {vehicle.preferTyped!r}",
140+
)
141+
)
142+
143+
# Pending config must not be shared between vehicles.
144+
first = make_vehicle(VIN_A, [FAILED])
145+
second = make_vehicle(VIN_B, [ACCEPTED])
146+
await first.update_config({"fields": {"RouteLastUpdated": None}})
147+
await second.update_config({"fields": {"BatteryLevel": None}})
148+
sent = second.sent[0]["fields"] # type: ignore[attr-defined]
149+
results.append(
150+
check(
151+
"one vehicle's pending config does not leak to another",
152+
"RouteLastUpdated" not in sent,
153+
f"sent {sorted(sent)}",
154+
)
155+
)
156+
results.append(
157+
check(
158+
"one vehicle's recorded fields do not leak to another",
159+
"BatteryLevel" not in first.fields,
160+
f"first {sorted(first.fields)}, second {sorted(second.fields)}",
161+
)
162+
)
163+
164+
print("-" * 72)
165+
print("ALL PASS" if all(results) else "FAILURES PRESENT")
166+
if not all(results):
167+
raise SystemExit(1)
168+
169+
170+
if __name__ == "__main__":
171+
asyncio.run(main())

0 commit comments

Comments
 (0)