Skip to content

refactor(vehicle): simplify the state machine: plain atom states, DB records moved into the state data - #5259

Open
brianmay wants to merge 11 commits into
mainfrom
refactor/extract-records-from-states
Open

refactor(vehicle): simplify the state machine: plain atom states, DB records moved into the state data#5259
brianmay wants to merge 11 commits into
mainfrom
refactor/extract-records-from-states

Conversation

@brianmay

@brianmay brianmay commented Apr 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR completes the state machine simplification by replacing compound state tuples like {:driving, status, %Log.Drive{}}, {:charging, %Log.ChargingProcess{}}, and {:updating, %Log.Update{}} with simple atoms (:driving, :charging, :updating). DB records and sub-states are stored in Data struct fields.

Changes (4 commits)

Commit 1: Add preparatory fields to Data struct

Adds current_drive, current_charging_process, current_update, and driving_status fields.

Commit 2: Populate Data struct fields in parallel with compound state tuples

Every transition into a compound state now also updates the corresponding struct fields.

Commit 3: Migrate compound state reads to struct field guards

Replaces pattern-match reads with guards on the struct fields.

Commit 4: Replace compound state tuples with simple atoms throughout state machine

This is the main simplification commit. After this:

  • The GenState state is always a simple atom
  • {:driving, status, drive}:driving (status in data.driving_status, drive in data.current_drive)
  • {:charging, cproc}:charging (cproc in data.current_charging_process)
  • {:updating, update}:updating (update in data.current_update)

Key changes in this commit:

  • All 12 next_state positions now use simple atoms
  • Driving sub-state machine (available → unavailable → offline) is tracked in data.driving_status instead of the state tuple's second element
  • All driving handlers match :driving with guards on driving_status
  • suspend_logging, fetch/can_fall_asleep, and fetch/reachable all use simple atom matching
  • Summary module updated to handle the new state format

Refactoring sequence (complete!)

  1. ✅ Replace fake_online_state integers with pre_online_check atoms
  2. ✅ Add struct fields + populate them + migrate reads
  3. This PR: Replace compound states with simple atoms

Before vs After

Before (hard to read):

def handle_event(:internal, {:update, {:offline, _}}, {:driving, {:unavailable, n}, drv}, ...)
def handle_event(:internal, {:update, {:offline, _}}, {:driving, {:offline, last}, drive}, ...)
def handle_event(:info, {:stream, %Stream.Data{} = stream_data}, {:driving, status, drv}, ...)

After (clean):

def handle_event(:internal, {:update, {:offline, _}}, :driving, %Data{driving_status: {:unavailable, n}}) do ...
def handle_event(:internal, {:update, {:offline, _}}, :driving, %Data{driving_status: {:offline, last}}) do ...
def handle_event(:info, {:stream, %Stream.Data{}}, :driving, %Data{driving_status: :available, current_drive: drv}) do ...

Testing

All commits are no-behaviour-change mechanical refactors. Existing tests pass.

@brianmay brianmay changed the title Add preparatory fields to Data struct for extracting DB records Extract DB records from compound state tuples: preparatory Data struct changes Apr 5, 2026
@brianmay

brianmay commented Apr 5, 2026

Copy link
Copy Markdown
Collaborator Author

This is only the first phase so far, we haven't seen the simplification to the compound state tuples yet. That will come later.

@brianmay

brianmay commented Apr 5, 2026

Copy link
Copy Markdown
Collaborator Author

As before, this was done with AI. It was going to make one big pull request, then we agreed that breaking it up might be better.

@swiffer
swiffer force-pushed the mcu2-upgraded-cars branch 2 times, most recently from c7b2015 to 5ccde37 Compare April 6, 2026 17:04
@brianmay
brianmay force-pushed the mcu2-upgraded-cars branch from 5ccde37 to ba33348 Compare April 9, 2026 04:58
@brianmay brianmay changed the title Extract DB records from compound state tuples: preparatory Data struct changes Extract DB records from compound state tuples: struct fields + read migration Apr 11, 2026
@brianmay brianmay changed the title Extract DB records from compound state tuples: struct fields + read migration Extract DB records from compound state tuples: simple atoms (Phase 5 final) Apr 11, 2026
@brianmay

