Skip to content

Raw car replies in the live state and derive values once - #158

Merged
dzid26 merged 3 commits into
mainfrom
work/read-model-wrappers
Oct 8, 2026
Merged

dzid26 merged 3 commits into
mainfrom
work/read-model-wrappers

Conversation

@dzid26

@dzid26 dzid26 commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Owner direction (2026-10-08, "no duplicating", then "add enums"): the read models stop copying proto fields, and the charging state and gear are enums instead of strings. The car's raw reply is the live state, and every derived value is defined in one place. Three commits: the acquiredAt rename, the refactor, then the enums. This is a pure refactor with no behavior change.

No issue tracks the refactor itself. Part of #120 (domain values as strings: this covers the charging-state and shift-state part; the rest of #120 is untouched). Related: #118.

  • Live state is the raw reply. TeslaCommands.Charge and TeslaVcsec.Status are deleted: both copied proto fields and also carried raw. parseChargeState and parseStatusResponse return the raw ChargeState / VehicleStatus, as parseDriveState already did. TeslaConnection holds them, HistoryStore.record takes a ChargeState, and the screens read plain proto fields (charge.battery_level).
  • Derived values, once. New core/.../protocol/StateViews.kt holds extension properties for the values that need a rule: VehicleStatus.locked / asleep / userPresent, ChargeState.chargingStateKind, ChargeState.chargingMph (the rate fallback) and DriveState.shiftStateKind. The rules moved verbatim from TeslaVcsec.statusOf, Charge.chargingMph and the two name functions in TeslaCommands. The samples, the screens and the debug log all use them.
  • Enums for the charging state and the gear (Core API: stringly-typed state, null as the only error, no explicit API mode; not yet library-grade #120). ChargingStateKind (Charging, Complete, Stopped, Disconnected, NoPower, Starting, Calibrating, Unknown) and ShiftStateKind (P, R, N, D, Invalid, SNA) sit in core/.../protocol/StateKinds.kt, one value per member of the car's oneof, so there is no KMP-unfriendly import. chargingStateKind and shiftStateKind are each one explicit when over the oneof, null when unset. Every user switched over: BatterySample.chargingState, DriveSample.shiftState, charge-session cutting, the controller's change detection and debug log (which now prints the enum name), and chargingStateText, a when the compiler checks for missing cases. No string comparison against a state name remains. The log still stores the raw protos, so nothing on disk changes. StateViewsTest pins the mapping of every member.
  • Samples: compact shape (see below). BatterySample, StatusSample and DriveSample hold only the fields something reads, filled through the StateViews properties; the raw record stays on disk. BatterySample drops nine fields nothing read (usable level, estimated and ideal range, ideal miles added, both charge rates, charger power, voltage and current). The read surface is unchanged, so projections, health, charts and screens compile as before. The rules are unchanged: a record without the car's timestamp stays out of the battery and drive samples, and status is timed by acquired_at. There is no init { require }; the toXSample() factories remain the guard.
  • acquiredAt: Instant. acquiredAtMillis: Long becomes acquiredAt: Instant along the history path (controller, the four HistoryStore record functions, shouldLogStatus and its test), so the name and type match the acquired_at field. The controller still reads the clock once per reply, in whole milliseconds, so stored values are unchanged. chargeAtMillis stays a Long. core uses Wire's Instant, with no new java.* import.
  • Docs. ADR-0008's read-models bullet says where derived values live; a research note that named TeslaCommands.Charge points at ChargeState.chargingMph.

Sample shape and memory (owner decision)

No decision had arrived when I reached the samples, so I built the recommended default: compact samples. The alternative is a wrapper that holds the whole proto. Retained heap for 20k charge samples, measured on a JVM (the full-proto rows use a ChargeState with a third of its ~75 fields set, and with all of its scalars set). These were measured before the enum change, which adds a reference where a string reference was.

Shape Per sample 20k charge samples
Before (17 copied fields) 231 B 4.4 MB
This PR (compact, 8 fields) 95 B 1.8 MB
Full-proto wrapper 1.2 to 1.5 KB 22 to 30 MB

A DriveState wrapper would add 156 B (parked) to 469 B (fully populated) per sample, and it would put the destination and route into memory and toString; with the compact shape DriveSample never holds them, so ADR-0008's "stays in the log only" stays true. Switching to full-proto wrappers later touches only the three sample classes, their mappings and the tests that hand-build samples.

Decisions to confirm

  • Compact versus full-proto samples (above).
  • The nine unread BatterySample fields are dropped. Nothing in main read them; they remain in the log.
  • The screens read proto field names (charge.battery_level, charge.charge_limit_soc), so BleModels.kt and CarScreen.kt now import generated types. MainScreen.kt and BleTrackingService.kt only gain imports for the status flags. MainActivity.kt is untouched.
  • The debug-log text for the SOC and shift-state lines now prints the enum name; the wording is otherwise the same.

Type

  • Bug fix
  • Feature
  • Protocol / crypto change
  • Docs / CI / tooling
  • Refactor (no behavior change)

Testing

  • The full gate from the brief passes on the final tree: :core:test :app:assembleDebug :app:testDebugUnitTest :app:lintDebug ktlintCheck detekt. Counts: core 174 on main before, 181 now; app unit tests 30 before and after.
  • Tests changed only where the shape changed:
    • the parse tests assert the whole raw message;
    • the derived-value tests moved into the new StateViewsTest, which pins every enum member's mapping, the unset cases, and the documented fallbacks (a status without flags reads as unlocked, awake and away);
    • the sample helpers in the chart, history, session, scale and health tests take the enums instead of names;
    • BatteryHistoryTest loses chargeStatsDeriveFromTheDisplayedLevel, because its premise (a usable level on the sample) is gone; the displayed-versus-usable check now sits in ChargeStateMappingTest;
    • StatusLogPolicyTest passes Instants.
  • Old-versus-new differential (a throwaway harness, not committed), run on the first two commits: 12,000 lines from 3,000 seeded random replies each of charge, status, drive and status-log decisions, covering every charging and shift state, NaN, negative and infinite rates, and absent fields. The live-view values, every sample's read surface and the log decisions from main and from that tree match byte for byte. A deliberately perturbed rule makes the diff fail, so it can see a regression. The enum commit is covered by StateViewsTest and the existing session, chart and mapping tests rather than re-run through it.
  • Real car: not tested. The simulated-car tests (FakeCarProtocolTest) cover the parse round trips. A quick smoke test would be: connect, check the battery percentage, the charge-details line and the status line, and check that the charge, status and drive logs still grow.

Checklist

  • Protocol changes ship test vectors; expected.txt regenerated, not hand-edited (n/a: no protocol or log-format change; the car's raw replies are logged exactly as before)
  • No VINs, keys, or decrypted payloads in logs (the debug log lines are unchanged apart from the enum name)
  • Docs/master-plan updated where relevant (ADR-0008 and one research note; nothing in the master plan changes)
  • New source files carry SPDX-License-Identifier: AGPL-3.0-only

🤖 Generated with Claude Code

https://claude.ai/code/session_0129RzhbEE2cboMcYyZMuxqX

claude added 2 commits October 8, 2026 17:56
The history path called the phone's clock acquiredAtMillis and carried it
as a Long, converting it to the record's acquired_at Timestamp at the
store. It now travels as acquiredAt: Instant, from the controller's single
clock read through HistoryStore (record, recordStatus, recordDrive,
recordConnection) and shouldLogStatus, so the name and type match the
acquired_at field and the store no longer converts.

The controller still reads the clock once per reply, in whole
milliseconds (Instant.ofEpochMilli(System.currentTimeMillis())), so the
stored values are unchanged; Instant.now() can carry finer digits. The
UI's chargeAtMillis stays a Long. core uses Wire's Instant, so no java.*
import enters portable code. No behavior change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129RzhbEE2cboMcYyZMuxqX
The live state no longer copies proto fields. TeslaCommands.Charge and
TeslaVcsec.Status are gone: parseChargeState and parseStatusResponse
return the raw ChargeState and VehicleStatus, as parseDriveState already
did, TeslaConnection holds them, and HistoryStore.record takes the
ChargeState. The screens read plain fields off the message
(charge.battery_level).

A value that needs a rule is an extension property in the new
core/protocol/StateViews.kt, defined once: the status flags (locked,
asleep, userPresent), ChargeState.chargingStateName and chargingMph (the
rate fallback), and DriveState.shiftStateName. The samples, the screens
and the debug log all use them. The rules moved verbatim from
TeslaVcsec.statusOf, Charge.chargingMph and the two name functions in
TeslaCommands.

The history samples are filled through those properties and keep only
what something reads. BatterySample drops nine fields nothing read
(usable level, estimated and ideal range, ideal miles added, both charge
rates, charger power, voltage and current); the raw record still has
them in the log. StatusSample and DriveSample keep their shape. The read
surface is unchanged, so projections, health, charts and screens compile
as before, and the rules stay: a record without the car's timestamp is
left out of the battery and drive samples, and status is timed by
acquired_at.

Tests follow the new shapes; the derived-value tests live in
StateViewsTest. No behavior change: an old-versus-new dump of the read
surface over 12,000 seeded random replies matched byte for byte.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129RzhbEE2cboMcYyZMuxqX
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Screenshots

App screenshots: light and dark

Captured from the demo build at 773a062.

Debug APK: zip from Android CI (GitHub sign-in needed)

get() {
val state = charging_state ?: return null
return when {
state.Charging != null -> "Charging"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we determine those strings from the field name automatically? What would we lose - translations ability?

The charging state and the gear were turned into strings ("Charging",
"P") and then compared as strings in core, the controller and the
screens, where a typo compiled and failed silently. They are now enums
in core/protocol/StateKinds.kt, one value per member of the car's oneof:
ChargingStateKind (Charging, Complete, Stopped, Disconnected, NoPower,
Starting, Calibrating, Unknown) and ShiftStateKind (P, R, N, D, Invalid,
SNA).

ChargeState.chargingStateName becomes chargingStateKind and
DriveState.shiftStateName becomes shiftStateKind, each still one explicit
when over the oneof and null when unset. Every user switches over:
BatterySample.chargingState and DriveSample.shiftState, the charge
session cutting, the controller's change detection and debug log (which
now prints the enum name), and chargingStateText, a when the compiler
checks for missing cases. The demo history seeds from the enum too. The
log still stores the raw protos, so nothing on disk changes.

StateViewsTest pins the mapping of every member; the test helpers take
the enums instead of names.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129RzhbEE2cboMcYyZMuxqX
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Screenshots

App screenshots: light and dark

Captured from the demo build at 2563491.

Debug APK: zip from Android CI (GitHub sign-in needed)

@dzid26 dzid26 changed the title Hold raw car replies in the live state and derive values once Raw car replies in the live state and derive values once Oct 8, 2026
@dzid26
dzid26 merged commit 924dfaa into main Oct 8, 2026
5 checks passed
@dzid26
dzid26 deleted the work/read-model-wrappers branch October 8, 2026 23:37
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