feat(state): add VehicleState dataclass for the GetCarState endpoint#206
Conversation
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.
|
Warning Review limit reached
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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Summary
Adds a new read-only API surface for the
GetCarStateendpoint 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 nestedGetCarpayload. This wraps the response in aVehicleStatedataclass, fetches it best-effort on eachget_vehicles()refresh, and attaches it toSmartVehicle.statefor 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 ownjournalLogStateflag so a downstream switch can verify the toggle actually took effect rather than blindly trusting the command-dispatch response.Endpoint specifics
/remote-control/vehicle/status/state/{vin}(joined ontoself.vehicles[vin].base_url, so V1/V2 base URLs are honored per vehicle)GET, no query params, no userId, no authorization-grant POSTgenerate_default_header(device_id, api_access_token, params={}, method="GET", url=path)— same HMAC signing as every other authed endpoint{code, data: {...}, success, hint, httpStatus, sessionId, message}. Thedatadict is flat (no nested status sections), with ~21 scalar fields{"code": "8153", "data": null}for vehicles with the endpoint disabled; parser treats this asNone(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
pysmarthashtag.vehicle.vehicle_state.VehicleState—@dataclass(VehicleDataBase)with 21Optional[...]fields. Classmethodfrom_response(response: Any) -> Optional["VehicleState"]accepts either the full envelope (unwrapsdata) or an already-unwrappeddatadict; returnsNonefor non-dict input, empty dict, or error envelopes.SmartAccount.get_vehicle_state(self, vin) -> dict— async fetcher returning the raw response dict (caller wraps viaVehicleState.from_response()). Three-retry loop catchingSmartTokenRefreshNecessary(refresh + retry) andSmartHumanCarConnectionError(re-select active vehicle + retry). Returns{}on exhausted retries rather than raising. Mirrorsget_trip_journal()exactly.SmartVehicle.state: Optional[VehicleState] = None— class-level attribute slot populated via a newstate_response: Optional[dict] = Nonekwarg incombine_data.SmartAccount.get_vehicles()refresh loop — callsget_vehicle_state(vin)wrapped intry/except Exception(best-effort, errors logged at DEBUG only) and passes the result throughcombine_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, notOptional[bool]— matches the existing convention inRunning(~25 light/status flags also typedOptional[int]). The cloud sends raw0/1integers; coercing toboolwould lose the ability to distinguish0fromNonein downstream sensor display and would diverge fromBattery/Running/Maintenancestyle. Some fields (e.g.vstd_state: 2) are also wider than 0/1.Optional[str]forpower_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]fornext_wakeup_time— cloud reports as epoch milliseconds as a string (canonical fixture:"1706002216000"). Code doesdatetime.fromtimestamp(int(nw) / 1000, tz=timezone.utc). Result is deliberately TZ-aware UTC — downstream TIMESTAMP device_classes reject naive datetimes.TypeError/ValueErrorfromint(nw)are caught and the field is leftNonewith a DEBUG line; garbage like"not-a-timestamp"will not raise.engine_statenaming gotcha documented in the field docstring: this isint(0/1), distinct from theengineStatusSTRING 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 inget_vehicles()is wrapped intry/except Exception(with# noqa: BLE001and inline rationale). If the call fails,state_responsestaysNone, andcombine_dataskips the assignment branch — preserving any previously cachedvehicle.staterather than blowing it away (same semantics aslast_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:test_from_response_unwraps_envelope— full{code, data, success, ...}envelope unwraps; verifiesjournal_log_state,position_upload_state,car_locator_active,svt_state,vin.test_from_response_accepts_unwrapped_data— already-unwrappeddatadict works too; assertsengine_state,power_mode,privacy_mode.test_next_wakeup_time_decoded— epoch-ms string decodes to datetime, year is 2024,tzinfo is not None.test_next_wakeup_time_handles_garbage—"not-a-timestamp"silently yieldsnext_wakeup_time=None.test_from_response_handles_none—None/non-dict input returnsNone.test_from_response_handles_empty—{}and the real{"code":"8153","data":null}envelope both returnNone.test_from_response_handles_partial_data— missing fields silently becomeNone.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
stateattribute is a class-level default ofNone, so any code that touchesvehicle.stateon an older payload gracefully seesNone. 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 Noneevaluate cleanly on this branch (the class attribute resolves), but the same expression against a pre-branch install will raiseAttributeErrorbeforeis Nonecan run. Defensive consumers should prefergetattr(vehicle, "state", None) is Nonewhen supporting mixed installs.Verification
ruff check pysmarthashtag/— cleanpytest pysmarthashtag/tests/ -q— 119 tests pass (111 existing + 8 new)Stacked / dependencies
Independent. Sits directly on
upstream/mainata49f0a6. PR #194 (feat/trip-journal) is already merged and is context only — no rebase, no stacked-PR dance needed. The pairing argument withjournalLogV4is actually stronger now that #194 has landed: this endpoint confirms the bit that the trip-journal switch flips.