brianmay commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator Author

I decided to do the full refactor in one pull request. While this the PR more complicated, don't think you can really review this probably unless you can see the entire picture.

The steps are still there, but as separate commits. Which probably should stay as separate commits.

Key questions before merging:

  1. Does this make things simpler and easier to understand?

I think by making the state an enum only and putting state data into the data variable we have made it easier to understand. If anyone disagrees, feel free to speak out :-)

  1. Is it correct?

It looks OK to me... Although certainly is scope for errors to creep in. Guess next step will be to test this.

@brianmay
brianmay force-pushed the refactor/extract-records-from-states branch from f2d2ed5 to 2f1f3fe Compare April 11, 2026 04:18
@brianmay

Copy link
Copy Markdown
Collaborator Author

I spotted a place where the logic had been changed and was more complicated then required. Fixed now.

@brianmay
brianmay force-pushed the refactor/extract-records-from-states branch 2 times, most recently from 96e4597 to 5317d27 Compare April 14, 2026 05:58
@brianmay

Copy link
Copy Markdown
Collaborator Author

All tests are expected to pass.

Still need to manually review the "Fix compound state tuple handling after dce479c refactoring" commit - haven't done that yet.

@brianmay

Copy link
Copy Markdown
Collaborator Author

Might make sense to merge the last three commits into the "Migrate compound state reads to struct field guards" commit, seeing as all they do is fix problems caused by this commit.

Will decide when more awake :-)

@brianmay
brianmay force-pushed the refactor/extract-records-from-states branch from 5317d27 to 089956b Compare April 14, 2026 06:13
@JakobLichterfeld JakobLichterfeld added enhancement New feature or request area:teslamate Related to TeslaMate core labels May 1, 2026
@JakobLichterfeld JakobLichterfeld changed the title Extract DB records from compound state tuples: simple atoms (Phase 5 final) refactor: extract DB records from compound state tuples: simple atoms (Phase 5 final) May 1, 2026
@brianmay
brianmay force-pushed the refactor/extract-records-from-states branch from 089956b to d1e6599 Compare May 5, 2026 01:23
@brianmay
brianmay changed the base branch from mcu2-upgraded-cars to main May 5, 2026 01:24
@brianmay

brianmay commented May 5, 2026

Copy link
Copy Markdown
Collaborator Author

I rebased this against the main branch, now that mcu2-upgraded-cars has been merged into main.

@brianmay
brianmay force-pushed the refactor/extract-records-from-states branch from d1e6599 to b334df0 Compare June 11, 2026 23:41
@netlify

netlify Bot commented Jun 11, 2026

Copy link
Copy Markdown

Deploy Preview for teslamate ready!

Name Link
🔨 Latest commit c3e626b
🔍 Latest deploy log https://app.netlify.com/projects/teslamate/deploys/6a2d4e430c7d490008cb2fcb
😎 Deploy Preview https://deploy-preview-5259--teslamate.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@JakobLichterfeld
JakobLichterfeld force-pushed the refactor/extract-records-from-states branch from b334df0 to c3e626b Compare June 13, 2026 12:34
@brianmay

Copy link
Copy Markdown
Collaborator Author

I got Claude Fable 5 to review this (while I still had access to Fable5!). I haven't had a chance to seriously evaluate its feedback yet, so will just post it here in its entirety so I don't accidentally lose it.

PR 5259 Review: extract DB records from compound state tuples

Overview

Final phase of the state-machine refactor: {:driving, status, drive},
{:charging, cproc}, {:updating, update} become plain atoms, with the records
moved to new Data fields (current_drive, current_charging_process,
current_update, driving_status). 2 files, +124/−75. CI is green (lint job
still pending).

Correctness

I traced every converted clause against the old behavior and found no
functional regressions:

  • The tricky {:driving, {:offline, last}, nil} drive-timeout case maps
    correctly to driving_status: {:offline, last}, current_drive: nil and still
    routes to :start on the next offline event (vehicle.ex:1218 vs 1236).
  • Summary.format_state(:driving, {:offline, _}) → :offline preserves the old
    special case, and the {state, _} clause still covers the remaining tuple
    states ({:offline, _}, {:asleep, _}, {:suspended, _}).
  • No leftover compound-state patterns anywhere in lib/ or test/, and both
    Summary.into call sites pass the new required driving_status key.

Main issue: stale record fields on exit (latent, worth fixing now)

The fields are cleared on some exits but not others, so the invariant "field
mirrors state" — the whole point of the refactor — doesn't actually hold:

  • current_charging_process is never cleared when charging completes (:charging
    → :start, vehicle.ex:1170). Only the asleep-while-charging path clears it
    (line 1132).
  • current_update is never cleared on either :updating → :start path (cancel at
    ~1412, finish at ~1434).
  • current_drive survives most :driving → :start exits: end-of-drive (1364),
    gained-range (1283), offline-timeout (1289), and asleep (1308) all clear
    driving_status but leave the closed drive in current_drive. Only the
    service-mode path clears both (481).

This is safe today because every entry into a state overwrites its field and
all readers guard on the state atom — but it's exactly the kind of footgun
this refactor exists to remove. The service-mode handler already reads
data.current_drive != nil as a guard (line 468), so a future reader doing the
same in another state would see a stale record. Suggestion: clear all three
fields (and driving_status) at every transition to :start — a small helper
like reset_activity(data) would make this impossible to forget.

Smaller points

  • driving_status sentinel is inconsistent. The struct default is :available,
    the comment documents :available | {:unavailable, n} | {:offline,
    last_vehicle}, but every exit path sets nil. Pick one "not driving" value —
    nil with a default of nil reads best — and update the comment to include it.
  • Redundant self-assignment at line 1154: %{data | current_charging_process:
    cproc} where cproc was just extracted from data — it's a no-op; plain data is
    clearer. Same pattern would apply if you keep the keep-charging clause as is.
  • Summary now silently couples to the caller: into/2's attrs map pattern-match
    makes driving_status required, which is fine for the two internal callers,
    but a brief @doc note or typespec update on Summary.into would help.

Test coverage

No test changes, and no tests reference the old tuples or new fields — they
exercise the public API, so the passing suite is a genuine
behavior-preservation signal. However, the field lifecycle (the staleness
issue above) is exactly the part with zero coverage. A test asserting the
records are cleared after a drive/charge/update ends would lock in the
invariant.

Verdict

Solid mechanical refactor with a clear before/after win in readability, and
the commit sequencing (populate → migrate reads → swap states) made it easy to
verify. I'd fix the stale-field cleanup and the nil/:available inconsistency
before merging, since they undermine the invariant the new design depends on;
the rest is optional polish.

✻ Cooked for 2m 29s

※ recap: I reviewed TeslaMate PR 5259, the state-machine refactor replacing compound state tuples with atoms. It's correct overall, but stale record fields aren't cleared on most state exits. Next step: decide whether to fix that cleanup before merging. (disable recaps in /config)

@JakobLichterfeld JakobLichterfeld added this to the v4.1.0 milestone Jun 28, 2026
brianmay and others added 5 commits August 13, 2026 13:24
This PR migrates all pattern-match reads of compound state tuples
({:driving, status, drive}, {:charging, cproc}, {:updating, update})
to use the corresponding Data struct fields instead.

Changes:
- vehicle_in_service driving case: guard on data.current_drive != nil,
  read drive from data.current_drive, clear both current_drive and
  driving_status on exit from driving state
- remaining_distance interval: use {current_drive, current_charging_process}
  tuple instead of compound state pattern match
- asleep handler: guard on driving_status != nil && driving_status != :available
  to detect offline sub-states, clear driving_status on :start exit
- fetch can_fall_asleep?: add guards on current_drive/update/charging_process
- fetch reachable: same guards added
- All 6 transitions to :start from driving state now clear driving_status: nil

The compound state tuples remain in next_state positions (handled in prior
PR), so this is purely a read migration. No behaviour changes expected.

No behaviour changes - all guards are guaranteed true at runtime given
correct dual-write population from prior PR.
The dce479c commit converted :driving, :charging, :updating states to simple atoms but left :asleep, :offline, :suspended as compound tuples, causing Summary.format_state and various handlers to fail.

Fixes:
- Summary.format_state: Handle compound tuples like {:asleep, interval}, {:offline, interval}
- driving: Add handler for {:asleep, _} in :driving state (was only matching {:offline, _})
- driving offline: Fix offline handler to not overwrite {:offline, _} without checking timeout
- charging: Convert {:charging, cproc} to simple :charging atom
- updating: Convert {:updating, _update_id} to simple :updating atom
- :summary call: Include driving_status in attrs map for Summary.into
- vehicle_in_service handler: Use :driving instead of {:driving, _, _}

All 91 vehicle tests and 333 total tests pass.
The interval calculation for API errors was unnecessarily restructured
during the read-migration phase. The original case on state was still
correct at that point and should have remained unchanged.

With simple atoms now in place, the correct form is restored:
  :driving -> 10
  :charging -> 15
  :online -> 20
  _ -> 30

This is a 4-line case expression instead of the 14-line restructure
that was added during read migration.
The guards 'when data.current_drive != nil' etc. on the simple atom
patterns ':driving', ':updating', ':charging' are unnecessary. The state
atom itself is the source of truth - if we're in ':driving', the
dual-write invariant guarantees current_drive is populated. The guards
added nothing over the atom pattern match.
The store_position timeout guard still matched the old compound state tuple ({:charging, cproc}); with the plain :charging atom it never matched, so no positions were stored during a charging session and the timer chain stayed dead until the next :online entry.

Match the state atoms directly and make the storing interval configurable via the :store_position_interval start option so the regression test can exercise the timer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JakobLichterfeld
JakobLichterfeld force-pushed the refactor/extract-records-from-states branch from c3e626b to 474786f Compare August 13, 2026 12:06
@netlify

netlify Bot commented Aug 13, 2026

Copy link
Copy Markdown

Deploy Preview for teslamate ready!

Name Link
🔨 Latest commit 3c2711d
🔍 Latest deploy log https://app.netlify.com/projects/teslamate/deploys/6a7f1de394006e0008a4f326
😎 Deploy Preview https://deploy-preview-5259--teslamate.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

JakobLichterfeld and others added 3 commits August 13, 2026 14:14
The driving stream handler only matches driving_status: :available after the atom-state refactor. Stream frames received while {:unavailable, n} or {:offline, last} fell through to the generic catch-all, which logged every frame at info level and no longer triggered an immediate fetch — recovery had to wait for the 5s/30s polling cycle.

Restore the pre-refactor behavior: any stream frame while :driving in a non-available sub-state schedules an immediate fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Data fields added for the atom-state refactor (current_drive, current_charging_process, current_update, driving_status) were cleared on some :start transitions but not others, so stale records could outlive their state. Route every transition to :start through a reset_activity/1 helper so the invariant "field mirrors state" always holds, and make nil the single "not driving" sentinel for driving_status (the struct default was :available while every exit set nil).

Also drop a no-op self-assignment of current_charging_process and lock the lifecycle with assertions in the drive/charge/update flow tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JakobLichterfeld JakobLichterfeld changed the title refactor: extract DB records from compound state tuples: simple atoms (Phase 5 final) refactor(vehicle): simplify the state machine: plain atom states, DB records moved into the state data Aug 13, 2026
@JakobLichterfeld

