Skip to content

feat(state): add VehicleState dataclass for the GetCarState endpoint#206

Merged
DasBasti merged 2 commits into
DasBasti:mainfrom
jusii:feat/vehicle-state-endpoint
Jun 13, 2026
Merged

feat(state): add VehicleState dataclass for the GetCarState endpoint#206
DasBasti merged 2 commits into
DasBasti:mainfrom
jusii:feat/vehicle-state-endpoint

Conversation

@jusii

@jusii jusii commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new read-only API surface for the GetCarState endpoint that the official app polls after toggling vehicle state (trip recording, valet mode, etc.). The endpoint returns a flat dictionary of ~21 scalar status flags distinct from the larger nested GetCar payload. This wraps the response in a VehicleState dataclass, fetches it best-effort on each get_vehicles() refresh, and attaches it to SmartVehicle.state for downstream consumers.

The primary motivation is to pair with journalLogV4 (PR #194, now merged — context only): after the trip-journal switch flips, this endpoint exposes the TBox's own journalLogState flag so a downstream switch can verify the toggle actually took effect rather than blindly trusting the command-dispatch response.

Endpoint specifics

  • Path: /remote-control/vehicle/status/state/{vin} (joined onto self.vehicles[vin].base_url, so V1/V2 base URLs are honored per vehicle)
  • Method: GET, no query params, no userId, no authorization-grant POST
  • Headers: standard generate_default_header(device_id, api_access_token, params={}, method="GET", url=path) — same HMAC signing as every other authed endpoint
  • Response shape: standard envelope {code, data: {...}, success, hint, httpStatus, sessionId, message}. The data dict is flat (no nested status sections), with ~21 scalar fields
  • Error envelope: cloud returns {"code": "8153", "data": null} for vehicles with the endpoint disabled; parser treats this as None (not an empty dataclass) so consumers can distinguish "no state available" from "all flags zero"

Unlike journalLogV4, this endpoint takes no rotating grant and has no per-VIN cache to invalidate — it's a simple side-effect-free read. No grant rotation, no token-cache contention, no per-VIN auth state.

New API surface

  1. pysmarthashtag.vehicle.vehicle_state.VehicleState@dataclass(VehicleDataBase) with 21 Optional[...] fields. Classmethod from_response(response: Any) -> Optional["VehicleState"] accepts either the full envelope (unwraps data) or an already-unwrapped data dict; returns None for non-dict input, empty dict, or error envelopes.

  2. SmartAccount.get_vehicle_state(self, vin) -> dict — async fetcher returning the raw response dict (caller wraps via VehicleState.from_response()). Three-retry loop catching SmartTokenRefreshNecessary (refresh + retry) and SmartHumanCarConnectionError (re-select active vehicle + retry). Returns {} on exhausted retries rather than raising. Mirrors get_trip_journal() exactly.

  3. SmartVehicle.state: Optional[VehicleState] = None — class-level attribute slot populated via a new state_response: Optional[dict] = None kwarg in combine_data.

  4. SmartAccount.get_vehicles() refresh loop — calls get_vehicle_state(vin) wrapped in try/except Exception (best-effort, errors logged at DEBUG only) and passes the result through combine_data(..., state_response=state_response). A flaky vehicle does not break the wider refresh.

Field-by-field breakdown

Telemetry / recording: journal_log_state (the headline field — flips 0→1 when trip recording activates), position_upload_state, car_locator_active, engine_state, power_mode.

Special modes: valet_mode_state, camping_mode_active, drift_mode_active, wash_car_mode_active, chat_video_main_active, park_comfort_state, privacy_mode.

Thermal: pulse_heat_active, overheat_state, bt_active (battery-thermal), bt_temp_active.

Connectivity / security: next_wakeup_time, svt_state, pnc_status, vstd_state (VSTD subsystem state — read from GetCarState; exact semantics not yet reverse-engineered, surfaced as best-effort for downstream consumers that may have observed it in their own captures), vin (echo-back sanity check).

Type choices

  • Optional[int] for 0/1 flags, not Optional[bool] — matches the existing convention in Running (~25 light/status flags also typed Optional[int]). The cloud sends raw 0/1 integers; coercing to bool would lose the ability to distinguish 0 from None in downstream sensor display and would diverge from Battery/Running/Maintenance style. Some fields (e.g. vstd_state: 2) are also wider than 0/1.

  • Optional[str] for power_mode — the cloud emits "1" as a quoted string. Coercing to int would silently drop any future non-numeric mode value; string round-trips the wire format faithfully.

  • Optional[datetime] for next_wakeup_time — cloud reports as epoch milliseconds as a string (canonical fixture: "1706002216000"). Code does datetime.fromtimestamp(int(nw) / 1000, tz=timezone.utc). Result is deliberately TZ-aware UTC — downstream TIMESTAMP device_classes reject naive datetimes. TypeError/ValueError from int(nw) are caught and the field is left None with a DEBUG line; garbage like "not-a-timestamp" will not raise.

  • engine_state naming gotcha documented in the field docstring: this is int (0/1), distinct from the engineStatus STRING field ("engine_off"/"engine_on") in the big GetCar payload. Two encodings for the same physical fact under two attribute names.

Best-effort fetch

The get_vehicle_state(vin) call in get_vehicles() is wrapped in try/except Exception (with # noqa: BLE001 and inline rationale). If the call fails, state_response stays None, and combine_data skips the assignment branch — preserving any previously cached vehicle.state rather than blowing it away (same semantics as last_trip). One flaky vehicle does not break refresh for the others.

Test coverage

pysmarthashtag/tests/test_vehicle_state.py — 8 tests, all dataclass-level units:

  1. test_from_response_unwraps_envelope — full {code, data, success, ...} envelope unwraps; verifies journal_log_state, position_upload_state, car_locator_active, svt_state, vin.
  2. test_from_response_accepts_unwrapped_data — already-unwrapped data dict works too; asserts engine_state, power_mode, privacy_mode.
  3. test_next_wakeup_time_decoded — epoch-ms string decodes to datetime, year is 2024, tzinfo is not None.
  4. test_next_wakeup_time_handles_garbage"not-a-timestamp" silently yields next_wakeup_time=None.
  5. test_from_response_handles_noneNone/non-dict input returns None.
  6. test_from_response_handles_empty{} and the real {"code":"8153","data":null} envelope both return None.
  7. test_from_response_handles_partial_data — missing fields silently become None.
  8. test_journal_log_state_flip_detection — practical use case: comparing two snapshots detects a 0→1 toggle flip.

Backward compatibility

Purely additive on the pysmarthashtag side. The state attribute is a class-level default of None, so any code that touches vehicle.state on an older payload gracefully sees None. No existing dataclass fields, function signatures, or wire calls are modified.

For downstream consumers: a wheel built from this branch is required — guards like vehicle.state is None evaluate cleanly on this branch (the class attribute resolves), but the same expression against a pre-branch install will raise AttributeError before is None can run. Defensive consumers should prefer getattr(vehicle, "state", None) is None when supporting mixed installs.

Verification

  • ruff check pysmarthashtag/ — clean
  • pytest pysmarthashtag/tests/ -q119 tests pass (111 existing + 8 new)
  • Live probe against the cloud confirms the canonical fixture matches real responses

Stacked / dependencies

Independent. Sits directly on upstream/main at a49f0a6. PR #194 (feat/trip-journal) is already merged and is context only — no rebase, no stacked-PR dance needed. The pairing argument with journalLogV4 is actually stronger now that #194 has landed: this endpoint confirms the bit that the trip-journal switch flips.

jusii added 2 commits June 12, 2026 17:41
Adds support for ``/remote-control/vehicle/status/state/{vin}`` (no params),
the small flat-dict state-flag endpoint distinct from the big GetCar
payload that Battery/Maintenance/etc. parse. Pairs naturally with the
trip-journal feature in PR DasBasti#194: this endpoint carries a
``journalLogState`` bit that diagnoses why ``journalLogV4`` returns
``code:8153`` for some accounts (the TBox-side trip-recording toggle is
off). Same envelope shape as every other endpoint here, same auth, same
signing — no rotating grant, no per-VIN auth cache.

What's new
----------
* ``pysmarthashtag/vehicle/vehicle_state.py`` — ``VehicleState`` dataclass
  with 21 fields parsed defensively from the response envelope:
  - ``journal_log_state`` — trip-recording bit on the TBox (the missing
    diagnostic for "why is journalLogV4 returning code:8153?")
  - ``position_upload_state``, ``car_locator_active`` — GPS sharing flags
  - ``next_wakeup_time`` — UTC-aware datetime (cloud reports epoch ms)
  - ``engine_state``, ``power_mode``, ``valet_mode_state``,
    ``camping_mode_active``, ``drift_mode_active``, ``wash_car_mode_active``,
    ``chat_video_main_active``, ``park_comfort_state``, ``privacy_mode``,
    ``pulse_heat_active``, ``overheat_state``, ``bt_active``,
    ``bt_temp_active``, ``svt_state``, ``pnc_status``, ``vstd_state``
  ``from_response()`` accepts both the full ``{code, data, success, …}``
  envelope and the unwrapped ``data`` dict; returns ``None`` for empty/
  error envelopes (e.g. ``{"code":"8153","data":null}``).
* ``SmartAccount.get_vehicle_state(vin)`` — async fetcher mirroring the
  ``get_trip_journal()`` retry/refresh pattern; errors are logged but not
  raised so a single flaky vehicle doesn't break the wider refresh.
* ``SmartAccount.get_vehicles()`` — wraps the new fetch in a best-effort
  ``try/except`` and passes the result through ``combine_data`` via the
  new ``state_response`` kwarg, identical to ``journal_response``.
* ``SmartVehicle.state`` — typed accessor for downstream consumers
  (e.g. SmartHashtag HA integration).

Tests
-----
* ``pysmarthashtag/tests/test_vehicle_state.py`` — 8 unit tests covering
  envelope unwrap, partial-data tolerance, malformed timestamps, the
  toggle-flip detection use case (which is the whole point), and
  TZ-aware datetime requirement (HA's TIMESTAMP device_class rejects
  naive datetimes).

All tests pass.
Three pre-commit ruff nits the earlier VehicleState commit slipped past:

* Two test functions (`test_from_response_handles_none`,
  `test_from_response_handles_empty`) lacked docstrings (D103).
* `test_journal_log_state_flip_detection` summary ended with a `?`
  instead of a `.` (D400).
* Imports were unsorted (I001) — auto-fixed.

Pure documentation/whitespace tweaks. All 112 tests pass.
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@jusii, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 59 minutes and 46 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a4f95c36-457a-4d8f-b126-5957693d226f

📥 Commits

Reviewing files that changed from the base of the PR and between a49f0a6 and 77bef09.

📒 Files selected for processing (4)
  • pysmarthashtag/account.py
  • pysmarthashtag/tests/test_vehicle_state.py
  • pysmarthashtag/vehicle/vehicle.py
  • pysmarthashtag/vehicle/vehicle_state.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@DasBasti DasBasti merged commit 59971d3 into DasBasti:main Jun 13, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants