Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3
fix: gate config no-op skip on a populated flag, not connection state#32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -46,7 +46,6 @@ | ||
| ShiftState, | ||
| Signal, | ||
| SpeedAssistLevel, | ||
| SseTopic, | ||
| State, | ||
| Status, | ||
| SunroofInstalledState, | ||
| @@ -73,6 +72,8 @@ class TeslemetryStreamVehicle: | ||
| preferTyped: bool | None | ||
| _config: dict[str, Any] | ||
| _flight: asyncio.Task[None] | None | ||
| _populated: bool | ||
| _populate_flight: asyncio.Task[None] | None | ||
| def __init__(self, stream: TeslemetryStream, vin: str): | ||
| # A dictionary of TelemetryField keys and null values | ||
| @@ -83,10 +84,18 @@ def __init__(self, stream: TeslemetryStream, vin: str): | ||
| self.fields = {} | ||
| self.preferTyped = None | ||
| self._config = {} | ||
| # Whether fields/preferTyped reflect a real server answer (a push | ||
| # event or a REST fetch) rather than just their unset defaults - | ||
| # gates add_field/prefer_typed's lazy REST fetch below. | ||
| self._populated = False | ||
| # The single in-flight (or most recently completed) coalesced flush. | ||
| # Callers that arrive while it is running merge into `_config` and | ||
| # await it instead of starting their own PATCH. | ||
| self._flight = None | ||
| # The single in-flight populating get_config() call, so a batch of | ||
| # listeners (e.g. HA integration setup) discovering an unpopulated | ||
| # vehicle joins one GET instead of each starting its own. | ||
| self._populate_flight = None | ||
| # Registered from birth, not lazily, so no connection can ever | ||
| # predate this listener and miss a config event. Safe outside a | ||
| # running loop: `internal=True` makes async_add_listener's | ||
| @@ -97,6 +106,11 @@ def __init__(self, stream: TeslemetryStream, vin: str): | ||
| {Key.VIN: self.vin, Key.CONFIG: None}, | ||
| internal=True, | ||
| ) | ||
| # A disconnect can leave fields/preferTyped stale until the next | ||
| # connection's config snapshot arrives - unpopulate so a | ||
| # field-config call landing in that window awaits a fresh fetch | ||
| # instead of trusting the pre-disconnect record. | ||
| self.stream.async_add_connection_listener(self._on_connection_event) | ||
| @property | ||
| def config(self) -> dict[str, Any]: | ||
| @@ -120,12 +134,37 @@ async def get_config(self) -> None: | ||
| self.fields = response.get("fields", {}) | ||
| self.preferTyped = response.get("prefer_typed", False) | ||
| self._populated = True | ||
| return | ||
| if req.status == 404: | ||
| # No config exists for this vehicle yet - an authoritative | ||
| # answer (empty), not a missing one. | ||
| self._populated = True | ||
| return | ||
| req.raise_for_status() | ||
| def _on_connection_event(self, connected: bool) -> None: | ||
| """Unpopulate on disconnect - see the __init__ registration comment.""" | ||
| if not connected: | ||
| self._populated = False | ||
| async def _ensure_populated(self) -> None: | ||
| """Lazily fetch current config over REST if not yet known. | ||
| Optimistic updates from `_on_config_event` keep the record fresh | ||
| once established; this only covers the gap before a connection's | ||
| first snapshot (or after a disconnect) arrives. Concurrent callers | ||
| join the same fetch rather than each issuing their own GET. | ||
| """ | ||
| if self._populated: | ||
| return | ||
Comment on lines
+160
to
+161
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When AGENTS.md reference: AGENTS.md:L18-L18 Useful? React with 👍 / 👎. | ||
| flight = self._populate_flight | ||
| if flight is None or flight.done(): | ||
| flight = asyncio.ensure_future(self.get_config()) | ||
| self._populate_flight = flight | ||
| await asyncio.shield(flight) | ||
| def _on_config_event(self, event: dict[str, Any]) -> None: | ||
| """Sync the record from a server-pushed config event. | ||
| @@ -140,6 +179,8 @@ def _on_config_event(self, event: dict[str, Any]) -> None: | ||
| ) | ||
| return | ||
| self._populated = True | ||
| if "fields" in config: | ||
| fields = config["fields"] | ||
| # Every entry must itself be a dict (e.g. {"interval_seconds": 60} | ||
| @@ -290,10 +331,10 @@ async def add_field(self, field: Signal | str, interval: int | None = None) -> N | ||
| if isinstance(field, Signal): | ||
| field = field.value | ||
| if ( | ||
| self._record_is_live() | ||
| and field in self.fields | ||
| and (interval is None or self.fields[field].get("interval_seconds") == interval) | ||
| await self._ensure_populated() | ||
| if field in self.fields and ( | ||
| interval is None or self.fields[field].get("interval_seconds") == interval | ||
| ): | ||
| LOGGER.debug( | ||
| "Streaming field %s already enabled @ %ss", | ||
| @@ -307,26 +348,11 @@ async def add_field(self, field: Signal | str, interval: int | None = None) -> N | ||
| async def prefer_typed(self, prefer_typed: bool) -> None: | ||
| """Set prefer typed.""" | ||
| if self._record_is_live() and self.preferTyped == prefer_typed: | ||
| await self._ensure_populated() | ||
| if self.preferTyped == prefer_typed: | ||
| return | ||
| await self.update_config({"prefer_typed": prefer_typed}) | ||
| def _record_is_live(self) -> bool: | ||
| """Whether the record is being kept current and can gate the no-op skip. | ||
| The skip is purely an optimization - the server handles a redundant | ||
| PATCH fine - so this only needs to answer "is the config-sync | ||
| listener actually able to observe a server-side change right now", | ||
| not force the record fresh. That requires both a live connection and | ||
| the `config` topic not being filtered out via `TeslemetryStream | ||
| (topics=...)`; if either is false, add_field/prefer_typed skip the | ||
| no-op check and always send, same as the pre-feature status quo. | ||
| """ | ||
| if not self.stream.connected: | ||
| return False | ||
| topics = self.stream.topics | ||
| return topics is None or SseTopic.CONFIG in topics | ||
| def _enable_field(self, field: Signal) -> None: | ||
| """Enable a field for streaming from a listener.""" | ||
| asyncio.create_task(self.add_field(field)) | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this vehicle has cached fields or
preferTyped, is disconnected, and the lazy config GET then returns 404 because its server-side config was removed, this marks the old record populated without clearing it. The following no-op check can therefore skip recreating a missing field or restoringprefer_typedbased on stale pre-disconnect values; reset both cached pieces to the authoritative empty/default state before setting_populated.AGENTS.md reference: AGENTS.md:L21-L21
Useful? React with 👍 / 👎.