JakobLichterfeld commented Aug 13, 2026

Copy link
Copy Markdown
Member

Update: I rebased this onto current main and pushed commits addressing all points from the review above.

Rebase: The conflicts were the expected ones — main moved in exactly this area. The semantics of #5535 (stream reconnect on drive resume), #5515 (geofence updates while driving) and #5417 (streaming-aware interval) are preserved in the converted clauses. Main's new Summary unit tests (#5487) now pass the required driving_status key; that adaptation is folded into the "Migrate compound state reads" commit to keep the branch bisectable.

Fixes on top:

  • 474786f fix(vehicle): keep periodic position storing while charging — the store_position guard still expected the {:charging, _} tuple and never matched the new atom, so no positions were stored during a charging session. Comes with a regression test (verified red against the broken guard) and a test-configurable :store_position_interval start option.
  • 68fe7fe fix(vehicle): restore immediate fetch on stream data while unavailable — stream frames while :driving in {:unavailable, n} / {:offline, last} fell through to the generic catch-all (info-log spam, no immediate fetch). Behavior is back to pre-refactor: a frame while unavailable triggers an immediate fetch.
  • e941f5f refactor(vehicle): reset activity fields on every transition to :start — the agreed reset_activity/1 helper now runs on all 17 transitions to :start, nil is the single "not driving" sentinel for driving_status, the no-op self-assignment is gone, and the drive/charge/update flow tests assert the fields are cleared once the vehicle is back :online.

Full suite is green (461 tests). @brianmay from my side this is now ready — please give it a look, and shout if you disagree with any of the calls above (especially restoring the immediate-fetch behavior rather than dropping it).


🤖 Fixes drafted with Claude Code (Fable 5 extra) — sponsored by Claude for Open Source

@brianmay

Copy link
Copy Markdown
Collaborator Author

Review by opencode/Big Pickle:

Review Summary: PR 5259 (simplify vehicle state machine to plain atoms)

Verdict: no bugs found. Mechanical, well-executed refactor; verified by applying the diff, compiling with --warnings-as-errors, and running the 124-test vehicle suite (all pass).

Verification highlights:

  • All 17 {:next_state, :start, ...} transitions call reset_activity/1 (needed since DB sub-state left the dropped state tuple) — none missed.
  • No stale {:driving, _, _} / {:charging, _} / {:updating, _} patterns remain.
  • Split driving-stream handler (vehicle.ex:707-759) is behavior-equivalent to the old case; driving_status: :available always implies current_drive != nil.
  • Sub-state clause ordering (e.g. {:unavailable, n} with n < 15 before catch-all) preserves old match order.
  • Summary.format_state/2 covers all remaining state shapes; dropping the 3-tuple clause is correct.

Minor issues:

  1. Test efficacy (low): the new keeps storing positions periodically while charging test asserts only two insert_position events, but two independent store-position timers (online-entry + charging-entry) can each fire once to satisfy it — a broken reschedule wouldn't be caught. Assert a third event to actually exercise "periodically."
  2. Leftover tuple state in summary_test.exs: state: {:online, nil} no longer occurs in production (states are now atoms); harmless but tests an unreachable shape.
  3. Pre-existing observation: the double store-position timer (online + charging both armed) causes doubled position inserts while charging; predates this PR but the new test makes it visible.

No further action needed — nothing blocking in the PR.

@brianmay brianmay left a comment

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.

Looks good to me.

Looks like I can't approve my own pull request :-)

I think this needs to be carefully tested, simply because of the invasive changes into the state machine logic.

@d1stru3t0r

Copy link
Copy Markdown

So move it to 4.2 to have a surprise 4.1 release these hours? :P

@JakobLichterfeld JakobLichterfeld modified the milestones: v4.1.0, v4.2.0 Aug 14, 2026
@JakobLichterfeld

Copy link
Copy Markdown
Member

Thanks for running another review over it! The verification section matches my own checks. On the minor issues:

1 & 3 rest on a wrong premise: there is no second store-position timer. Generic timeouts in gen_statem are keyed by name — setting {:timeout, :store_position} again replaces the running timer (see the gen_statem docs: "Setting a timer with the same Name while it is running will restart it with the new time-out value.", see https://www.erlang.org/doc/apps/stdlib/gen_statem.html#t:generic_timeout/0). So entering :charging replaces the timer armed at :online entry, and there are no doubled inserts — before or after this PR. The new test also drains the mailbox after :charging is established, so the two asserted inserts can only come from that single timer, and the second one requires the handler to have re-armed — a broken reschedule fails the test.

2 is fair: state: {:online, nil} in summary_test.exs was never a real state shape (it came in with #5487, independent of this PR). I'll change it to :online in passing.

Nothing blocking from my side either.


🤖 Reply drafted with Claude Code (Fable 5 extra) — sponsored by Claude for Open Source

{:online, nil} was never a state the machine produces; since the atom-state refactor the online state is the plain :online atom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JakobLichterfeld

Copy link
Copy Markdown
Member

I think this needs to be carefully tested, simply because of the invasive changes into the state machine logic.

Agreed on careful testing — the state machine is the heart of the logger. When running this branch for a few days, specifically watching: positions being stored every 5 min during charging (the regression fixed here), recovery behavior on mid-drive connectivity loss, clean field resets after drive/charge/update (:sys.get_state), and the logs for any FunctionClauseError or stream-data spam. If anything surfaces report here.


🤖 Drafted with Claude Code (Fable 5 extra) — sponsored by Claude for Open Source

@JakobLichterfeld

JakobLichterfeld commented Aug 14, 2026

Copy link
Copy Markdown
Member

So move it to 4.2 to have a surprise 4.1 release these hours? :P

Ok, as requested :-) https://github.com/teslamate-org/teslamate/releases/tag/v4.1.0 and https://github.com/teslamate-org/teslamate/releases/tag/v4.1.1

@d1stru3t0r

Copy link
Copy Markdown

So move it to 4.2 to have a surprise 4.1 release these hours? :P

Ok, as requested :-) https://github.com/teslamate-org/teslamate/releases/tag/v4.1.0 and https://github.com/teslamate-org/teslamate/releases/tag/v4.1.1

haha ty, I was mid trip when I tried to update my docker and I bricked everything xd

@JakobLichterfeld

JakobLichterfeld commented Aug 15, 2026

Copy link
Copy Markdown
Member

haha ty, I was mid trip when I tried to update my docker and I bricked everything xd

Quoting myself:

yeah, I can hear it myself as I type this.

Same for you 😀

@JakobLichterfeld JakobLichterfeld modified the milestones: v4.2.0, v4.3.0 Aug 17, 2026
@d1stru3t0r

Copy link
Copy Markdown

Ok, i think it got extremele fricked up. I've read the stuff about backup but at this point I feel like i can't do anything with it. Do you know the simple way to backup the database, as I'd want to delete my entire docker, reinstall and then reuse my backup.

I can either use teslamate without database now OR sync database but then teslamate doesn't work. No idea what got so ruined and I'm tired of so much debugging nonsense on termux

@brianmay

Copy link
Copy Markdown
Collaborator Author

@d1stru3t0r This probably deserves a new discussion item.

@d1stru3t0r

Copy link
Copy Markdown

@d1stru3t0r This probably deserves a new discussion item.

Small noobie question, my 3000 work now but reports a very old status, do i have to regenerate tesla tokens? Or what tf is happening xd

@JakobLichterfeld

Copy link
Copy Markdown
Member

Please open a discussion as mentioned by Brian or an Issue, we do not know anything about what you did, what your logs show etc. This PR is not the right place to discuss your issues. Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:teslamate Related to TeslaMate core enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants