rivian

package
v0.38.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 22, 2026 License: MIT Imports: 40 Imported by: 0

Documentation

Overview

Package rivian is Rivolt's adapter to the unofficial Rivian owner-app GraphQL API. The public surface is intentionally narrow so we can swap the implementation if the upstream breaks.

NOTE: this is a stub. Credentials flow, token refresh, and the real GraphQL calls are implemented in follow-up commits once we vendor or reimplement a community Rivian client in Go.

Index

Constants

View Source
const (
	PlaceHome = "home"
	PlaceAway = "away"
)

Values of charges.Charge.Place (migration 0044).

View Source
const (
	CostSourceRivian           = "rivian"
	CostSourceHomeRate         = "home_rate"
	CostSourceNetworkEstimate  = "network_estimate"
	CostSourceObservedEstimate = "observed_estimate"
	CostSourceUser             = "user"
)

Values of charges.Charge.CostSource (migration 0040).

View Source
const (
	SamplePackKWh  = samplePackKWh
	SampleMiPerKWh = sampleMiPerKWh
)

The sample truck's pack size and efficiency, for planners that model its energy use.

View Source
const (
	SamplePlanVegasTwoDay = "Las Vegas, 2 days"
	// SampleHotelLabel names the overnight stop; SampleHotelKW is its L2.
	SampleHotelLabel = "Las Vegas hotel (sample)"
	SampleHotelKW    = sampleHotelKW
	// SampleOvernightMaxSoC caps the overnight charge, as the owner's
	// charge limit did at the hotel.
	SampleOvernightMaxSoC = 90.0
)

The sample owner's two-day plan for the Las Vegas trip: the way out as day 1, a night at the hotel on its L2, and the way home as day 2.

View Source
const (
	// FieldReported: the vehicle sends it and we show it.
	FieldReported = "reported"
	// FieldEstimate: derived or best guess; the UI labels it "(estimate)".
	FieldEstimate = "estimate"
	// FieldBestEffort: usually there, can lag or be missing for a while.
	FieldBestEffort = "best_effort"
	// FieldUnavailable: not sent, or not confirmed; the UI hides it.
	FieldUnavailable = "unavailable"
)

Field support levels, as /api/vehicles reports them in field_support.

View Source
const DCFCMinEffectiveKW = 20.0

DCFCMinEffectiveKW is the energy-over-charging-time floor a session must also clear. Recorded peaks glitch: prod has 15-hour home L2 sessions with a single "145 kW" sample. Real DC fast charging averages well above any AC charger's ~11-19 kW.

View Source
const DCFCMinKW = 50.0

isDrivingGear is true for any non-park gear. Empty ("") is treated as parked so a missing/unknown value doesn't spuriously open a drive session on startup. DCFCMinKW is the session peak power at which a charge counts as DC fast charging — the same line analytics.DefaultParams draws for the Fast bucket (no AC L2 hardware exceeds ~22 kW).

View Source
const DCFCMinKWh = 1.0

DCFCMinKWh is the least energy a session must deliver to count as DC fast charging. A few-second fragment of a home session can carry a spurious high-kW sample, and over 4 s even 0.12 kWh averages ~110 kW (seen on preview), so the effective-power test alone can't reject it. A real fast-charging stop delivers tens of kWh; anything under 1 kWh isn't worth pricing as one.

View Source
const DefaultAccept = "application/json"

DefaultAccept stays application/json. The iOS multipart/mixed value makes the gateway @defer fields like gnssLocation into separate parts our single-shot json.Unmarshal can't reassemble.

View Source
const DefaultChargingEndpoint = "https://rivian.com/api/gql/chrg/user/graphql"

DefaultChargingEndpoint hosts getLiveSessionHistory and friends.

View Source
const DefaultClientName = "com.rivian.android.consumer"

DefaultClientName identifies us as the Android Owner App. The iOS client name triggers server-side @defer of gnssLocation on the WS subscription, which our single-shot frame parser drops.

View Source
const DefaultClientVersion = "3.6.0-3989"

DefaultClientVersion is paired with DefaultClientName; keep them in sync to avoid a never-shipped hybrid fingerprint.

View Source
const DefaultEndpoint = "https://rivian.com/api/gql/gateway/graphql"

DefaultEndpoint is the Rivian Owner App GraphQL gateway. Unofficial.

View Source
const DefaultPackKWh = 131.0

DefaultPackKWh is the usable capacity we fall back to when trim / model are unknown. It matches the R1T/R1S Gen 1 Large pack (131 kWh usable per Rivian spec) and is a reasonable default for Rivolt's current owner base.

View Source
const DefaultUserAgent = "RivianApp/4400 CFNetwork/1498.700.2 Darwin/23.6.0"

DefaultUserAgent matches the iOS app verbatim; the gateway 403s on modified UAs.

View Source
const FeatureConnectivityParallax = "VEHICLE_CONNECTIVITY_PARALLAX"

FeatureConnectivityParallax is the API `name` value gating whether a vehicle does Parallax connectivity at all. This is the right gate for cherry-picking individual Parallax topics (e.g. dynamics.vehicle.gnss for GPS): AVAILABLE means the topics stream, even when the full vehicle-state migration (PX_STATE_ALL) hasn't been enabled.

View Source
const FeatureParallaxVehicleState = "PX_STATE_ALL"

FeatureParallaxVehicleState is the API `name` value for the *full* Parallax vehicle-state migration — replacing legacy vehicleState with Parallax RVMs wholesale. The app models it as the enum PARALLAX_VEHICLE_STATE; the wire value is "PX_STATE_ALL". Not required for consuming a single topic (see FeatureConnectivityParallax): a vehicle streams dynamics.vehicle.gnss with this absent.

View Source
const FeatureStatusAvailable = "AVAILABLE"

FeatureStatusAvailable is the only supportedFeatures status the app treats as "on" (per UserVehicleKt.isFeatureSupported).

View Source
const HomeRadiusM = 250.0

HomeRadiusM is how far from the saved home location a charge can start and still count as charging at home. Shared with the ledger so the recorder's pricing and the Money tab's Home/Away agree.

View Source
const LiveStateSnapshotTTL = 90 * time.Second

LiveStateSnapshotTTL bounds how long a published live State snapshot lives in the shared store after the last refresh. Much shorter than LiveStateTTL because a snapshot is a live-view convenience, not durable session state: when the owning pod dies, the snapshot should age out within a couple of publish intervals so peer replicas fall back to REST rather than serving a stale frame indefinitely.

View Source
const LiveStateTTL = 2 * time.Hour

LiveStateTTL is how long a snapshot lingers in Redis after the last upsert. Sized to ~2× liveDriveMaxGap so a graceful pod-handoff window comfortably covers WS reconnect cadence; the recorder's own stale-session guard at liveDriveMaxGap will reject any rehydrated state whose endAt is clearly out of date.

View Source
const MaxDepartureAhead = 30 * 24 * time.Hour

MaxDepartureAhead is how far in the future a planned departure may be. The gateway is sent only departures a person could plausibly pick in the app, so a request for a far-future date is refused here rather than forwarded.

View Source
const MaxPlanWaypoints = 10

MaxPlanWaypoints is the gateway's limit on waypoints in one planTrip call, counting the origin and the destination.

View Source
const NetworkRivianAdventure = "Rivian Adventure Network"

NetworkRivianAdventure is the display name the ledger and the charge map use for the Rivian Adventure Network.

View Source
const SampleSeed int64 = 20261014

SampleSeed is the seed the demo uses.

Variables

View Source
var CaptureRVMs = []string{
	rvmTrailer,
	rvmLowVoltBatt, rvmOtaState, rvmAlarmState, rvmPreconStatus,
	rvmPetMode, rvmClimateHold, rvmImmobilizer,
}

CaptureRVMs are the read topics carried on the drive-dynamics subscription only under the preview capture flag, so prod isn't subscribed to them yet. alarm + 12V health are decoded from the APK enums and applied on preview (with the concurrent vehicleState value logged for verification); trailer + precon are decode-logged only (no State field / unconfirmed field number); ota_state is still on the raw default case. Graduate each into the base list above once the logs confirm the mapping (ota.deployment.state has: decodeOTADeployment).

View Source
var ErrInvalidTrip = errors.New("invalid trip")

ErrInvalidTrip marks a trip request the planner cannot accept (the number of waypoints, a missing starting SoC). The handlers answer it with a 400.

View Source
var ErrMFARequired = errors.New("rivian: MFA code required")

ErrMFARequired signals an OTP challenge; resubmit Login on the same client with Credentials.OTP populated.

View Source
var ErrNeedsReauth = errors.New("rivian: re-authentication required")

ErrNeedsReauth is returned when the per-user needs_reauth flag is set. Cleared on a successful Login.

View Source
var ErrNoFixture = errors.New("rivian: no stored plan matches this trip")

ErrNoFixture means no stored plan matches the request.

View Source
var ErrNoPlanner = errors.New("rivian: no trip planner on this install")

ErrNoPlanner means this replay install has no trip planner configured.

View Source
var ErrNotAuthenticated = errors.New("rivian: not authenticated; call Login first")

ErrNotAuthenticated means the caller invoked a data method before Login succeeded (or after the session was cleared).

View Source
var ErrNotImplemented = errors.New("rivian: client not implemented yet")

ErrNotImplemented is returned by the stub. Every method that hits the network returns this until we land the real client. Callers should treat ErrNotImplemented as "fine for boot, unusable at runtime."

View Source
var ErrReplayNotAllowed = errors.New("replay: demo vehicle not enabled for this account")

ErrReplayNotAllowed is returned by ReplayClient.Login for a user the install hasn't allowed to connect a demo vehicle.

View Source
var ErrUpstreamBreakerOpen = errors.New("rivian upstream breaker open")

ErrUpstreamBreakerOpen is returned by the upstream gate when the circuit breaker has tripped. It satisfies errors.Is for the same "don't bother retrying right now" semantics as ErrUpstreamPaused — the api layer maps both to a 503-with-Retry-After.

View Source
var ErrUpstreamPaused = errors.New("rivian: upstream paused by operator")

ErrUpstreamPaused is returned when the operator kill switch is on.

View Source
var ErrVehicleNotFound = errors.New("rivian: vehicle not found")

ErrVehicleNotFound means the caller asked for state on a vehicle ID the client does not know about.

View Source
var RecorderWriteFailureObserver func(kind string)

RecorderWriteFailureObserver, when set (once, at startup, before serving), is told about every failed recorder write by kind ("drive_upsert", "charge_upsert", "stale_charge"); main counts them. Never given an id.

View Source
var (
	// SampleHome is the sample truck's "home": the Rose Bowl Stadium's
	// public parking (Pasadena, CA). Non-residential by design.
	SampleHome = samplePoint{34.1613, -118.1676}
)

Places: public landmarks only.

View Source
var WSFrameDropObserver func()

WSFrameDropObserver, when set (once, at startup, before serving), is told each time a received "next" frame is dropped because its subscription's channel was full; main counts them. Never given an id.

Functions

func AwayFromHome added in v0.32.0

func AwayFromHome(lat, lon float64, home HomeLookup) bool

AwayFromHome reports whether (lat, lon) is known to be more than HomeRadiusM from the saved home location. False when either the home location or the position is unknown.

func BeginDrain added in v0.37.7

func BeginDrain(deadline time.Time)

BeginDrain starts the shutdown drain: from now on the per-vehicle flushes end at deadline.

func DemoVehicleID added in v0.32.0

func DemoVehicleID(uid uuid.UUID) string

DemoVehicleID is the rivian_vehicle_id of uid's demo vehicle. Unique per user: leases and owner resolution key on it install-wide.

func Describe added in v0.36.6

func Describe(err error) string

Describe renders an error for a log line or a stored last_error: for an upstream error only its classification (operation, HTTP status, class, the GraphQL code, our reason phrase), never the cause, which can carry a slice of a response body or Rivian's message text. Context and session errors keep their text; anything else is "error".

func EncodePolyline added in v0.33.0

func EncodePolyline(points [][2]float64) string

encodePolyline encodes a slice of (lat, lon) points using Google's polyline algorithm at precision 5 (~1.1m resolution). The result is the same format consumed by Leaflet's various polyline-decoding helpers and by Google Maps; see https://developers.google.com/maps/documentation/utilities/polylinealgorithm

We use it to persist a drive's GPS path on the drives row so the overview map can render a real on-road route instead of a straight line between start/end markers.

The input is expected to be an already-thinned trace (one point per recorder frame). At ~1 frame / few seconds and 5-decimal varint encoding this comes out to well under 10 KB even for multi-hour drives. EncodePolyline is encodePolyline for repair tools that rebuild a drive's route from stored samples.

func ErrCode added in v0.36.6

func ErrCode(err error) string

ErrCode returns the GraphQL extensions.code of an upstream error (a short identifier such as "FORBIDDEN"), or "". It is only ever the code, never the error message, which can carry response text.

func FieldSupport added in v0.37.0

func FieldSupport(model string) map[string]string

FieldSupport says, per model, which live fields can be trusted, so the UI labels and hides from one place instead of checking models. One table to edit when a field is confirmed on a model or an R2 software change moves it (the evidence is in scratchpad r2-verified-design.md). Unknown models are treated as R1s. The map is a fresh copy.

func InferPackKWh

func InferPackKWh(model, trimID string, modelYear int) float64

InferPackKWh maps a (model, trim-option-id, modelYear) tuple to the best-known USABLE battery capacity in kWh, to seed a truck until it reports its own (the vehicle's batteryCapacity replaces this on its first frame, and that is what drives and overnight charging use). The GraphQL API exposes model / modelYear / trimOption.optionId but NOT pack size. Unknown combinations return DefaultPackKWh.

The figures are usable, not gross. Rivian does not publish a usable figure; these are the numbers owners and the press measured and compiled (Rivian Forums "battery size Gen 1 vs Gen 2", InsideEVs on the Max pack, 2026-10):

           Gen 1 usable (gross)     Gen 2 usable
Standard   106 (105)                92.5
Standard+  121                      (92.5, named "Standard+" in some trim ids)
Large      131 (135)                109.4
Max        141 (149)                141.5

An earlier version had Max at 180 kWh, which is neither the usable (141) nor the gross (149) figure, and Gen 2 Large at 141.5 (that is the Max pack). Gen 1 Standard is 105: sources give 105 and 106 for it, and a usable figure cannot exceed the gross one, so the lower, consistent value is used.

The optionIds seen on real trucks (prod, 2026-10) are "PKG-LCH", "PKG-ADV", "PKG-ASC", "BLD-DR1T", "BLD-TR1S" and "BLD-QR1S": they do not encode the pack, so most trucks reach the model-only default below. For a Gen 2 R1 that default is the Max figure, and a Standard or Large Gen 2 owner sees it until the truck reports its own capacity. Pack-size tokens in the trim ("MAX", "LRG", "STD") win when present.

Model year >= 2025 or a trim prefix of "G2-" indicates Gen 2.

func InferPackKWhKnown added in v0.37.7

func InferPackKWhKnown(model, trimID string, modelYear int) (kwh float64, known bool)

InferPackKWhKnown is InferPackKWh plus whether the pack was actually identified: true when a pack token in the trim matched, or the trim is the R2's one seen on real trucks (BLD-PRF2); false for the model-only default and for an R2 trim not seen yet. Callers that show the figure as the truck's "spec" must use it, because a guessed default beside the truck's own reported capacity reads as a degradation that is not there (a Gen 2 Standard owner would see 141.5 kWh beside a reported ~92).

func IsAuthFlavoredReason added in v0.20.0

func IsAuthFlavoredReason(reason string) bool

IsAuthFlavoredReason reports whether a UserAction reason names a real auth problem (expired session, rejected credentials, MFA challenge, locked account) versus a generic 4xx like "HTTP 404" or "HTTP 422" that ClassifyHTTP's catch-all also routes through ClassUserAction. Used to gate needs_reauth: a stray 404 should not poison the user's session, but a 401 should.

Keep this aligned with the reasons emitted by classifyFromBody + statusReason for 401/403 + ClassifyGraphQL — every reason string any of those produce for ClassUserAction must appear here.

func IsChargingState added in v0.38.15

func IsChargingState(chargerState string) bool

IsChargingState reports whether a charger_state means energy is flowing (or the truck is waiting on the charger), the same test the recorder opens a charge on.

func IsDCFCSession added in v0.32.0

func IsDCFCSession(peakKW, energyKWh, activeSeconds float64, span time.Duration) bool

IsDCFCSession reports whether a charge was DC fast charging: peak at or above DCFCMinKW, at least DCFCMinKWh delivered, AND energy divided by active charging time (wall span when that's unknown) at or above DCFCMinEffectiveKW.

func IsNotAvailable added in v0.36.6

func IsNotAvailable(err error) bool

IsNotAvailable reports an upstream answer that says "not for this vehicle or account": a FORBIDDEN or not-found/unsupported code, or HTTP 403, 404, 405 or 501. It is an expected outcome (shared or authorized-driver accounts, a model without the feature), not a failure to retry daily.

func IsUpstream

func IsUpstream(err error, class ErrorClass) bool

IsUpstream reports whether err is an *UpstreamError with the given class. Convenience for call sites that only care about one class (e.g. "is this a user-action? flip needs_reauth").

func ParseBoolEnv added in v0.32.0

func ParseBoolEnv(v string) bool

ParseBoolEnv is parseBoolEnv for other packages' env flags (main's live-client guard), so every switch reads truthiness the same way.

func PlaceOf added in v0.32.0

func PlaceOf(lat, lon float64, home HomeLookup) string

PlaceOf classifies (lat, lon) against the saved home location: PlaceHome within HomeRadiusM, PlaceAway beyond it, "" when either the home location or the position is unknown.

func QSessionChargerForTest added in v0.36.4

func QSessionChargerForTest() string

QSessionChargerForTest exposes the detail document to the privacy test in internal/receipts.

func SampleTripPlanNames added in v0.34.0

func SampleTripPlanNames() []string

SampleTripPlanNames are the names of the sample owner's saved plans.

func SampleUserPrice added in v0.33.0

func SampleUserPrice(startedAt time.Time) (cost float64, currency string, ok bool)

SampleUserPrice reports whether the sample truck's charge that started at startedAt (wall clock, as the replay feed recorded it) is the one its owner priced by hand (the Las Vegas hotel's L2, a flat $15), so the demo seeder can apply that price through the normal user-pricing path after the backfill: a price only a person can enter, which the recorder must not invent. Deterministic: startedAt is mapped back onto the looping corpus like the replay feed maps it forward.

func WithPriority

func WithPriority(ctx context.Context) context.Context

WithPriority returns a derived context that, when reached by checkUpstream, draws from the priority token bucket instead of the main one. Use for Login, RefreshToken, and any other call the UI is blocked on.

Types

type Account

type Account interface {
	// Login accepts a first-leg (Email+Password) or second-leg
	// (OTP) set of credentials. Must return ErrMFARequired when the
	// server issued an MFA challenge.
	Login(ctx context.Context, c Credentials) error
	// Logout clears every authenticated-session field locally.
	Logout()
	// Authenticated reports whether a valid session exists.
	Authenticated() bool
	// MFAPending reports whether Login returned ErrMFARequired and
	// the client is waiting for an OTP. Survives page reloads.
	MFAPending() bool
	// Email returns the email the current session is authenticated
	// as, or "" if no session is active.
	Email() string
	// NeedsReauth reports whether a runtime classifier (errclass /
	// session expiry / WS auth failure) flagged the stored session
	// as no longer usable. The user must run a fresh Login (likely
	// with OTP) to clear it. The bool is the gate; the string is a
	// human-readable reason for surfacing in the UI.
	NeedsReauth() (bool, string)
	// Snapshot returns a copy of the current session. Persisted as
	// JSON by secrets.SaveRivianSession so a restart doesn't drop
	// the login.
	Snapshot() Session
	// Restore hydrates the client from a prior Snapshot. No I/O.
	Restore(s Session)
	// AuthReady returns a channel that is closed once the client
	// holds usable session material. Background workers (the WS
	// monitor in particular) select on it to avoid busy-looping
	// against an unauthenticated client until a user happens to
	// open the UI.
	//
	// On Logout the channel is replaced with a fresh open one, so
	// receivers that took the closed signal previously will not see
	// false positives after the next sign-in.
	AuthReady() <-chan struct{}
}

Account is the auth-surface shared by every Rivian Client variant that can drive the Settings sign-in UI. Both *LiveClient and the *MockClient implement it, which lets RIVIAN_CLIENT=mock exercise exactly the same /api/settings/rivian flow the real deployment uses — you sign in, maybe clear an MFA prompt, then vehicles show up. The stub client deliberately does NOT implement this because there's nothing to sign into.

type AccountRegistry

type AccountRegistry interface {
	// For returns the Account bound to userID, constructing one on
	// first sight. Returns nil only when the registry was wired
	// with a session-less client (rivian.Stub).
	For(userID uuid.UUID) Account
	// Loaded returns every userID currently held in the registry.
	// Used by metrics + the boot hydrator. Order is unspecified.
	Loaded() []uuid.UUID
}

AccountRegistry resolves a per-user Account. Each user owns an isolated *LiveClient (or *MockClient under RIVIAN_CLIENT=mock) so concurrent Login/Restore from different sessions cannot corrupt each other's tokens.

This replaces the singleton Account that v0.17.x carried at the top of api.Deps. The shared singleton was the root cause of two classes of bugs:

  1. rivianHydrateMW restoring the request user's session into a shared LiveClient — last-write-wins across concurrent users.
  2. Boot-time pods coming up with no session at all because no authenticated UI request had triggered hydration yet, leaving the StateMonitor goroutines busy-looping on ErrNotAuthenticated until someone opened the app.

Implementations must be safe for concurrent use; resolution is idempotent (the same userID returns the same Account instance for the lifetime of the registry).

func NewLiveAccountRegistry

func NewLiveAccountRegistry(build func(uuid.UUID) *LiveClient) AccountRegistry

NewLiveAccountRegistry returns a registry whose For(uid) calls produce a fresh *LiveClient on first sight by invoking build(uid).

build is the operator-supplied factory (typically a closure over rivian.NewLive().WithRivoltVersion(...).WithBreaker(...)... etc.) so every per-user client gets the same breaker/limiter/kill-switch wiring that the singleton in main.go used to receive. The uid argument lets the closure thread it through user-scoped sinks (e.g. WithReauthSink that persists needs_reauth keyed by user_id).

func NewMockAccountRegistry

func NewMockAccountRegistry(build func(uuid.UUID) *MockClient) AccountRegistry

NewMockAccountRegistry returns a registry that constructs a fresh *MockClient per user via build(uid). Used under RIVIAN_CLIENT=mock so the multi-user UI flow works in local dev without standing up real Rivian credentials.

func NewNopAccountRegistry

func NewNopAccountRegistry() AccountRegistry

NewNopAccountRegistry returns a registry that always returns nil. Used with the stub client.

func NewReplayAccountRegistry added in v0.32.0

func NewReplayAccountRegistry(feed *ReplayFeed, planner TripPlanner) AccountRegistry

NewReplayAccountRegistry returns a registry handing each user a ReplayClient over feed.

type BatteryCharacteristics added in v0.36.3

type BatteryCharacteristics struct {
	Chemistry    uint64  // 1: 1 NCA, 2 LFP, 3 NMC
	CellType     uint64  // 2: 1 50G, 2 53G, 3 G124, 4 LG_4695
	ModuleType   uint64  // 3: 1 9M, 2 1M, 3 8M, 4 6M, 5 11M, 6 7M, 7 3M, 8 10M
	PackCapacity uint64  // 4: see packCapacityKWh
	UserTotalKWh float32 // 5
	UserMaxKWh   float32 // 6
}

BatteryCharacteristics is one energy.high_voltage.battery_characteristics frame. The enums are logged raw; packCapacityKWh names the capacity one.

type BatteryTemp added in v0.27.0

type BatteryTemp struct {
	CellAvgC float64 `json:"cell_avg_c"`
	CellMaxC float64 `json:"cell_max_c"`
	CellMinC float64 `json:"cell_min_c"`
	// SoCPct (charge_state.chargePercentage, field 1.1) and PackKWh
	// (charge_state.pack_capacity, field 1.4) come from the same
	// battery_state message. Zero when the frame omits charge_state.
	SoCPct    float64 `json:"soc_pct"`
	PackKWh   float64 `json:"pack_kwh"`
	Timestamp string  `json:"timestamp"`
	RawB64    string  `json:"raw_b64"`
}

BatteryTemp holds decoded high-voltage pack cell temperatures (°C).

type BatteryTempCallback added in v0.27.0

type BatteryTempCallback func(*BatteryTemp)

BatteryTempCallback receives decoded pack temperatures per frame.

type Breaker

type Breaker struct {
	// contains filtered or unexported fields
}

Breaker is a sliding-window circuit breaker over Rivian's classified upstream errors. Construct one per LiveClient.

Concurrency: every method takes the mutex; the breaker is on the hot path of every Rivian call, so contention is bounded by the outbound concurrency to Rivian (small — single-digit RPS at the scale we're targeting).

func NewBreaker

func NewBreaker(cfg BreakerConfig, obs BreakerObserver) *Breaker

NewBreaker builds a breaker with the given config. Pass nil observer to skip metric/event emission. Zero-valued cfg fields are filled with DefaultBreakerConfig values.

func (*Breaker) Allow

func (b *Breaker) Allow(_ context.Context) error

Allow is the upstream-gate hook. Returns ErrUpstreamBreakerOpen when the breaker is open. In half-open state the very first caller is admitted as a probe and the rest are rejected; the probe's outcome (Observe) decides the next state.

func (*Breaker) Observe

func (b *Breaker) Observe(class ErrorClass)

Observe records the outcome of a Rivian call. Pass the ErrorClass returned by the classifier; success-equivalent outcomes (ClassUnknown after a 2xx, etc.) should call ObserveSuccess instead.

func (*Breaker) ObserveSuccess

func (b *Breaker) ObserveSuccess()

ObserveSuccess marks a successful call. In half-open state this closes the breaker and resets the cooldown.

func (*Breaker) State

func (b *Breaker) State() BreakerState

State returns the current breaker state. Lock-free read isn't worth the complexity at this call frequency; just take the lock.

type BreakerConfig

type BreakerConfig struct {
	// Window is the sliding time window over which failures are
	// counted. Failures older than now-Window are pruned on every
	// Observe call.
	Window time.Duration

	// RateLimitedThreshold is the number of ClassRateLimited
	// outcomes within Window that trips the breaker. 429s are the
	// most expensive failure mode — Rivian's throttle compounds
	// the longer we ignore it — so this is intentionally low.
	RateLimitedThreshold int

	// OutageThreshold is the number of ClassOutage outcomes within
	// Window that trips the breaker. Higher than rate-limited
	// because a single 5xx is often a deploy-window blip we'd
	// rather retry through.
	OutageThreshold int

	// Cooldown is how long the breaker stays open before
	// transitioning to half-open. Doubled (capped at MaxCooldown)
	// if a half-open probe fails.
	Cooldown time.Duration

	// MaxCooldown caps the exponential cooldown growth so a
	// permanently degraded upstream still gets retried regularly
	// instead of asymptotically never.
	MaxCooldown time.Duration

	// ProbeTimeout is how long a half-open probe may stay unreported before
	// another is admitted. A probe can be admitted and never come back (the call
	// was refused by a later gate, failed before it reached the network, or its
	// outcome was never reported), and without a deadline the breaker would stay
	// half-open, rejecting everything, for good. Defaults to Cooldown.
	ProbeTimeout time.Duration

	// Now is injectable for tests; defaults to time.Now.
	Now func() time.Time
}

BreakerConfig tunes the breaker's tripping thresholds.

func DefaultBreakerConfig

func DefaultBreakerConfig() BreakerConfig

DefaultBreakerConfig returns the production-ready defaults. Tuned for a single-region deployment against Rivian's gateway: 60s window, trip on 3 rate-limited or 8 outage events, 30s initial cooldown.

type BreakerObserver

type BreakerObserver interface {
	OnStateChange(from, to BreakerState)
	OnTrip(reason string) // "rate_limited" or "outage"
}

BreakerObserver receives state-change notifications. Used to drive Prometheus gauges/counters from main.go without coupling the breaker to the metrics package.

type BreakerState

type BreakerState int

BreakerState describes the breaker's gate behaviour.

BreakerClosed   — fully open gate, requests flow normally.
BreakerOpen     — gate closed, every Allow returns
                  ErrUpstreamBreakerOpen until the cooldown
                  expires.
BreakerHalfOpen — exactly one probe is allowed through; its
                  outcome decides whether to close or reopen
                  with a doubled cooldown.
const (
	BreakerClosed BreakerState = iota
	BreakerHalfOpen
	BreakerOpen
)

func (BreakerState) String

func (s BreakerState) String() string

type ChargeCloseHook

type ChargeCloseHook func(ctx context.Context, c charges.Charge)

ChargeCloseHook is invoked asynchronously after a charge session closes (terminal charger state, plug pulled, or stale-session reaper). ctx is bounded; implementations must be safe for concurrent calls across vehicles.

type ChargingFrame

type ChargingFrame struct {
	At        time.Time `json:"at"`
	VehicleID string    `json:"vehicle_id"`
	Event     string    `json:"event,omitempty"`
	Raw       string    `json:"raw,omitempty"`
	Err       string    `json:"err,omitempty"`
}

ChargingFrame is one raw ChargingSession push or lifecycle event. Event is set for lifecycle markers ("open", "error", "close"); Raw carries the JSON payload for push frames.

type ChargingSchedule added in v0.36.5

type ChargingSchedule struct {
	// StartMin is the window start in minutes after local midnight.
	StartMin int `json:"start_min"`
	// DurationMin is the window length in minutes (1440 = all day).
	DurationMin int  `json:"duration_min"`
	Enabled     bool `json:"enabled"`
	// WeekDays are the days the window starts on, "Monday".."Sunday".
	WeekDays []string `json:"weekdays"`
	// AmperageA is the charge current the schedule sets.
	AmperageA int `json:"amperage_a,omitempty"`
	// AppliesHere reports whether the truck was at the schedule's location
	// (within scheduleRadiusM) at the last read. Reads happen on connect,
	// daily, and when the truck is seen plugged in and not charging, so
	// it's current whenever that's the question. nil when either position
	// is unknown.
	AppliesHere *bool `json:"applies_here,omitempty"`
	// contains filtered or unexported fields
}

ChargingSchedule is one scheduled charging window.

type ChargingStatus added in v0.36.3

type ChargingStatus struct {
	Plug    uint64 // 1: 0 INIT, 1 NOT_CONNECTED, 2 CONNECTED, 3 ERROR, 4 V2L_CONNECTED
	Display uint64 // 2: 0 SNA, 1 READY … 25 SMART_CHARGING_CHARGING (chargerStateFromParallax)
	EVSE    uint64 // 3: evse_type, enum not pinned
}

ChargingStatus is one charging.session.status frame, validated against vehicleState's chargerStatus on live frames. Absent fields are 0, the proto default: plug INIT, display SNA.

type Client

type Client interface {
	Login(ctx context.Context, creds Credentials) error
	Vehicles(ctx context.Context) ([]Vehicle, error)
	State(ctx context.Context, vehicleID string) (*State, error)
}

Client is the high-level API surface Rivolt uses against Rivian. Concrete implementations live in live.go (real upstream) and mock.go (offline fixture for tests and demos).

type Credentials

type Credentials struct {
	Email    string
	Password string
	OTP      string // optional: populated only for the initial MFA challenge
}

Credentials is what the operator submits via Settings → Rivian account. Stored encrypted at rest; never logged.

type DCFCDetector added in v0.33.0

type DCFCDetector func(lat, lon float64, rivianCharger bool, peakKW float64) (v DCFCVerdict, on bool)

DCFCDetector detects and prices the network of a DC fast charge Rivian didn't bill, at the charge's position (where it opened). on=false means auto-detection is off for this user: the recorder falls back to the DCFCPriceLookup. rivianCharger is Rivian's isRivianCharger for this charge's own session; peakKW is the session's peak so far.

type DCFCEstimate added in v0.38.8

type DCFCEstimate struct {
	Rate     float64
	Currency string
	Network  string
	// Month and URL name the report the figure came from ("2026-10",
	// the dcfctracker.com page).
	Month, URL string
}

DCFCEstimate is a fast-charging session's price taken from a typical public price (cost_source "observed_estimate"): never the user's own rate, which always wins, and replaced by Rivian's bill when it arrives.

type DCFCEstimator added in v0.38.8

type DCFCEstimator func(slug string, lat, lon float64, start time.Time) (DCFCEstimate, bool)

DCFCEstimator returns a typical-price estimate for a fast charge that started at start, when the user has no rate for it. slug is the detected network's, or "" to find the network at (lat, lon). ok=false leaves the session unpriced. Wired for the live recorder only; `rivolt reprice` never sets one.

type DCFCPriceLookup added in v0.32.0

type DCFCPriceLookup func(lat, lon float64) (ratePerKWh float64, currency, network string, ok bool)

DCFCPriceLookup returns the user's $/kWh for a DC fast-charging session at (lat, lon), the currency, and the matched network's display name ("" when no station matched and a fallback rate was used). ok=false means no estimate is available.

type DCFCVerdict added in v0.33.0

type DCFCVerdict struct {
	Priced     bool
	Rate       float64
	Currency   string
	Network    string
	State      string
	Confidence float64
	Evidence   json.RawMessage
	// Slug is the matched network's slug ("" when nothing matched); the
	// recorder hands it to the DCFCEstimator when the user has no rate.
	Slug string
}

DCFCVerdict is a DC fast charge network detection, priced: the network, its rate for this user, and the verify state the charge row carries (contract dcfc-auto-network v1). Priced=false means nothing matched: the charge stays unpriced and needs the user.

type DriveCloseHook

type DriveCloseHook func(ctx context.Context, drv drives.Drive)

DriveCloseHook is invoked asynchronously after a D→P transition finishes upserting the drive row. ctx is bounded; the hook should honour it. Implementations must be safe for concurrent calls across vehicles.

type DriveDynamicsCallback added in v0.27.4

type DriveDynamicsCallback func(DriveDynamicsFrame)

DriveDynamicsCallback receives each decoded drive-dynamics frame.

type DriveDynamicsFrame added in v0.27.4

type DriveDynamicsFrame struct {
	RVM         string
	Value       uint64
	ValueOK     bool
	Payload     string
	TimestampMs int64
}

DriveDynamicsFrame is one shadow frame. Value is the field-1 varint (when ValueOK) — interpret per RVM (gear enum, drive_mode enum, odometer in whole km). Payload is the raw base64 so a not-yet-RE'd topic (e.g. power.state) can still be logged and decoded offline.

type DynamicsGNSS added in v0.27.0

type DynamicsGNSS struct {
	Latitude    float64 `json:"latitude"`
	Longitude   float64 `json:"longitude"`
	AltitudeM   float64 `json:"altitude_m"`
	SpeedMS     float64 `json:"speed_ms"`
	HeadingDeg  float64 `json:"heading_deg"`
	TimestampMs int64   `json:"timestamp_ms"`
}

DynamicsGNSS is a decoded dynamics.vehicle.gnss frame. Field numbers confirmed from a live frame (see parallax_test.go): lat/lon/alt are doubles, speed/heading floats, timestamp a varint (epoch ms). Fields 6-9 are GPS accuracy metrics we don't consume yet. speed (field 4) was absent (0) in the parked capture — mapped here, to be confirmed against a moving frame.

type DynamicsGNSSCallback added in v0.27.0

type DynamicsGNSSCallback func(*DynamicsGNSS)

DynamicsGNSSCallback receives a decoded gnss frame.

type ElevationLookup

type ElevationLookup interface {
	Lookup(lat, lon float64) (float64, bool)
}

ElevationLookup is the minimal contract the recorder needs from an elevation resolver. Returns the altitude in meters, or ok=false when the tile is not (yet) cached -- the recorder writes NULL in that case rather than blocking on a network fetch. Implemented by *elevation.Resolver; abstracted here so the rivian package stays dep-free at the type-graph level and tests can stub it trivially.

type ErrRateLimited

type ErrRateLimited struct{ RetryAfter time.Duration }

ErrRateLimited is returned by checkUpstream when the global upstream token bucket is empty. Carries the retry-after so the api layer can map it to a 503 Retry-After.

func (*ErrRateLimited) Error

func (e *ErrRateLimited) Error() string

type ErrorClass

type ErrorClass int

ErrorClass buckets every upstream failure into one of five categories. The classes drive very different downstream reactions: a Transient error should be retried with backoff; an Outage should be circuit-broken; RateLimited should back off globally; UserAction should raise the per-user needs_reauth flag and stop retrying for that user; Unknown is treated as Transient-with-a-logged-warning.

This enum is the single biggest determinant of support load at scale. Misclassifying UserAction as Transient means retrying a bad-password response until Rivian locks the account; misclassifying RateLimited as Transient means a reconnect storm that makes the throttle worse.

const (
	// ClassUnknown is the default for anything we haven't seen
	// enough of to pattern-match confidently. Callers should
	// treat it as Transient but log at WARN so repeated unknowns
	// become a signal to extend Classify.
	ClassUnknown ErrorClass = iota

	// ClassTransient is network flaps, 5xx from a healthy
	// upstream (503 during a deploy), context timeouts on a
	// single call. Retry with jittered backoff.
	ClassTransient

	// ClassOutage is sustained 5xx, DNS failure, connection
	// refused. Distinct from Transient only in degree; the
	// circuit breaker trips on repeated Outage within a window.
	ClassOutage

	// ClassRateLimited is 429 or a GraphQL error whose extension
	// code / message names a rate limit. Must never be retried
	// without honoring a global Redis token bucket.
	ClassRateLimited

	// ClassUserAction means the user has to do something before
	// their calls will succeed again — log back in, accept an
	// MFA challenge, or contact Rivian support. Retrying is
	// actively harmful: repeated bad-password attempts escalate
	// to account lockouts. Raises the per-user needs_reauth
	// flag and suppresses further calls for that user.
	ClassUserAction
)

func ClassifyGraphQL

func ClassifyGraphQL(extCode, message string) (ErrorClass, string)

ClassifyGraphQL maps the first GraphQL error in a 200-OK response envelope to an ErrorClass. The gateway will happily return HTTP 200 with errors in the body — a password reject during Login looks exactly like this.

func ClassifyHTTP

func ClassifyHTTP(status int, body string) (ErrorClass, string)

ClassifyHTTP maps an HTTP status + optional response body fragment to an ErrorClass. Body is inspected only when the status alone is ambiguous (400 can be either user-action or transient depending on message).

This is the narrow, pure-function seam so the classifier is trivially testable without a live gateway. doGraphQLAt calls it at one specific point in its pipeline.

func ClassifyNetwork

func ClassifyNetwork(err error) (ErrorClass, string)

ClassifyNetwork maps a Go-side error from http.Client.Do, json.Unmarshal, or ctx cancellation into an ErrorClass. Called when the pipeline fails before we have an HTTP status to look at.

func (ErrorClass) String

func (c ErrorClass) String() string

String returns a short label suitable for logs and metrics labels. Keep stable — dashboards and alerts key on these.

type FirstPlanner added in v0.37.0

type FirstPlanner []TripPlanner

FirstPlanner tries each planner in order and returns the first answer that is not ErrNoFixture (so a stored plan is used when one matches and the next planner otherwise). Any other error ends the search.

func (FirstPlanner) PlanTrip added in v0.37.0

func (f FirstPlanner) PlanTrip(ctx context.Context, in PlanTripInput) (*TripPlan, error)

PlanTrip implements TripPlanner.

type FixturePlanner added in v0.37.0

type FixturePlanner struct {

	// Now stands in for "now" when the input has no departure time.
	Now func() time.Time
	// contains filtered or unexported fields
}

FixturePlanner answers a trip with a stored plan when one matches: origin and destination each within 1 km of a fixture's, no intermediate stops, and a starting SoC within 5 points of the fixture's. The stored plan is moved to the requested departure, and its SoC values to the requested start, and it is marked Simulated. No match is ErrNoFixture. Deterministic: the only clock is Now, which tests pin.

func NewFixturePlanner added in v0.37.0

func NewFixturePlanner(fx []PlanFixture, now func() time.Time) *FixturePlanner

NewFixturePlanner wraps the fixtures. now may be nil (time.Now).

func (*FixturePlanner) PlanTrip added in v0.37.0

func (p *FixturePlanner) PlanTrip(_ context.Context, in PlanTripInput) (*TripPlan, error)

PlanTrip implements TripPlanner.

type FrameSource added in v0.32.0

type FrameSource interface {
	Stream(ctx context.Context, vehicleID string, emit func(st State, catchingUp bool)) error
}

FrameSource feeds a StateMonitor full-snapshot State frames in place of a live Rivian subscription (see NewSourceMonitor). Stream blocks until ctx is cancelled, calling emit once per frame in time order. catchingUp is true while the source is replaying frames whose time has already passed (backfill after a restart or first connect), so the monitor can hold back user-facing side effects such as push notifications for sessions that "closed" days ago. Stream returns ErrVehicleNotFound for a vehicle the source doesn't serve.

type GatedLiveStateStore added in v0.37.7

type GatedLiveStateStore struct {
	// contains filtered or unexported fields
}

GatedLiveStateStore wraps a LiveStateStore for a Redis that may not be reachable yet, or any more. While ready() is false, or for gatedBackoff after a failed call, it answers as if nothing were stored (Load: not found, writes: nil) without touching Redis; otherwise it delegates with a short timeout. This is what lets the store be installed at boot before Redis answers (issue #603) and start working the moment it does, with the monitors already running.

func NewGatedLiveStateStore added in v0.37.7

func NewGatedLiveStateStore(inner LiveStateStore, ready func() bool) *GatedLiveStateStore

NewGatedLiveStateStore gates inner behind ready (nil: always ready).

func (*GatedLiveStateStore) Delete added in v0.37.7

func (g *GatedLiveStateStore) Delete(ctx context.Context, vehicleID string) error

func (*GatedLiveStateStore) GetState added in v0.37.7

func (g *GatedLiveStateStore) GetState(ctx context.Context, vehicleID string) (*State, error)

func (*GatedLiveStateStore) Load added in v0.37.7

func (*GatedLiveStateStore) PutState added in v0.37.7

func (g *GatedLiveStateStore) PutState(ctx context.Context, vehicleID string, st *State, ttl time.Duration) error

func (*GatedLiveStateStore) Save added in v0.37.7

func (g *GatedLiveStateStore) Save(ctx context.Context, vehicleID string, snap LiveStateSnapshot, ttl time.Duration) error

type HomeLookup added in v0.32.0

type HomeLookup func() (lat, lon float64, ok bool)

HomeLookup returns the user's saved home location; ok=false when the user hasn't set one.

type LiveChargeSnapshot

type LiveChargeSnapshot struct {
	ID         string    `json:"id"`
	Number     int64     `json:"number"`
	StartedAt  time.Time `json:"started_at"`
	StartSoC   float64   `json:"start_soc"`
	Lat        float64   `json:"lat"`
	Lon        float64   `json:"lon"`
	MaxPower   float64   `json:"max_power"`
	EndAt      time.Time `json:"end_at"`
	EndSoC     float64   `json:"end_soc"`
	FinalState string    `json:"final_state"`
	// LastMeaningfulAt anchors the stale-session guard across restarts.
	// Older snapshots omit it; restore falls back to EndAt then.
	LastMeaningfulAt time.Time `json:"last_meaningful_at"`
	// LastProgressAt anchors it for a charge with no plug indicator;
	// zero (older snapshots) restores as LastMeaningfulAt.
	LastProgressAt time.Time `json:"last_progress_at,omitempty"`
	// ActiveSeconds carries the accumulated real charging time across a
	// pod handoff so the session duration doesn't reset when a peer
	// takes over mid-charge.
	ActiveSeconds float64 `json:"active_seconds"`
}

LiveChargeSnapshot mirrors liveCharge.

type LiveClient

type LiveClient struct {
	// contains filtered or unexported fields
}

LiveClient talks to the real Rivian Owner App GraphQL gateway. All exported methods are safe to call concurrently.

func NewLive

func NewLive() *LiveClient

NewLive returns a LiveClient with sane defaults.

func (*LiveClient) AuthReady

func (c *LiveClient) AuthReady() <-chan struct{}

AuthReady returns a channel closed once the client has a usable session. See Account.AuthReady. Replaced on Logout.

func (*LiveClient) Authenticated

func (c *LiveClient) Authenticated() bool

Authenticated reports whether a userSessionToken is set locally.

func (*LiveClient) ChargingFieldProbe

func (c *LiveClient) ChargingFieldProbe(ctx context.Context, field, vehicleID string) (map[string]any, error)

ChargingFieldProbe fires a deliberately malformed query for diagnostics.

func (*LiveClient) ChargingFieldProbeWithSelection

func (c *LiveClient) ChargingFieldProbeWithSelection(ctx context.Context, field, vehicleID, selection string) (map[string]any, error)

ChargingFieldProbeWithSelection probes a field with a custom selection set.

func (*LiveClient) ChargingSchedules added in v0.36.5

func (c *LiveClient) ChargingSchedules(ctx context.Context, vehicleID string) ([]ChargingSchedule, error)

ChargingSchedules reads the vehicle's charging schedules. An empty list is a valid answer (no schedule set).

func (*LiveClient) ChargingSchemaProbe

func (c *LiveClient) ChargingSchemaProbe(ctx context.Context) (map[string]any, error)

ChargingSchemaProbe runs an introspection query against the charging endpoint, returning __schema.queryType.fields for diagnostics.

func (*LiveClient) CompletedSessionSummaries added in v0.36.4

func (c *LiveClient) CompletedSessionSummaries(ctx context.Context) ([]SessionSummary, error)

CompletedSessionSummaries returns the user's completed charging sessions with what Rivian billed. Rows without a transaction id or a parseable window are dropped.

func (*LiveClient) Email

func (c *LiveClient) Email() string

Email returns the authenticated email, or "" if not logged in.

func (*LiveClient) IntrospectEnum added in v0.19.4

func (c *LiveClient) IntrospectEnum(ctx context.Context, typeName string) (map[string]any, error)

IntrospectEnum returns the enumValues of a named GraphQL enum type. Separate query from IntrospectInputType because Rivian's gateway validates __type subselections strictly: a query that asks for both `inputFields` and `enumValues` fails with GRAPHQL_VALIDATION_FAILED. Splitting them keeps each query valid against whatever shape the gateway exposes.

func (*LiveClient) IntrospectInputType

func (c *LiveClient) IntrospectInputType(ctx context.Context, typeName string) (map[string]any, error)

IntrospectInputType returns the field set of a named GraphQL input object via the gateway's __type introspection. Used to reverse-engineer the exact shape of inputs like CoordinatesInput that the iOS-app reverse-engineered docs got wrong (they conflated input/response shapes). Admin-only.

func (*LiveClient) LiveSession

func (c *LiveClient) LiveSession(ctx context.Context, vehicleID string) (*LiveSession, error)

LiveSession returns the in-progress charging session, or an inactive one.

func (*LiveClient) Login

func (c *LiveClient) Login(ctx context.Context, creds Credentials) error

Login performs the email/password → MFA dance. See ErrMFARequired for the two-call flow.

func (*LiveClient) Logout

func (c *LiveClient) Logout()

Logout clears session fields but keeps the CSRF token (it survives logout).

func (*LiveClient) MFAPending

func (c *LiveClient) MFAPending() bool

MFAPending reports whether Login is awaiting an OTP submission.

func (*LiveClient) NeedsReauth

func (c *LiveClient) NeedsReauth() (bool, string)

NeedsReauth reports the in-memory needs_reauth flag and reason.

func (*LiveClient) OTAUpdateDetails added in v0.36.5

func (c *LiveClient) OTAUpdateDetails(ctx context.Context, vehicleID string) (*OTAInfo, error)

OTAUpdateDetails returns the current and available OTA releases for a vehicle. A release with no version is dropped; a release-notes URL that is not a plain https link is dropped (it ends up behind an <a>).

func (*LiveClient) PendingSnapshot added in v0.23.1

func (c *LiveClient) PendingSnapshot() (PendingMFA, bool)

PendingSnapshot returns the current OTP challenge, or false when no OTP submission is awaited. IssuedAt is left zero for the persistence layer to stamp.

func (*LiveClient) PlanTrip

func (c *LiveClient) PlanTrip(ctx context.Context, in PlanTripInput) (*TripPlan, error)

PlanTrip runs the gateway's planTripWithMultiStop operation and returns a flattened, JSON-friendly TripPlan. Per-user — the authenticated session must own the vehicleId.

Schema source: jrgutier/rivian-python-client/src/rivian/schemas/gateway.graphql. The operation is `planTrip` (not `planTripMultiStop` — the docs at rivian-api.kaedenb.org described a non-existent or broken sibling), taking scalar origin+destination CoordinatesInput args (not a waypoints array), `bearing` (not `originBearing`), and required startingRangeMeters. driveMode/trailerProfile/avoidAdapterRequired/ supportedConnectorTypes don't exist on this schema at all.

func (*LiveClient) PlanTripRaw

func (c *LiveClient) PlanTripRaw(ctx context.Context, variables map[string]any) (map[string]any, error)

PlanTripRaw posts the planTripWithMultiStop operation with a caller-supplied variables payload and returns the gateway's response data + errors verbatim. Bypasses the typed PlanTrip path so we can reverse-engineer schema/value mismatches without round- tripping through chart bumps. Admin-only (the wrapping HTTP handler enforces this) — gives the operator full control over every field in the request, including ones the typed path defaults.

Returns the parsed JSON `data` field on success. On a GraphQL `errors` envelope, returns the structured error from doGraphQLAt's classification (which preserves extension codes / messages) so callers can read the failure reason directly.

func (*LiveClient) ProbeBatteryTemperature added in v0.27.0

func (c *LiveClient) ProbeBatteryTemperature(ctx context.Context, vehicleID string) (*BatteryTemp, error)

ProbeBatteryTemperature opens a short-lived Parallax subscription for vehicleID, waits for the first battery_state frame, decodes the pack cell temperatures, and returns them. Proof-of-concept: it reuses the existing subscription websocket mux + auth, so no new transport.

func (*LiveClient) ProbeParallaxRaw added in v0.27.0

func (c *LiveClient) ProbeParallaxRaw(ctx context.Context, vehicleID string, rvms []string, maxFrames int) ([]ParallaxRawFrame, error)

ProbeParallaxRaw subscribes to the given RVM topics and returns up to maxFrames raw frames, or whatever arrived before the timeout. The vehicle must be awake (and, for dynamics.vehicle.gnss, moving) for frames to stream.

func (*LiveClient) RawGraphQL

func (c *LiveClient) RawGraphQL(ctx context.Context, operationName, query string, variables map[string]any) (map[string]any, error)

RawGraphQL posts an arbitrary operation+query+variables to the gateway and returns the response data verbatim. Admin-only debugging primitive for trying different operation names, response selection sets, etc. without round-tripping through chart bumps.

func (*LiveClient) RecentActions added in v0.32.0

func (c *LiveClient) RecentActions(vehicleID string, from, to time.Time) []RivoltAction

RecentActions returns the vehicle's (and account-level) requests in [from, to], oldest first.

func (*LiveClient) RecentChargingFrames

func (c *LiveClient) RecentChargingFrames(vehicleID string) []ChargingFrame

RecentChargingFrames returns a copy of the ring buffer, most recent last. Filter by vehicleID if non-empty.

func (*LiveClient) RecordChargingEvent

func (c *LiveClient) RecordChargingEvent(vehicleID, event, errMsg string)

RecordChargingEvent appends a lifecycle marker to the ring buffer.

func (*LiveClient) RecordChargingFrame

func (c *LiveClient) RecordChargingFrame(vehicleID string, raw []byte)

RecordChargingFrame appends a raw frame to the ring buffer.

func (*LiveClient) RefreshSession added in v0.24.5

func (c *LiveClient) RefreshSession(ctx context.Context) error

RefreshSession forces a fresh a-sess/csrf pair and then probes the account with a real authenticated call (getUserInfo via Vehicles). It's the manual counterpart to the auto-heal in doGraphQL: an admin can trigger it for a user stuck on needs_reauth to see whether the session is salvageable (stale csrf) or genuinely dead (expired u-sess, which no refresh can fix). Returns nil when the probe succeeds — the caller then clears needs_reauth.

func (*LiveClient) Restore

func (c *LiveClient) Restore(s Session)

Restore hydrates the client from a Snapshot. No network I/O.

func (*LiveClient) RestorePending added in v0.23.1

func (c *LiveClient) RestorePending(p PendingMFA)

RestorePending re-arms an OTP challenge captured by PendingSnapshot on another pod so this client can complete the second leg.

func (*LiveClient) SessionCharger added in v0.36.4

func (c *LiveClient) SessionCharger(ctx context.Context, transactionID string) (*SessionCharger, error)

SessionCharger returns the charger name, vendor and isHomeCharger for a transaction, or nil when Rivian has no charger for it.

func (*LiveClient) SetNeedsReauth

func (c *LiveClient) SetNeedsReauth(needs bool, reason string)

SetNeedsReauth sets the flag without firing the sink. Used to hydrate from storage at startup; runtime classification goes through doGraphQLAt.

func (*LiveClient) Snapshot

func (c *LiveClient) Snapshot() Session

Snapshot returns the current Session, or zero if not authenticated.

func (*LiveClient) State

func (c *LiveClient) State(ctx context.Context, vehicleID string) (*State, error)

State returns the current vehicle snapshot. Battery %, distances km, temps °C.

func (*LiveClient) StateRaw

func (c *LiveClient) StateRaw(ctx context.Context, vehicleID string) (map[string]any, error)

StateRaw returns the decoded vehicleState as generic JSON for debugging.

func (*LiveClient) SubscribeBatteryState added in v0.27.0

func (c *LiveClient) SubscribeBatteryState(ctx context.Context, vehicleID string, cb BatteryTempCallback) error

SubscribeBatteryState streams the energy.high_voltage.battery_state Parallax topic for vehicleID, invoking cb with decoded pack cell temperatures on every frame. Blocks until ctx is cancelled or auth fails; reconnects with exponential backoff on transient errors. The topic only pushes while the vehicle is awake.

This is the additive-Parallax pattern (see docs on the pack-temp migration): vehicleState stays the primary source, and a Parallax topic is layered on for a field it lacks. The next topic Rivolt wants (cold-weather SOC, richer thermal, …) follows this same shape.

func (*LiveClient) SubscribeChargingSession

func (c *LiveClient) SubscribeChargingSession(ctx context.Context, vehicleID string, cb LiveSessionCallback) error

SubscribeChargingSession streams charging telemetry for vehicleID. Blocks until ctx is cancelled or auth fails; reconnects with exponential backoff on transient errors. Preferred over the REST LiveSession poll — this path carries home AC / L2 data.

func (*LiveClient) SubscribeDriveDynamics added in v0.27.4

func (c *LiveClient) SubscribeDriveDynamics(ctx context.Context, vehicleID string, extra func() []string, cb DriveDynamicsCallback) error

SubscribeDriveDynamics streams the Parallax dynamics.vehicle.{gear, drive_mode,odometer} topics over one multiplexed subscription, invoking cb per decoded frame. Blocks until ctx is cancelled or auth fails; reconnects with backoff. Phase 2 measurement — run in shadow alongside vehicleState to pin the gear enum and compare cadence before any field is made authoritative.

extra, if non-nil, is called before every (re)connect for the extra topics to carry, so a change (wake-safety capture switch) takes effect at the next reconnect without a forced one.

func (*LiveClient) SubscribeDynamicsGNSS added in v0.27.0

func (c *LiveClient) SubscribeDynamicsGNSS(ctx context.Context, vehicleID string, cb DynamicsGNSSCallback) error

SubscribeDynamicsGNSS streams the dynamics.vehicle.gnss Parallax topic for vehicleID, invoking cb with each decoded frame. Blocks until ctx is cancelled or auth fails; reconnects with backoff. Measurement step of the driving-stats migration — run alongside vehicleState to compare GPS cadence/accuracy before flipping the recorder.

func (*LiveClient) SubscribeParallaxCharging

func (c *LiveClient) SubscribeParallaxCharging(ctx context.Context, vehicleID string, cb LiveSessionCallback) error

SubscribeParallaxCharging streams charge-session-breakdown RVM messages for vehicleID. Blocks until ctx is cancelled or auth fails; reconnects with exponential backoff on transient errors. This is the path that carries home-AC / L1 / L2 live power + energy data (the regular ChargingSession subscription returns nulls for those).

func (*LiveClient) SubscribeVehicleState

func (c *LiveClient) SubscribeVehicleState(ctx context.Context, vehicleID string, cb StateCallback) error

SubscribeVehicleState opens a websocket to Rivian's subscription gateway, subscribes to the named vehicle's state, and invokes cb with every push. Blocks until ctx is cancelled or the connection fails terminally (unauthenticated, context deadline, etc.). Reconnects with exponential backoff on transient errors.

The caller owns the lifetime via ctx; there is no separate unsubscribe — ctx cancellation tears everything down cleanly.

func (*LiveClient) SubscribeVehicleStateHeld added in v0.32.0

func (c *LiveClient) SubscribeVehicleStateHeld(ctx context.Context, vehicleID string, hold ReconnectHold, cb StateCallback) error

SubscribeVehicleStateHeld is SubscribeVehicleState with a hold (wake-safety switch (c), see wake_safety.go). nil hold is SubscribeVehicleState.

func (*LiveClient) SupportedFeatures added in v0.27.0

func (c *LiveClient) SupportedFeatures(ctx context.Context) (map[string]map[string]string, error)

SupportedFeatures returns the capability map for every vehicle on the authenticated account: vehicleID -> (feature name -> status). Backed by the same gateway endpoint as vehicleState. Callers typically look up one vehicle and one feature (see VehicleSupportsParallaxState).

func (*LiveClient) VehicleImages

func (c *LiveClient) VehicleImages(ctx context.Context) ([]VehicleImage, error)

VehicleImages returns the configurator-rendered mobile images for every vehicle on the account. Filter on VehicleID client-side. Returns an empty slice (not an error) if Rivian has no images yet.

func (*LiveClient) VehicleSupportsParallax added in v0.27.0

func (c *LiveClient) VehicleSupportsParallax(ctx context.Context, vehicleID string) (bool, error)

VehicleSupportsParallax reports whether the given vehicle can stream Parallax topics at all (VEHICLE_CONNECTIVITY_PARALLAX = AVAILABLE) — the gate for cherry-picking individual topics like the gnss GPS feed. This is deliberately NOT the full-migration PX_STATE_ALL capability. A vehicle missing from the response, or reporting any other status, returns false.

func (*LiveClient) Vehicles

func (c *LiveClient) Vehicles(ctx context.Context) ([]Vehicle, error)

Vehicles lists the vehicles on the authenticated account.

func (*LiveClient) WithBreaker

func (c *LiveClient) WithBreaker(b *Breaker) *LiveClient

WithBreaker attaches a circuit breaker. Every classified outcome from doGraphQLAt is reported to b; when b is open, checkUpstream returns ErrUpstreamBreakerOpen before we hit the network.

func (*LiveClient) WithEndpoint

func (c *LiveClient) WithEndpoint(url string) *LiveClient

WithEndpoint points the client at an alternate GraphQL URL.

func (*LiveClient) WithHTTPClient

func (c *LiveClient) WithHTTPClient(h *http.Client) *LiveClient

WithHTTPClient installs a custom *http.Client.

func (*LiveClient) WithRateLimit

func (c *LiveClient) WithRateLimit(rl RateLimiter) *LiveClient

WithRateLimit attaches a global token-bucket gate. Calls without an explicit class go through the "main" class; the account-aware paths (Login, RefreshToken, getUserInformation) can be promoted by the caller via doGraphQLPriority.

func (*LiveClient) WithReauthSink

func (c *LiveClient) WithReauthSink(sink func(ctx context.Context, reason string)) *LiveClient

WithReauthSink installs a callback fired on ClassUserAction errors.

func (*LiveClient) WithResultObserver added in v0.36.15

func (c *LiveClient) WithResultObserver(o ResultObserver) *LiveClient

WithResultObserver attaches o (nil to remove).

func (*LiveClient) WithRivoltVersion

func (c *LiveClient) WithRivoltVersion(v string) *LiveClient

WithRivoltVersion stamps the build version into X-Rivolt-Version.

func (*LiveClient) WithUpstreamGate

func (c *LiveClient) WithUpstreamGate(gate func(ctx context.Context) error) *LiveClient

WithUpstreamGate installs a pre-flight hook that short-circuits outbound calls when it returns non-nil. Pass nil to remove.

type LiveDrive

type LiveDrive struct {
	VehicleID       string    `json:"vehicle_id"`
	Number          int64     `json:"number"`
	StartedAt       time.Time `json:"started_at"`
	EndedAt         time.Time `json:"ended_at"`
	ElapsedSec      float64   `json:"elapsed_sec"`
	StartSoCPct     float64   `json:"start_soc_pct"`
	EndSoCPct       float64   `json:"end_soc_pct"`
	SoCUsedPct      float64   `json:"soc_used_pct"`
	StartOdometerMi float64   `json:"start_odometer_mi"`
	EndOdometerMi   float64   `json:"end_odometer_mi"`
	DistanceMi      float64   `json:"distance_mi"`
	MaxSpeedMph     float64   `json:"max_speed_mph"`
	AvgSpeedMph     float64   `json:"avg_speed_mph"`
	EnergyUsedKWh   float64   `json:"energy_used_kwh"`
	MiPerKWh        float64   `json:"mi_per_kwh"`
	PackKWh         float64   `json:"pack_kwh"`
}

LiveDrive is the wire-friendly snapshot of an in-flight drive session — the recorder's internal `liveDrive` accumulator projected into the same style that /api/live-session returns for charges. Returned by ActiveDrive; fields are flat so the frontend can render without having to reach into recorder internals.

type LiveDriveSnapshot

type LiveDriveSnapshot struct {
	ID         string       `json:"id"`
	Number     int64        `json:"number"`
	StartedAt  time.Time    `json:"started_at"`
	StartSoC   float64      `json:"start_soc"`
	StartOdoMi float64      `json:"start_odo_mi"`
	StartLat   float64      `json:"start_lat"`
	StartLon   float64      `json:"start_lon"`
	MaxSpeed   float64      `json:"max_speed"`
	SumSpeed   float64      `json:"sum_speed"`
	SpeedN     int          `json:"speed_n"`
	EndAt      time.Time    `json:"end_at"`
	EndSoC     float64      `json:"end_soc"`
	EndOdoMi   float64      `json:"end_odo_mi"`
	EndLat     float64      `json:"end_lat"`
	EndLon     float64      `json:"end_lon"`
	Path       [][2]float64 `json:"path,omitempty"`
}

LiveDriveSnapshot mirrors liveDrive.

type LiveSession

type LiveSession struct {
	At                       time.Time `json:"at"`
	VehicleID                string    `json:"vehicle_id"`
	Active                   bool      `json:"active"`
	VehicleChargerState      string    `json:"vehicle_charger_state"`
	StartTime                string    `json:"start_time"`
	TimeElapsedSeconds       int64     `json:"time_elapsed_seconds"`
	TimeRemainingSeconds     int64     `json:"time_remaining_seconds"`
	PowerKW                  float64   `json:"power_kw"`
	KilometersChargedPerHour float64   `json:"kilometers_charged_per_hour"`
	RangeAddedKm             float64   `json:"range_added_km"`
	TotalChargedEnergyKWh    float64   `json:"total_charged_energy_kwh"`
	// PackKWh / ThermalKWh / OutletsKWh / SystemKWh come only from the
	// Parallax ChargingSessionLiveData breakdown. The regular
	// ChargingSession subscription leaves them at zero. Together they
	// approximate where the wall energy went: into the pack, into
	// thermal management, into 12V outlets / accessories, and into
	// other vehicle systems. ThermalKWh is the closest thing Rivian
	// gives us to a "battery temperature" signal — high values during
	// a session mean the BMS is working hard to heat or cool the pack.
	PackKWh         float64 `json:"pack_kwh"`
	ThermalKWh      float64 `json:"thermal_kwh"`
	OutletsKWh      float64 `json:"outlets_kwh"`
	SystemKWh       float64 `json:"system_kwh"`
	SoCPct          float64 `json:"soc_pct"`
	CurrentPrice    string  `json:"current_price"`
	CurrentCurrency string  `json:"current_currency"`
	IsFreeSession   bool    `json:"is_free_session"`
	IsRivianCharger bool    `json:"is_rivian_charger"`
	// RivianChargerKnown is true when a source reported isRivianCharger
	// (only the REST getLiveSessionHistory fetch does), so a false
	// IsRivianCharger means "another network", not "not reported".
	RivianChargerKnown bool `json:"rivian_charger_known,omitempty"`
	// Network is the display name of the charging network billing the
	// session (e.g. "Rivian Adventure Network") when a source knows it.
	// Rivian's live-session APIs carry no network name, so today only
	// the sample corpus sets it; empty means unknown.
	Network string `json:"network,omitempty"`
}

LiveSession is the snapshot of an in-progress charging session, pulled from Rivian's `chrg/user/graphql` endpoint via getLiveSessionData. All zero/empty when no session is active. Units: power in kW, energy in kWh, rate in km/h, SoC in percent, time fields in whole seconds.

type LiveSessionCallback

type LiveSessionCallback func(*LiveSession)

LiveSessionCallback fires on every ChargingSession push. Delivered sessions are marked Active — the subscription only emits while live.

type LiveStateSnapshot

type LiveStateSnapshot struct {
	DriveCounter  int64               `json:"drive_counter"`
	ChargeCounter int64               `json:"charge_counter"`
	Drive         *LiveDriveSnapshot  `json:"drive,omitempty"`
	Charge        *LiveChargeSnapshot `json:"charge,omitempty"`
}

LiveStateSnapshot is the wire format for a vehicle's open drive/charge accumulator. Fields mirror liveSessions / liveDrive / liveCharge with explicit JSON shapes so the format stays legible and is stable across process versions.

type LiveStateStore

type LiveStateStore interface {
	Save(ctx context.Context, vehicleID string, snap LiveStateSnapshot, ttl time.Duration) error
	Load(ctx context.Context, vehicleID string) (LiveStateSnapshot, bool, error)
	Delete(ctx context.Context, vehicleID string) error

	// PutState publishes the vehicle's latest merged live State so a
	// replica that doesn't own the WS lease can serve a complete
	// /api/state (including subscription-only / Parallax-only fields)
	// instead of a REST fallback that omits them. Short-TTL and
	// refreshed by the owner on a timer, so a dead owner's snapshot
	// ages out and readers fall back to REST.
	PutState(ctx context.Context, vehicleID string, st *State, ttl time.Duration) error
	// GetState returns the latest published live State for the vehicle,
	// or (nil, nil) when none exists (the normal case on the owner pod,
	// which never reads its own snapshot).
	GetState(ctx context.Context, vehicleID string) (*State, error)
}

LiveStateStore persists per-vehicle in-flight session accumulators across pod restarts and lease handoffs. The recorder upserts the snapshot after every WS-driven frame and rehydrates on first contact for a vehicle, so a freshly elected lease owner picks up the open drive instead of opening a new one and fragmenting the trip.

Save/Load are best-effort: storage failures degrade to fragmentation but must never break the recorder hot path. Implementations must be safe for concurrent use across vehicles.

type MockClient

type MockClient struct {
	LoginReturnErr error // if non-nil, Login returns this error
	// contains filtered or unexported fields
}

MockClient is a Client implementation backed by in-memory fixture data. It exists so the UI can be demoed without Rivian credentials and so handler-level tests can drive a predictable, offline Client.

The defaults model a stationary Rivian R1T at ~72% SoC. Callers can swap the state out via SetState before serving requests.

MockClient also implements Account — the same sign-in/MFA/logout surface LiveClient exposes — so the UI flow under RIVIAN_CLIENT=mock is identical to production. Any email + password combo succeeds, except:

  • email containing "mfa" triggers an MFA challenge that any non-empty OTP clears; this exercises the two-leg flow locally.
  • email containing "fail" or "bad" rejects the password so the error state is reachable without tripping real Rivian.

Snapshots round-trip through JSON so secrets.SaveRivianSession can persist them; on restart Restore brings the mock back to its authenticated state, matching the live client's behaviour.

func NewMock

func NewMock() *MockClient

NewMock returns a MockClient primed with one fake vehicle. The client starts logged-out so the Settings UI shows the sign-in panel — call Login with any email+password (or Restore a Session) to unlock Vehicles/State.

func (*MockClient) AuthReady

func (c *MockClient) AuthReady() <-chan struct{}

AuthReady returns a channel closed when the mock has a logged-in session. Replaced by Logout so callers see a fresh signal after the next sign-in.

func (*MockClient) Authenticated

func (c *MockClient) Authenticated() bool

Authenticated reports whether the mock client has completed a successful Login (or been Restored from a persisted session).

func (*MockClient) Email

func (c *MockClient) Email() string

Email returns the email the current session is authenticated as.

func (*MockClient) Login

func (c *MockClient) Login(_ context.Context, cr Credentials) error

Login drives the two-leg auth dance. Returns LoginReturnErr if set so tests can exercise failure paths without patching transport.

func (*MockClient) Logout

func (c *MockClient) Logout()

Logout clears every authenticated-session field.

func (*MockClient) MFAPending

func (c *MockClient) MFAPending() bool

MFAPending reports whether Login returned ErrMFARequired and the client is waiting for an OTP submission.

func (*MockClient) NeedsReauth

func (c *MockClient) NeedsReauth() (bool, string)

NeedsReauth is always (false, "") for the mock client — the mock has no concept of server-side session expiry, and tests that exercise the needs_reauth UI path drive the LiveClient directly.

func (*MockClient) Restore

func (c *MockClient) Restore(s Session)

Restore hydrates the mock from a prior Snapshot.

func (*MockClient) SetState

func (c *MockClient) SetState(vehicleID string, s State)

SetState replaces the fixture state for a vehicle ID. The timestamp is always refreshed to time.Now() so sequential polls look "live".

func (*MockClient) SetVehicles

func (c *MockClient) SetVehicles(vs []Vehicle)

SetVehicles overwrites the fixture vehicle list. Useful for tests that want to assert list-rendering or multi-vehicle pickers.

func (*MockClient) Snapshot

func (c *MockClient) Snapshot() Session

Snapshot returns a serialisable copy of the current session so secrets.SaveRivianSession can round-trip it through JSON. The mock uses UserSessionToken purely as the "we are logged in" sentinel — the contents are not real tokens.

func (*MockClient) State

func (c *MockClient) State(_ context.Context, vehicleID string) (*State, error)

State returns the fixture snapshot. The timestamp is bumped to now on every call so consumers that rely on "recency" work.

func (*MockClient) Vehicles

func (c *MockClient) Vehicles(_ context.Context) ([]Vehicle, error)

Vehicles returns the fixture list. Requires a prior Login call so tests that forget auth fail obviously.

type MonitorRegistry

type MonitorRegistry struct {
	// contains filtered or unexported fields
}

MonitorRegistry holds one *StateMonitor per user. Each monitor is wired to that user's *LiveClient and that user's per-user data-plane stores (drives/charges/samples/settings), so the recorder writes land under the correct user_id without any global "current user" assumption.

Lifecycle:

  • Start(ctx, uid) is idempotent. It builds and starts a monitor on first call for that uid, no-ops on later calls.
  • Stop(uid) cancels that user's monitor ctx and removes the entry. Used on logout / explicit reauth-required transitions.
  • For(uid) returns the monitor (or nil if not started). Request handlers use this for /api/state, /api/live-session, etc.
  • EnsureSubscribed/Unsubscribe route by rivian_vehicle_id → vehicle owner uid → that user's monitor. The lease coordinator stays user-agnostic; this registry resolves ownership at dispatch time.
  • AllVehicleInfo unions every monitor's known vehicles so the coordinator's vehicleSource sees one flat set.

The registry records for live accounts and for replay accounts (RIVIAN_CLIENT=replay, source-fed monitors). Mock and stub paths don't have a recorder.

func NewMonitorRegistry

func NewMonitorRegistry(
	pool *sql.DB,
	accounts AccountRegistry,
	drivesF *drives.Factory,
	chargesF *charges.Factory,
	samplesF *samples.Factory,
	settingsF *settings.Factory,
	logger *slog.Logger,
) *MonitorRegistry

NewMonitorRegistry constructs a registry. Start(ctx, uid) is what actually launches per-user monitors; the parent ctx passed there (typically the server ctx) bounds every monitor's lifetime.

func (*MonitorRegistry) AddChargeCloseHookFactory added in v0.36.4

func (r *MonitorRegistry) AddChargeCloseHookFactory(factory func(uid uuid.UUID) ChargeCloseHook)

AddChargeCloseHookFactory registers a further charge-close side effect next to the one SetChargeCloseHookFactory holds (push notifications), so independent features can each react to a closed charge. Every registered hook runs, in order registered, for each close.

func (*MonitorRegistry) AddDriveCloseHookFactory added in v0.37.5

func (r *MonitorRegistry) AddDriveCloseHookFactory(factory func(uid uuid.UUID) DriveCloseHook)

AddDriveCloseHookFactory registers a further drive-close side effect next to the one SetDriveCloseHookFactory holds (weather), the way AddChargeCloseHookFactory does for charges. Every registered hook runs, in order registered, for each close.

func (*MonitorRegistry) AllVehicleInfo

func (r *MonitorRegistry) AllVehicleInfo() []Vehicle

AllVehicleInfo unions every running monitor's vehicle set. Used by the lease coordinator's vehicleSource so it sees one flat list across all users.

func (*MonitorRegistry) EnsureSubscribed

func (r *MonitorRegistry) EnsureSubscribed(rivianVehicleID string)

EnsureSubscribed routes the rivian_vehicle_id to the owning user's monitor. Owner is resolved via vehicles.user_id; if the owner isn't tracked (no monitor running for that uid) the call is a no-op with a debug log — the lease will get re-evaluated once the owner signs in.

func (*MonitorRegistry) FlushPending added in v0.37.7

func (r *MonitorRegistry) FlushPending(ctx context.Context) int

FlushPending flushes every monitor the registry runs.

func (*MonitorRegistry) For

func (r *MonitorRegistry) For(uid uuid.UUID) *StateMonitor

For returns the monitor for uid, or nil if not started. Safe for concurrent reads.

func (*MonitorRegistry) SetBatteryCapacityHookFactory added in v0.22.1

func (r *MonitorRegistry) SetBatteryCapacityHookFactory(factory func(uid uuid.UUID) func(vehicleID string, kwh float64))

SetBatteryCapacityHookFactory wires a per-user write-through hook for the live-reported usable pack capacity. Each new monitor gets factory(uid) and installs it via SetBatteryCapacityHook; the hook runs whenever Rivian's vehicleState.batteryCapacity changes for one of the user's vehicles. Typically wires through to a SQL UPDATE on vehicles.pack_kwh so a future restart sees the right nameplate without having to wait for another live observation.

func (*MonitorRegistry) SetChargeCloseHookFactory

func (r *MonitorRegistry) SetChargeCloseHookFactory(factory func(uid uuid.UUID) ChargeCloseHook)

SetChargeCloseHookFactory mirrors SetDriveCloseHookFactory for charge sessions. Used to fire per-user notifications and similar side-effects on charge close.

func (*MonitorRegistry) SetDCFCDetectorFactory added in v0.33.0

func (r *MonitorRegistry) SetDCFCDetectorFactory(factory func(uid uuid.UUID) DCFCDetector)

SetDCFCDetectorFactory wires per-user fast-charge network detection (on only where the dcfc_auto_network flag covers the user; the detector itself says when it's off). Safe to call at boot before any Start.

func (*MonitorRegistry) SetDCFCEstimatorFactory added in v0.38.8

func (r *MonitorRegistry) SetDCFCEstimatorFactory(factory func(uid uuid.UUID) DCFCEstimator)

SetDCFCEstimatorFactory wires the per-user typical-price fallback for fast charges the user has no rate for. Live recorder only. Safe to call at boot before any Start.

func (*MonitorRegistry) SetDCFCPriceLookupFactory added in v0.32.0

func (r *MonitorRegistry) SetDCFCPriceLookupFactory(factory func(uid uuid.UUID) DCFCPriceLookup)

SetDCFCPriceLookupFactory wires a per-user fast-charge pricer (the user's network memberships pick the rate). Safe to call at boot before any Start.

func (*MonitorRegistry) SetDriveCloseHookFactory

func (r *MonitorRegistry) SetDriveCloseHookFactory(factory func(uid uuid.UUID) DriveCloseHook)

SetDriveCloseHookFactory wires a per-user DriveCloseHook factory. On each new monitor spawn, factory(uid) is called and the result (may be nil) is handed to the StateMonitor so the recorder can run async post-close enrichment (e.g. weather fetch) as drives are recorded — instead of deferring to a manual SPA backfill. Safe to call at boot before any Start.

func (*MonitorRegistry) SetElevationLookup

func (r *MonitorRegistry) SetElevationLookup(e ElevationLookup)

SetElevationLookup wires an optional elevation resolver shared across every per-user monitor the registry spawns. Safe to call at boot before any Start, idempotent thereafter (the resolver itself is stateless w.r.t. user identity -- altitude is a pure function of lat/lon, not a per-user query).

func (*MonitorRegistry) SetLiveStateStoreFactory

func (r *MonitorRegistry) SetLiveStateStoreFactory(factory func(uid uuid.UUID) LiveStateStore)

SetLiveStateStoreFactory wires a per-user LiveStateStore factory. On each new monitor spawn, factory(uid) is called and the result (may be nil) is handed to the StateMonitor so the recorder can rehydrate / persist its in-flight session accumulators across pod restarts and lease handoffs. Safe to call at boot before any Start.

func (*MonitorRegistry) SetParent

func (r *MonitorRegistry) SetParent(ctx context.Context)

SetParent records the outer ctx all per-user monitors derive their lifetime from. Must be called once at boot before Start.

func (*MonitorRegistry) SetRouteFiller

func (r *MonitorRegistry) SetRouteFiller(rf RouteFiller)

SetRouteFiller wires the optional GPS-gap fill backend shared across every per-user monitor. Stateless w.r.t. user identity, so one client serves all users.

func (*MonitorRegistry) SetSessionRehydrator added in v0.23.3

func (r *MonitorRegistry) SetSessionRehydrator(fn func(ctx context.Context, uid uuid.UUID) bool)

SetSessionRehydrator wires a callback that loads a user's persisted Rivian session from shared storage and restores it into their cached per-pod client, returning true when the client ends up authenticated. The lease coordinator can hand this pod a vehicle whose owner signed in on a peer pod, so the cached client here predates the session and reports "not authenticated" — the monitor would then wait on AuthReady forever and record nothing. EnsureSubscribed calls this to self-heal without waiting for a pod restart's boot hydrate. Returns false (and the subscribe stays a no-op) when no session is stored.

func (*MonitorRegistry) SetSleepObserver added in v0.31.2

func (r *MonitorRegistry) SetSleepObserver(o SleepObserver)

SetSleepObserver installs the sleep metrics hook handed to every monitor started afterwards.

func (*MonitorRegistry) SetSleepSafe added in v0.31.2

func (r *MonitorRegistry) SetSleepSafe(fn func(vehicleID string) bool)

SetSleepSafe installs the per-vehicle sleep-safe check handed to every monitor started afterwards (see StateMonitor.sleepGate).

func (*MonitorRegistry) SetVehicleInfoHookFactory added in v0.36.4

func (r *MonitorRegistry) SetVehicleInfoHookFactory(factory func(uid uuid.UUID) func([]Vehicle))

SetVehicleInfoHookFactory wires the per-user persister for the vehicle metadata (trim, image, pack guess) each monitor fetches in RefreshVehicleInfo.

func (*MonitorRegistry) SetWakeSafety added in v0.32.0

func (r *MonitorRegistry) SetWakeSafety(fn func(vehicleID string) WakeSafety)

SetWakeSafety installs the wake-safety switch lookup handed to every monitor started afterwards (see wake_safety.go).

func (*MonitorRegistry) Start

func (r *MonitorRegistry) Start(ctx context.Context, uid uuid.UUID) *StateMonitor

Start launches a monitor for uid if one is not already running. Returns the monitor (existing or new). Returns nil when the account is neither a LiveClient nor an authenticated FrameSource (mock/stub paths shouldn't be calling Start; this is a defensive guard).

func (*MonitorRegistry) Stop

func (r *MonitorRegistry) Stop(uid uuid.UUID)

Stop tears down the monitor for uid. Idempotent.

func (*MonitorRegistry) Streaming added in v0.33.0

func (r *MonitorRegistry) Streaming(rivianVehicleID string) bool

Streaming reports whether this pod runs a subscription for the vehicle on any of its monitors (including one parked waiting for auth or re-auth). The lease coordinator re-subscribes a leased vehicle that isn't streaming (SetStreamingCheck). Any monitor, not just the current owner's: if the owner resolution changed (users sharing a VIN), the old owner's run still counts, so the heal can't start a second subscription beside it. No database lookup: it runs inside every reconcile.

func (*MonitorRegistry) Unsubscribe

func (r *MonitorRegistry) Unsubscribe(rivianVehicleID string)

Unsubscribe tears the vehicle's subscription down on every monitor that runs it, not only its current owner's. After an owner flip (users sharing a VIN) the old owner's run is still the one streaming; routing by owner cancelled the new owner's (a no-op) and left the old one streaming on a pod that no longer holds the lease: a cross-pod duplicate. Unsubscribe on a monitor that doesn't run it is a no-op. No database lookup.

type NetworkPreference

type NetworkPreference struct {
	NetworkID  string
	Preference int
}

NetworkPreference biases the planner toward or away from a charging network. Preference is an integer rank: lower = more preferred.

type OTAInfo added in v0.36.5

type OTAInfo struct {
	Current   *OTAVersion `json:"current,omitempty"`
	Available *OTAVersion `json:"available,omitempty"`
	// Status is the live otaStatus from the vehicle state (idle,
	// downloading, ready_to_install, ...), filled when served, never stored.
	Status string `json:"status,omitempty"`
	// FetchedAt is when Rivian was last asked.
	FetchedAt time.Time `json:"fetched_at"`
}

OTAInfo is what /api/vehicles reports under "ota".

type OTASource added in v0.36.5

type OTASource interface {
	OTAUpdateDetails(ctx context.Context, vehicleID string) (*OTAInfo, error)
}

OTASource is what the OTA sync needs from a Rivian account; *LiveClient implements it.

type OTAVersion added in v0.36.5

type OTAVersion struct {
	Version         string `json:"version"`
	ReleaseNotesURL string `json:"release_notes_url,omitempty"`
	Locale          string `json:"locale,omitempty"`
}

OTAVersion is one software release: its version string, the release-notes page Rivian publishes for it and the page's locale. ReleaseNotesURL is an outbound link for the UI to show; Rivolt never fetches it.

type ParallaxRawFrame added in v0.27.0

type ParallaxRawFrame struct {
	RVM        string `json:"rvm"`
	Timestamp  string `json:"timestamp"`
	PayloadB64 string `json:"payload_b64"`
}

ParallaxRawFrame is one captured Parallax message: the RVM topic, its server timestamp, and the raw base64 protobuf payload. Used to reverse-engineer a topic's wire shape from live frames before writing a decoder (the reliable path: field layouts are confirmed against real frames, not assumed).

type ParallaxReplayObserver added in v0.33.0

type ParallaxReplayObserver interface {
	OnParallaxReplayIgnored(topic string)
}

ParallaxReplayObserver is optionally implemented by the SleepObserver (main's metrics adapter): told each time a replayed or stale Parallax value is ignored instead of applied, per topic.

type PendingMFA added in v0.23.1

type PendingMFA struct {
	Email           string    `json:"email"`
	OTPToken        string    `json:"otp_token"`
	CSRFToken       string    `json:"csrf_token"`
	AppSessionToken string    `json:"app_session_token"`
	IssuedAt        time.Time `json:"issued_at"`
}

PendingMFA is the in-flight OTP challenge, captured so a different pod than the one that ran the password leg can finish the second leg. The OTP submission replays the same csrf/appSession tokens and the server-issued otpToken; a pod that never saw the password leg has none of these in memory, so without sharing them the OTP lands on a pod that reports "no challenge in flight".

type PlanFixture added in v0.37.0

type PlanFixture struct {
	Name        string
	Origin      [2]float64 // lat, lon
	Destination [2]float64
	StartSoC    float64
	Plan        TripPlan
}

PlanFixture is one stored plan and the trip it answers.

type PlanTripInput

type PlanTripInput struct {
	VehicleID               string
	StartingSoC             float64
	StartingRangeMeters     float64 // 0 → derived from SoC by PlanTrip
	OriginBearing           float64 // degrees from true north; pass 0 if unknown
	Waypoints               []PlanTripWaypoint
	TargetArrivalSocPercent *float64
	NetworkPreferences      []NetworkPreference
	// DriveMode is one of "CONSERVE" / "SPORT" / "ALL_PURPOSE".
	// Empty means "let the planner pick its default" (typically
	// ALL_PURPOSE). CONSERVE produces materially different routes
	// (more stops, longer legs at lower speeds) so this is a real
	// user-facing knob.
	DriveMode string
	// HasAdapter signals whether the vehicle has the Tesla NACS
	// adapter. Pointer so we can distinguish "unset" (rely on
	// account defaults) from "explicitly false" (block any
	// adapter-required Supercharger). Default unset.
	HasAdapter *bool
	// Fields kept on the input for SPA-side compat / future schemas;
	// not forwarded to Rivian's current planTrip schema.
	TrailerProfile          string
	AvoidAdapterRequired    bool
	SupportedConnectorTypes []string
	// RoutePreferences asks the planner to avoid tolls, ferries or highways.
	// nil means no preference, and then the request does not mention it.
	RoutePreferences *RoutePreferences
	// DepartureTime is when the trip leaves; the zero value means "now".
	// The fixture and synthetic planners time their waypoints from it.
	DepartureTime time.Time
}

PlanTripInput is the request shape for PlanTrip. Mirrors the arguments accepted by the Rivian gateway's planTripWithMultiStop operation. All required fields are non-pointer; optional fields (e.g. TargetArrivalSocPercent, TrailerProfile) are pointers so the zero value is distinguishable from "not provided".

Waypoints[0] is the origin, Waypoints[len-1] is the destination. Slice 1 of the planner only supports those two; intermediate stops aren't part of this schema's planTrip operation. The gateway computes charging stops between origin and destination.

type PlanTripWaypoint

type PlanTripWaypoint struct {
	Latitude     float64
	Longitude    float64
	WaypointType string
	EntityID     string
}

PlanTripWaypoint is one input waypoint. waypointType is one of "origin", "destination", or "waypoint" (intermediate stop). EntityID is optional and used when the user picked a specific POI from a places search; the gateway uses it as a hint.

type PlannedWaypoint

type PlannedWaypoint struct {
	WaypointType             string
	EntityID                 string
	Name                     string
	Latitude                 float64
	Longitude                float64
	MaxPowerKW               float64
	ChargeDurationSec        int
	ArrivalSoC               float64
	DepartureSoC             float64
	ArrivalReachableMeters   float64
	DepartureReachableMeters float64
	AdapterRequired          bool
	// UTC timestamps from Rivian's planner, computed from the request
	// time as departure. Empty string when Rivian omitted them.
	ArrivalTimeUTC   string
	DepartureTimeUTC string
	// DistanceFromOriginMeters is Rivian's cumulative distance from
	// the route origin to this waypoint. Powers the "180 mi from
	// origin · +120 mi from prev" columns in the SPA stops table.
	// Zero on legacy response shapes that don't include the field.
	DistanceFromOriginMeters float64
}

PlannedWaypoint is one stop on the planned route. Origin and destination entries appear with WaypointType="origin"/"destination"; every other entry is a charger pick.

type PriceLookup

type PriceLookup func() (ratePerKWh float64, currency string)

PriceLookup returns the current home electricity rate and its ISO-4217 currency code. Rate of 0 means "not configured"; callers should leave the persisted cost at zero in that case so it doesn't show up as a misleading $0.00 in history.

type RateLimiter

type RateLimiter interface {
	Allow(ctx context.Context, class string) (bool, time.Duration)
}

RateLimiter is the slice of ratelimit.Limiter the rivian package uses. Lives here as an interface to keep this package import-free of the limiter implementation (which depends on go-redis); main.go does the wiring.

Allow returns (true, 0) on grant and (false, retryAfter) when out of budget. retryAfter feeds ErrRateLimited so callers can build a Retry-After response.

type ReceiptSource added in v0.36.4

type ReceiptSource interface {
	CompletedSessionSummaries(ctx context.Context) ([]SessionSummary, error)
	SessionCharger(ctx context.Context, transactionID string) (*SessionCharger, error)
}

ReceiptSource is what the receipts sync needs from a Rivian account. *LiveClient implements it.

type ReconnectHold added in v0.32.0

type ReconnectHold func(ctx context.Context) (held bool, err error)

ReconnectHold is consulted before a subscription the server ended is reopened. It may block; it returns held = true if it waited (the reopen then happens at once, without the backoff wait), and ctx's error if ctx ended while it waited.

type RedisLiveStateStore

type RedisLiveStateStore struct {
	// contains filtered or unexported fields
}

RedisLiveStateStore is the production LiveStateStore. Keys are scoped by user so the same Redis cluster is shared across tenants without cross-tenant reads.

func NewRedisLiveStateStore

func NewRedisLiveStateStore(rdb *redis.Client, userID, keyPrefix string) *RedisLiveStateStore

NewRedisLiveStateStore binds rdb to the given userID. Pass the same *redis.Client constructed at boot for the rate limiter. keyPrefix is the install-wide Redis prefix (typically "rivolt" for prod, "rivolt-preview" for the preview env) — the livestate suffix is appended internally so two environments sharing one Redis still own disjoint keyspaces.

func (*RedisLiveStateStore) Delete

func (s *RedisLiveStateStore) Delete(ctx context.Context, vehicleID string) error

Delete drops the snapshot for vehicleID. Used after a normal session close so a future P → D transition can't accidentally rehydrate a closed drive on top of the new one.

func (*RedisLiveStateStore) GetState added in v0.27.2

func (s *RedisLiveStateStore) GetState(ctx context.Context, vehicleID string) (*State, error)

GetState reads the published live State for vehicleID. Returns (nil, nil) when no snapshot exists (owner hasn't published, or it aged out) so callers cleanly fall through to their REST path.

func (*RedisLiveStateStore) Load

Load reads the snapshot for vehicleID. The bool is false (with nil error) when no snapshot exists — the normal first-contact case.

func (*RedisLiveStateStore) PutState added in v0.27.2

func (s *RedisLiveStateStore) PutState(ctx context.Context, vehicleID string, st *State, ttl time.Duration) error

PutState serialises st and writes it under the per-user/vehicle state key with a short expiry. A zero ttl falls back to LiveStateSnapshotTTL.

func (*RedisLiveStateStore) Save

func (s *RedisLiveStateStore) Save(ctx context.Context, vehicleID string, snap LiveStateSnapshot, ttl time.Duration) error

Save serialises snap and writes it under the per-user/vehicle key with an expiry. A zero ttl falls back to LiveStateTTL.

type ReplayClient added in v0.32.0

type ReplayClient struct {
	// contains filtered or unexported fields
}

ReplayClient is the RIVIAN_CLIENT=replay Account: any sign-in succeeds and yields one demo vehicle per user that streams the shared corpus through the recorder. It never opens a network connection.

func NewReplayClient added in v0.32.0

func NewReplayClient(feed *ReplayFeed, uid uuid.UUID) *ReplayClient

NewReplayClient returns uid's logged-out replay account.

func (*ReplayClient) AuthReady added in v0.32.0

func (c *ReplayClient) AuthReady() <-chan struct{}

func (*ReplayClient) Authenticated added in v0.32.0

func (c *ReplayClient) Authenticated() bool

func (*ReplayClient) Email added in v0.32.0

func (c *ReplayClient) Email() string

func (*ReplayClient) Login added in v0.32.0

func (c *ReplayClient) Login(_ context.Context, cr Credentials) error

Login accepts any non-empty email and password; there is no MFA leg.

func (*ReplayClient) Logout added in v0.32.0

func (c *ReplayClient) Logout()

Logout clears the session.

func (*ReplayClient) MFAPending added in v0.32.0

func (c *ReplayClient) MFAPending() bool

func (*ReplayClient) NeedsReauth added in v0.32.0

func (c *ReplayClient) NeedsReauth() (bool, string)

func (*ReplayClient) PlanTrip added in v0.37.0

func (c *ReplayClient) PlanTrip(ctx context.Context, in PlanTripInput) (*TripPlan, error)

PlanTrip implements TripPlanner for the replay client from its planner: no network, no Rivian. Without one it answers ErrNoPlanner.

func (*ReplayClient) Restore added in v0.32.0

func (c *ReplayClient) Restore(s Session)

Restore re-authenticates from a replay Snapshot and ignores any other session (notably a real Rivian one persisted before the install switched to replay mode).

func (*ReplayClient) Snapshot added in v0.32.0

func (c *ReplayClient) Snapshot() Session

func (*ReplayClient) State added in v0.32.0

func (c *ReplayClient) State(ctx context.Context, vehicleID string) (*State, error)

State returns what the demo vehicle is "reporting" right now — the REST-fallback read for a replica that doesn't own its lease.

func (*ReplayClient) Stream added in v0.32.0

func (c *ReplayClient) Stream(ctx context.Context, vehicleID string, emit func(st State, catchingUp bool)) error

Stream implements FrameSource for the demo vehicle only. Any other id — e.g. a real vehicle the same user owns, including the corpus vehicle itself — gets ErrVehicleNotFound, so replay can never write into it.

func (*ReplayClient) Vehicles added in v0.32.0

func (c *ReplayClient) Vehicles(context.Context) ([]Vehicle, error)

Vehicles returns the user's demo vehicle.

type ReplayCorpus added in v0.32.0

type ReplayCorpus interface {
	// Frames returns the frames in [from, to), oldest first.
	Frames(ctx context.Context, from, to time.Time) ([]State, error)
	// LastAtOrBefore returns the newest frame at or before t, or nil.
	LastAtOrBefore(ctx context.Context, t time.Time) (*State, error)
}

ReplayCorpus is a time-ordered store of recorded frames that a demo vehicle replays. Times are the corpus's own (recorded) times.

type ReplayFeed added in v0.32.0

type ReplayFeed struct {
	// contains filtered or unexported fields
}

ReplayFeed plays a recorded corpus as if it were a live vehicle, time-shifted so the corpus's "now" is the wall clock's now. It is the RIVIAN_CLIENT=replay data source: preview gets moving demo vehicles through the real recorder without any Rivian connection.

func NewDBReplayFeed added in v0.32.0

func NewDBReplayFeed(ctx context.Context, pool *sql.DB, sourceVehicleID string, from, to time.Time, backfill time.Duration, logger *slog.Logger) (*ReplayFeed, error)

NewDBReplayFeed builds a feed that replays the recorded vehicle_state of the vehicle whose Rivian id is sourceVehicleID, restricted to [from, to) when either is non-zero. Demo vehicles resume from their own latest sample in the same database, falling back to backfill ago.

func NewSampleReplayFeed added in v0.32.0

func NewSampleReplayFeed(pool *sql.DB, backfill time.Duration, logger *slog.Logger) *ReplayFeed

NewSampleReplayFeed is the replay feed over the sample corpus (RIVIAN_CLIENT=replay with RIVOLT_REPLAY_SOURCE=sample). pool, when set, provides the resume point (the demo vehicle's latest sample).

func (*ReplayFeed) SetAllowedUsers added in v0.32.0

func (f *ReplayFeed) SetAllowedUsers(allow func(uid uuid.UUID) bool)

SetAllowedUsers restricts demo vehicles to the given users. nil (the default) allows everyone.

func (*ReplayFeed) Stream added in v0.32.0

func (f *ReplayFeed) Stream(ctx context.Context, vehicleID string, emit func(st State, catchingUp bool)) error

Stream replays frames for vehicleID from where its recording left off (or backfill ago, whichever is later) up to now as fast as the recorder accepts them, then keeps pace with the wall clock.

type Replayer added in v0.30.4

type Replayer struct {
	// contains filtered or unexported fields
}

Replayer feeds a captured sequence of State frames through the real recorder lifecycle with persistence disabled, collecting the drives it would have produced. It lets recorder/lifecycle changes be regression- tested against real (DB-exported) or hand-authored frame streams offline — no database and no live Rivian feed (which preview can no longer provide; see docs/REPLAY_HARNESS.md and the preview-rivian-disconnected note).

Frames are full State snapshots (the shape a vehicle_state row exports to), fed in arrival order — NOT deltas — so each frame is applied as-is, exactly as the merged cache state the live callback hands the recorder.

func NewReplayer added in v0.30.4

func NewReplayer(vehicleID string) *Replayer

NewReplayer builds a persistence-free monitor for one vehicle id. All stores are left nil, so the recorder's writes are no-ops (the SetStores contract) and only the in-memory lifecycle runs; closed drives are captured via the drive-close hook.

func (*Replayer) Charges added in v0.33.0

func (r *Replayer) Charges() []charges.Charge

Charges returns the charge sessions the replay closed, after the same settle wait as Drives (the close hook also runs in a goroutine).

func (*Replayer) Drives added in v0.30.4

func (r *Replayer) Drives() []drives.Drive

Drives returns the drives the replay produced. The recorder fires the close hook in a goroutine, so this waits for the collection to go quiet (no new drive for a short settle window) before reading it back — safe to call once after the last Feed.

func (*Replayer) Feed added in v0.30.4

func (r *Replayer) Feed(f State)

Feed pushes one frame through the recorder exactly as the live WS callback would after merging: it becomes the current cache state and drives the lifecycle. VehicleID is stamped automatically.

func (*Replayer) FeedAll added in v0.30.4

func (r *Replayer) FeedAll(frames []State)

FeedAll replays a whole sequence in order.

type ResultObserver added in v0.36.15

type ResultObserver interface {
	OnResult(op, class string)
}

ResultObserver receives the outcome of every call that reached Rivian's GraphQL gateway: op is the operation name (a fixed set), class is "success" or the ErrorClass label (transient, outage, rate_limited, user_action, unknown). main.go maps it onto rivolt_rivian_results_total, which the auth-failure alert keys on. Calls stopped before the network (kill switch, breaker open, rate limit) and our own cancellations are not reported.

type RivoltAction added in v0.32.0

type RivoltAction struct {
	At     time.Time
	Kind   string // "subscribe" | "unsubscribe" | "request"
	Op     string // GraphQL operation name
	Detail string // subscription topics, if any
}

RivoltAction is one request Rivolt made to Rivian.

func (RivoltAction) String added in v0.32.0

func (a RivoltAction) String() string

type RouteFiller

type RouteFiller interface {
	RouteShape(ctx context.Context, from, to [2]float64) ([][2]float64, error)
}

RouteFiller fills GPS-lag gaps in a live drive with a routing-engine shape between the last good fix and the next one. Returned slice includes both endpoints. Implemented by *maps.Valhalla; abstracted so the rivian package doesn't depend on internal/maps.

type RoutePreferences added in v0.37.0

type RoutePreferences struct {
	AvoidTolls    bool
	AvoidFerries  bool
	AvoidHighways bool
}

RoutePreferences are the road types a trip should avoid.

func (*RoutePreferences) Any added in v0.37.0

func (p *RoutePreferences) Any() bool

Any reports whether at least one preference is set.

type SampleCorpus added in v0.32.0

type SampleCorpus struct {
	// contains filtered or unexported fields
}

SampleCorpus is the generated corpus.

func NewSampleCorpus added in v0.32.0

func NewSampleCorpus(seed int64) *SampleCorpus

NewSampleCorpus plans the corpus for seed.

The corpus starts and ends the same way, at home on a weekday morning, plugged in at 80 % with the car awake shortly before leaving, so the replay's wrap from one loop to the next (sampleLoopGap) reads as just another morning, not a jump.

func (*SampleCorpus) Frames added in v0.32.0

func (c *SampleCorpus) Frames(_ context.Context, from, to time.Time) ([]State, error)

Frames implements ReplayCorpus: frames in [from, to), oldest first.

func (*SampleCorpus) LastAtOrBefore added in v0.32.0

func (c *SampleCorpus) LastAtOrBefore(_ context.Context, t time.Time) (*State, error)

LastAtOrBefore implements ReplayCorpus.

func (*SampleCorpus) Summary added in v0.32.0

func (c *SampleCorpus) Summary() SampleSummary

Summary describes the corpus.

func (*SampleCorpus) WriteJSONL added in v0.32.0

func (c *SampleCorpus) WriteJSONL(w io.Writer) error

WriteJSONL writes every frame as one replay-format JSON line.

type SamplePlace added in v0.34.0

type SamplePlace struct {
	Lat, Lon float64
	Label    string
}

SamplePlace is a named point of a sample plan.

type SampleSummary added in v0.32.0

type SampleSummary struct {
	Frames          int
	Drives          int
	HomeL2          int
	TopUps          int
	DCFC            int
	AwayAC          int
	FailedSleeps    int
	RoadTripMiles   []float64
	MinSoC, MaxSoC  float64
	Start, End      time.Time
	OdometerStartMi float64
	OdometerEndMi   float64
}

SampleSummary describes what a corpus contains, for tests and the CLI.

type SampleTripPlan added in v0.34.0

type SampleTripPlan struct {
	Name                string
	Origin, Destination SamplePlace
	DepartAt            time.Time // planned departure
	SavedAt             time.Time // when the owner saved it
	StartSoC            float64
	TargetSoC           float64
	PackKWh             float64 // the sample truck's, for pricing the plan
	// OutingStart and OutingEnd bound the whole road trip (home to
	// home) the plan's leg belongs to, on this loop.
	OutingStart, OutingEnd time.Time
	Plan                   TripPlan
}

SampleTripPlan is one of the sample owner's saved plans, placed on the wall clock for one loop of the replay.

func SampleTripPlans added in v0.34.0

func SampleTripPlans(now, since time.Time) []SampleTripPlan

SampleTripPlans returns the sample owner's saved plans for the latest sample road trip that is over by now (its outing back home at or before now) and started at or after since, or nil when there is none. A trip's plans stay on that loop until the next loop's trip is over, so a plan never points at an outing still under way; since keeps it off a trip recorded only in part.

func SampleTwoDayPlan added in v0.37.0

func SampleTwoDayPlan(now, since time.Time) []SampleTripPlan

SampleTwoDayPlan returns the two days of the sample owner's two-day Las Vegas plan, placed on the same loop as SampleTripPlans' (nil when there is none). Day 1 is the way-out plan. Day 2 is the way home as a multi-day plan chains it: it leaves with the charge the hotel's L2 puts in overnight (SampleOvernightMaxSoC; the stay allows far more), not with what the truck had left after a day of driving around town.

type Session

type Session struct {
	Email            string    `json:"email"`
	CSRFToken        string    `json:"csrf_token"`
	AppSessionToken  string    `json:"app_session_token"`
	UserSessionToken string    `json:"user_session_token"`
	AccessToken      string    `json:"access_token"`
	RefreshToken     string    `json:"refresh_token"`
	AuthenticatedAt  time.Time `json:"authenticated_at"`
}

Session is the persistable subset of LiveClient state.

type SessionCharger added in v0.36.4

type SessionCharger struct {
	Name   string
	Vendor string
	// IsHome is Rivian's isHomeCharger; nil when not reported.
	IsHome *bool
}

SessionCharger is the little the detail call is allowed to return.

type SessionSummary added in v0.36.4

type SessionSummary struct {
	TransactionID string
	StartedAt     time.Time
	EndedAt       time.Time
	// TotalCost is what Rivian billed; nil when the session has no cost
	// (the app shows nothing for it). Currency is its ISO code.
	TotalCost    *float64
	Currency     string
	EnergyKWh    *float64
	RangeAddedKm *float64
	Vendor       string
	City         string
}

SessionSummary is one row of Rivian's Charging history.

type SleepObserver added in v0.31.2

type SleepObserver interface {
	OnSleepEnded(vehicleID string, slept time.Duration, failed bool)
}

SleepObserver is told about every completed sleep: how long the car stayed asleep and whether that counts as a failed sleep. main wires it to Prometheus; keeping it an interface keeps metrics out of this package.

type Stagger added in v0.37.6

type Stagger struct {
	// contains filtered or unexported fields
}

Stagger hands out time slots at least interval apart. The monitors share one (MonitorRegistry.SetStagger), so the first connect of every subscription in the process is spaced, not only the ones of a single user. A nil or zero-interval Stagger never waits.

func NewStagger added in v0.37.6

func NewStagger(interval time.Duration) *Stagger

NewStagger returns a Stagger with the given spacing (<= 0: none).

func (*Stagger) Wait added in v0.37.6

func (s *Stagger) Wait(ctx context.Context)

Wait blocks until the caller's slot, or ctx is done. Slots are reserved under the lock and slept outside it, so callers queue up in order.

type State

type State struct {
	// At is wall-clock time when this snapshot was assembled by the
	// recorder (REST or WS receive time). Always monotonic with the
	// process clock. Use LocationFixAt for the actual GPS-fix age.
	// Earlier code sourced this from GNSSLocation.TimeStamp which
	// could be hours stale (parking garage, sleeping car) and caused
	// drive/charge sessions to fragment because lifecycle gap checks
	// saw spurious 38h "gaps".
	At time.Time `json:"at"`
	// LocationFixAt is the timestamp Rivian reports on the GNSS
	// location field — i.e. when the GPS receiver last had a lock.
	// Zero when not present in the payload. May lag At by hours
	// when the car is parked indoors.
	LocationFixAt   time.Time `json:"location_fix_at,omitempty"`
	VehicleID       string    `json:"vehicle_id"`
	BatteryLevelPct float64   `json:"battery_level_pct"` // 0..100
	// BatteryCapacityKWh is the vehicle's self-reported usable pack
	// capacity in kWh (batteryCapacity field on vehicleState). Zero
	// when the field wasn't included in the subscription push or
	// when an older firmware doesn't emit it.
	BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
	DistanceToEmpty    float64 `json:"distance_to_empty"` // kilometers
	OdometerKm         float64 `json:"odometer_km"`
	Gear               string  `json:"gear"`          // "P" | "R" | "N" | "D"
	DriveMode          string  `json:"drive_mode"`    // "everyday" | "sport" | ...
	ChargerState       string  `json:"charger_state"` // "charging_active" | "charger_disconnected" | ...
	ChargerPowerKW     float64 `json:"charger_power_kw"`
	ChargeTargetPct    float64 `json:"charge_target_pct"` // 0..100
	// ChargerStatus is the physical plug state ("chrgr_sts_connected_charging",
	// "chrgr_sts_not_connected", ...) — different from ChargerState which is
	// the session state.
	ChargerStatus           string  `json:"charger_status"`
	ChargePortState         string  `json:"charge_port_state"` // "open" | "closed" | ""
	RemoteChargingAvailable string  `json:"remote_charging_available"`
	Latitude                float64 `json:"latitude"`
	Longitude               float64 `json:"longitude"`
	SpeedKph                float64 `json:"speed_kph"`
	HeadingDeg              float64 `json:"heading_deg"`
	AltitudeM               float64 `json:"altitude_m"`
	// Aggregate closures / locks.
	Locked         bool `json:"locked"` // all LOCK_STATE_ENTITIES not unlocked
	DoorsClosed    bool `json:"doors_closed"`
	FrunkClosed    bool `json:"frunk_closed"`
	LiftgateClosed bool `json:"liftgate_closed"`
	TailgateClosed bool `json:"tailgate_closed"`
	TonneauClosed  bool `json:"tonneau_closed"`
	// Climate / power.
	CabinTempC                 float64 `json:"cabin_temp_c"`
	OutsideTempC               float64 `json:"outside_temp_c"`
	CabinPreconditioningStatus string  `json:"cabin_preconditioning_status"`
	// PowerState is the vehicle's high-level power mode reported by the
	// gateway: "sleep" | "standby" | "ready" | "go" | "vehicle_reset" |
	// "ota_update" | "shutdown" | "" (unknown). Mirrors home-assistant-rivian's powerState sensor.
	PowerState string `json:"power_state"`
	// ReplaySession is what Rivian's live charging session would report
	// alongside this frame (the running billed price at a Rivian
	// Adventure Network stop), carried only by replayed frames (the
	// sample corpus). The replay source applies it as a live session
	// after recording the frame; it never reaches the cache or JSON.
	ReplaySession *LiveSession `json:"-"`
	// PowerStateAt is when the car reported PowerState (Rivian's
	// powerState.timeStamp, or the Parallax power.state frame time);
	// zero when unknown. Anchors wake attribution (actions.go).
	PowerStateAt time.Time `json:"-"`
	// ChargingSchedules are the vehicle's scheduled charging windows
	// (charging_schedule.go), read on connect, daily and at plug-in; nil
	// until the first read. They explain a car that is plugged in and not
	// charging.
	ChargingSchedules []ChargingSchedule `json:"charging_schedules,omitempty"`
	// ChargingSchedulesUnavailable is true when Rivian refuses to share this
	// vehicle's schedules with the account (shared or authorized-driver
	// accounts get FORBIDDEN). Not an error: the Now line just has nothing
	// to say. Re-read after a week.
	ChargingSchedulesUnavailable bool `json:"charging_schedules_unavailable,omitempty"`
	// ChargerStatusPx marks a ChargerStatus taken from Parallax
	// charging.session.status, on a vehicle whose vehicleState sends none
	// (an R2). The recorder trusts it for "plugged" but keeps its power
	// inference as the fallback.
	ChargerStatusPx bool `json:"-"`
	// VehicleStateFrameAt rides only on the snapshot the lease owner
	// publishes for peer pods (liveStatePublisher), so they can serve
	// /now's vehicle_state_frame_at; never set on the cache.
	VehicleStateFrameAt *time.Time `json:"vehicle_state_frame_at,omitempty"`
	// Safety / maintenance.
	AlarmSoundStatus        string `json:"alarm_sound_status"`
	TwelveVoltBatteryHealth string `json:"twelve_volt_battery_health"`
	WiperFluidState         string `json:"wiper_fluid_state"`
	// Software (OTA). Available version may equal current when nothing is
	// pending. Install progress is 0..100.
	OtaCurrentVersion   string  `json:"ota_current_version"`
	OtaAvailableVersion string  `json:"ota_available_version"`
	OtaStatus           string  `json:"ota_status"`
	OtaInstallProgress  float64 `json:"ota_install_progress"`
	// Tires. Pressures reported in bar (per home-assistant-rivian's
	// native unit declaration). Status is a string like "Normal" /
	// "Low" / "" (unknown).
	TirePressureFLBar    float64 `json:"tire_pressure_fl_bar"`
	TirePressureFRBar    float64 `json:"tire_pressure_fr_bar"`
	TirePressureRLBar    float64 `json:"tire_pressure_rl_bar"`
	TirePressureRRBar    float64 `json:"tire_pressure_rr_bar"`
	TirePressureStatusFL string  `json:"tire_pressure_status_fl"`
	TirePressureStatusFR string  `json:"tire_pressure_status_fr"`
	TirePressureStatusRL string  `json:"tire_pressure_status_rl"`
	TirePressureStatusRR string  `json:"tire_pressure_status_rr"`
	// High-voltage battery pack cell temperatures (°C). Not in the
	// legacy vehicleState selection — merged in from the Parallax
	// energy.high_voltage.battery_state topic (see parallax.go). Zero
	// means "not yet reported" (the topic only streams while awake).
	PackTempAvgC float64 `json:"pack_temp_avg_c"`
	PackTempMaxC float64 `json:"pack_temp_max_c"`
	PackTempMinC float64 `json:"pack_temp_min_c"`
	// Charging context populated during an active session. Zero/empty
	// outside a session — clients gate UI on ChargerState first.
	//   TimeToEndMins:               minutes to a full charge at the
	//     current rate (0 when not charging or undefined).
	//   TripTargetSoCPct:            Rivian's recommended target SoC
	//     for the active trip plan, 0..100.
	//   TripTargetMinsRemaining:     minutes until the trip target
	//     SoC is hit at the current rate.
	//   ChargerDerateStatus:         "active" / "inactive" / "" — set
	//     when Rivian flags the station as derated below nameplate.
	//   ChargingDisabledAll:         "true" / "false" / "" — when
	//     "true", the car refuses to charge regardless of cable state.
	//   ChargingTimeEstimationValidity: "valid" / "invalid" / "" —
	//     SPA hides the time estimate when invalid.
	TimeToEndMins float64 `json:"time_to_end_mins"`
	// TimeToEndSource is "estimate" when TimeToEndMins came from Parallax
	// charging.session.time_estimation (an R2's vehicleState reports none),
	// "" when the vehicle's own vehicleState gave it.
	TimeToEndSource                string  `json:"time_to_end_source,omitempty"`
	TripTargetSoCPct               float64 `json:"trip_target_soc_pct"`
	TripTargetMinsRemaining        float64 `json:"trip_target_mins_remaining"`
	ChargerDerateStatus            string  `json:"charger_derate_status"`
	ChargingDisabledAll            string  `json:"charging_disabled_all"`
	ChargingTimeEstimationValidity string  `json:"charging_time_estimation_validity"`
	// Driver identity + special modes. Empty when the field hasn't
	// been emitted by the subscription yet (e.g. brand-new tab) or
	// when the mode isn't active.
	ActiveDriverName         string `json:"active_driver_name"`
	GearGuardLocked          string `json:"gear_guard_locked"`
	PetModeStatus            string `json:"pet_mode_status"`
	PetModeTemperatureStatus string `json:"pet_mode_temperature_status"`
	CabinHoldStatus          string `json:"cabin_hold_status"`
	// Window-closed states. Surfaced as raw Rivian enum strings
	// instead of bool because the closure vocabulary for windows
	// is much richer than doors ("opened", "open_allowed",
	// "obstructed_while_opening_close_allowed", "closed", …). The
	// SPA title-cases the value so the user sees the truth without
	// us guessing at a closed/open derivation.
	WindowFrontLeftClosed  string `json:"window_front_left_closed"`
	WindowFrontRightClosed string `json:"window_front_right_closed"`
	WindowRearLeftClosed   string `json:"window_rear_left_closed"`
	WindowRearRightClosed  string `json:"window_rear_right_closed"`
}

State is a point-in-time snapshot of one vehicle. Units are metric at the wire; the UI converts for display based on user preference.

func FramesFromJSONL added in v0.30.4

func FramesFromJSONL(r io.Reader) ([]State, error)

FramesFromJSONL parses a newline-delimited stream of replayFrame objects (a DB export or captured stream) into State frames, in file order. Blank lines are skipped.

type StateCallback

type StateCallback func(*State)

StateCallback fires on every state update pushed over the subscription. Called from the websocket receiver goroutine — the implementation should be quick (copy to a channel, update a cache, etc.) and must not block on I/O.

type StateMonitor

type StateMonitor struct {
	// contains filtered or unexported fields
}

StateMonitor maintains a websocket subscription per vehicle and caches the latest pushed State. Callers read via Latest(); the subscription keeps it fresh in the background. Use Start() once per process to kick off the monitoring goroutines.

Intended usage: one StateMonitor wrapping the live Client. The HTTP handler calls monitor.Latest(vehicleID) instead of client.State(), getting a cache-hit response without upstream cost. Missing-entry reads trigger a REST fallback to prime the cache while the subscription catches up.

func NewSourceMonitor added in v0.32.0

func NewSourceMonitor(src FrameSource, vehicles []Vehicle, logger *slog.Logger) *StateMonitor

NewSourceMonitor returns a monitor whose vehicles are fed by src instead of a Rivian subscription — the replay/demo path. It has no client, so nothing it does can reach Rivian. vehicles seeds the metadata cache that RefreshVehicleInfo would otherwise fetch.

func NewStateMonitor

func NewStateMonitor(client *LiveClient, logger *slog.Logger) *StateMonitor

NewStateMonitor wraps a live client. Pass a logger (usually from main.go's structured logger). nil is allowed; events will be discarded.

func (*StateMonitor) ActiveDrive

func (m *StateMonitor) ActiveDrive(vehicleID string) *LiveDrive

ActiveDrive returns a snapshot of the in-flight drive session for the given vehicle, or nil if no drive is currently open. The snapshot is derived from the recorder's accumulator under sessMu so callers see a consistent view even while telemetry frames are being folded in on another goroutine. Energy and efficiency are computed from the SoC delta × per-vehicle pack size — the same fallback the charge recorder uses for home-AC sessions.

func (*StateMonitor) AllVehicleInfo

func (m *StateMonitor) AllVehicleInfo() []Vehicle

AllVehicleInfo returns a snapshot of every cached vehicle record. Used by the HTTP /api/vehicles endpoint.

func (*StateMonitor) EnsureSubscribed

func (m *StateMonitor) EnsureSubscribed(vehicleID string)

EnsureSubscribed guarantees a background subscription exists for the given vehicle. Safe to call concurrently; the first caller wins, subsequent callers are no-ops. If the subscription dies (e.g. ctx cancelled after an unauthenticated error) it stays removed so a future call can retry.

func (*StateMonitor) FlushPending added in v0.37.7

func (m *StateMonitor) FlushPending(ctx context.Context) int

FlushPending writes every in-progress drive and charge whose periodic write was skipped by the throttle, so the stored rows are current. Called on shutdown (before the leases are released) and, per vehicle, when a lease is handed off, so a normal rollout loses nothing; only a kill -9 can lose up to liveTouchInterval of a session's totals. Returns how many rows it wrote.

func (*StateMonitor) Latest

func (m *StateMonitor) Latest(vehicleID string) (*State, time.Time)

Latest returns the most recently pushed state for a vehicle, along with the wall-clock time it was received, or (nil, zero) if nothing has arrived yet.

func (*StateMonitor) LatestLiveSession

func (m *StateMonitor) LatestLiveSession(vehicleID string) *LiveSession

LatestLiveSession returns the last charging-session snapshot observed for the vehicle, whichever source got there first — the WebSocket ChargingSession subscription or the REST getLiveSessionHistory poller. Callers should treat the result as read-only; it may be nil if no session has ever been seen for this vehicle.

func (*StateMonitor) PackKWhFor

func (m *StateMonitor) PackKWhFor(vehicleID string) float64

PackKWhFor returns the best-known usable pack capacity for the vehicle, falling back to DefaultPackKWh when no metadata is cached. Used by the recorder's SoC-delta energy fallback.

func (*StateMonitor) Prime

func (m *StateMonitor) Prime(vehicleID string, st *State)

Prime stores a state from an out-of-band source (typically a REST fallback on first request) so subsequent Latest() calls return it immediately while the subscription is still spinning up.

func (*StateMonitor) RefreshVehicleInfo

func (m *StateMonitor) RefreshVehicleInfo(ctx context.Context) error

RefreshVehicleInfo pulls the vehicles list + configurator images from Rivian's gateway and caches a per-vehicle metadata record including model, trim, inferred pack kWh, and a 3/4 image URL. Called once at startup (best-effort); errors are returned to the caller so they can log but shouldn't be fatal. Missing images are not an error — PackKWh still gets populated from the vehicles query.

func (*StateMonitor) RemoteLatest added in v0.27.2

func (m *StateMonitor) RemoteLatest(ctx context.Context, vehicleID string) *State

RemoteLatest returns the live State a peer pod published to the shared store for this vehicle, or nil when none is available (no shared store wired, the owner hasn't published yet, or the snapshot aged out). Used by the /api/state read path on a replica that does NOT own the vehicle's WS lease: its local cache is empty and a direct REST fetch would omit every subscription-only field (pack temperature, numeric tire pressures, charging context, driver chips, windows). Reading the owner's published snapshot serves those fields pod-agnostically.

A snapshot read is reused for remoteLatestTTL: the owner republishes only every liveStateSnapshotInterval, so /now and /api/state polls (the public demo's visitors all poll the same vehicle) share one store read instead of one each. Misses aren't cached.

func (*StateMonitor) ServeState added in v0.33.0

func (m *StateMonitor) ServeState(ctx context.Context, vehicleID string) (st *State, fetch bool)

ServeState decides what /api/state serves for vehicleID. It returns the state to serve and whether the caller should first fetch it directly (then Prime the result, or on failure serve st if non-nil):

  1. this pod streams the vehicle: its own cache, the freshest there is;
  2. otherwise the owner's published snapshot (RemoteLatest);
  3. otherwise the local entry (a past fetch, or the last frame before this pod lost the lease), with fetch=true at most once per stateRefetchEvery per vehicle on this pod, and never while that entry says the vehicle is asleep: this path has no sleep gate of its own, and a Redis outage must not fan out into Rivian calls;
  4. no entry: fetch=true under the same limit, else (nil, false).

Serving the local entry first, as Latest-first did, froze /api/state on a non-owner pod from its first fallback fetch until it restarted.

func (*StateMonitor) SetBatteryCapacityHook added in v0.22.1

func (m *StateMonitor) SetBatteryCapacityHook(hook func(vehicleID string, kwh float64))

SetBatteryCapacityHook wires a write-through callback fired whenever observeBatteryCapacity records a new pack capacity for a vehicle. The hook is called outside the monitor lock and MUST be non-blocking — callers typically dispatch a goroutine that performs the DB UPDATE.

func (*StateMonitor) SetChargeCloseHook

func (m *StateMonitor) SetChargeCloseHook(h ChargeCloseHook)

SetChargeCloseHook wires a post-close callback for charge sessions. nil disables. Safe to call before Start.

func (*StateMonitor) SetDCFCDetector added in v0.33.0

func (m *StateMonitor) SetDCFCDetector(fn DCFCDetector)

SetDCFCDetector wires fast-charge network detection (see dcfcDetect).

func (*StateMonitor) SetDCFCEstimator added in v0.38.8

func (m *StateMonitor) SetDCFCEstimator(fn DCFCEstimator)

SetDCFCEstimator wires the live typical-price fallback (see dcfcEstimate).

func (*StateMonitor) SetDCFCPriceLookup added in v0.32.0

func (m *StateMonitor) SetDCFCPriceLookup(fn DCFCPriceLookup)

SetDCFCPriceLookup wires the fast-charge pricer (see dcfcPrice).

func (*StateMonitor) SetDriveCloseHook

func (m *StateMonitor) SetDriveCloseHook(h DriveCloseHook)

SetDriveCloseHook wires a post-close enrichment callback (e.g. weather fetch). nil disables. Safe to call before Start; racy if called after subscriptions are running.

func (*StateMonitor) SetElevationLookup

func (m *StateMonitor) SetElevationLookup(e ElevationLookup)

SetElevationLookup wires an optional elevation resolver. nil disables altitude annotation; samples will be written with NULL altitude_m. Safe to call before Start; racy after.

func (*StateMonitor) SetHomeLookup added in v0.32.0

func (m *StateMonitor) SetHomeLookup(fn HomeLookup)

SetHomeLookup wires the home-location lookup (see homeLookup).

func (*StateMonitor) SetLiveStateStore

func (m *StateMonitor) SetLiveStateStore(s LiveStateStore)

SetLiveStateStore wires the cross-restart accumulator persistence store. nil disables persistence and rehydration; the recorder runs purely from in-memory state (the pre-Redis behaviour). Safe to call before Start; racy if called after subscriptions are running.

func (*StateMonitor) SetPriceLookup

func (m *StateMonitor) SetPriceLookup(fn PriceLookup)

SetPriceLookup wires a callback the recorder uses to stamp each closed charge with the current home $/kWh rate. Safe to call at any time; a nil value disables cost snapshotting.

func (*StateMonitor) SetRouteFiller

func (m *StateMonitor) SetRouteFiller(r RouteFiller)

SetRouteFiller wires the GPS-gap fill backend. nil disables gap filling; long lags will leave a straight-line shortcut in the drive polyline (the pre-Valhalla behavior). Safe to call before Start; racy after.

func (*StateMonitor) SetSleepObserver added in v0.31.2

func (m *StateMonitor) SetSleepObserver(o SleepObserver)

SetSleepObserver installs the sleep metrics hook. nil is allowed.

func (*StateMonitor) SetSleepSafe added in v0.31.2

func (m *StateMonitor) SetSleepSafe(fn func(vehicleID string) bool)

SetSleepSafe installs the per-vehicle sleep-safe check (normally the flags store's SleepSafe().Covers). nil disables gating; the gate still observes.

func (*StateMonitor) SetStagger added in v0.37.6

func (m *StateMonitor) SetStagger(s *Stagger)

SetStagger replaces the monitor's slot clock; the registry passes one shared instance to every monitor. Call before Start.

func (*StateMonitor) SetStores

func (m *StateMonitor) SetStores(samplesStore *samples.Store, drivesStore *drives.Store, chargesStore *charges.Store)

SetStores wires the recording stores. All three are optional — pass nil to disable that particular writer. Safe to call before Start; racy if called after subscriptions are running.

func (*StateMonitor) SetVehicleInfoHook added in v0.36.4

func (m *StateMonitor) SetVehicleInfoHook(h func([]Vehicle))

SetVehicleInfoHook wires the persister RefreshVehicleInfo calls with the vehicles it just cached. nil disables. Safe to call before Start.

func (*StateMonitor) SetWakeSafety added in v0.32.0

func (m *StateMonitor) SetWakeSafety(fn func(vehicleID string) WakeSafety)

SetWakeSafety installs the per-vehicle switch lookup (normally the flags store's wake_safety). nil turns every switch off.

func (*StateMonitor) Start

func (m *StateMonitor) Start(ctx context.Context)

Start binds the monitor to a parent context. All subscriptions started via EnsureSubscribed use a child context derived from this parent; cancelling parent tears them all down.

func (*StateMonitor) Subscribed added in v0.33.0

func (m *StateMonitor) Subscribed(vehicleID string) bool

Subscribed reports whether a subscription goroutine is running for vehicleID (EnsureSubscribed started it and it hasn't exited).

func (*StateMonitor) Unsubscribe

func (m *StateMonitor) Unsubscribe(vehicleID string)

Unsubscribe tears down the background subscription for vehicleID if one is running, and is a no-op otherwise. Used by the lease coordinator when this pod loses ownership of a vehicle to a peer.

Cancellation is async — the run goroutine observes ctx.Done() the next time it loops around. Everything this pod knew about the vehicle is forgotten (forgetVehicle): the new owner is the source of state from here on, and /api/state on this pod follows its published snapshot.

func (*StateMonitor) VehicleInfo

func (m *StateMonitor) VehicleInfo(vehicleID string) *Vehicle

VehicleInfo returns the cached per-vehicle metadata record, or nil if RefreshVehicleInfo hasn't been called (or hasn't seen this vehicle yet). The returned pointer is a copy; safe to read without holding the lock.

func (*StateMonitor) VehicleStateFrameAt added in v0.33.2

func (m *StateMonitor) VehicleStateFrameAt(ctx context.Context, vehicleID string) *time.Time

VehicleStateFrameAt is when the vehicleState feed last delivered a frame for the vehicle (GET /now's vehicle_state_frame_at), or nil when there is no live feed to judge: this pod doesn't stream the vehicle and the owner hasn't published one, the subscription hasn't delivered yet, or it is held while the car sleeps (wake-safety HoldAsleep). A non-nil time that's old means the feed is stuck. A pod that doesn't stream the vehicle reads the owner's published snapshot (refreshed every liveStateSnapshotInterval; gone after LiveStateSnapshotTTL once the owner stops).

type StubClient

type StubClient struct{}

StubClient is a safe-to-boot placeholder so cmd/rivolt compiles and the HTTP server comes up even before the real client is wired.

func NewStub

func NewStub() *StubClient

NewStub returns a StubClient. Use this in main.go until the live client is in.

func (*StubClient) Login

Login returns ErrNotImplemented.

func (*StubClient) State

func (*StubClient) State(context.Context, string) (*State, error)

State returns ErrNotImplemented.

func (*StubClient) Vehicles

func (*StubClient) Vehicles(context.Context) ([]Vehicle, error)

Vehicles returns ErrNotImplemented.

type TirePressure added in v0.27.4

type TirePressure struct {
	Position int
	Bar      float64
}

TirePressure is one decoded wheel from a dynamics.tires.state frame. Position 1=FL, 2=FR, 3=RL, 4=RR; Bar is the pressure in bar (matches vehicleState's tire_pressure_*_bar). Confirmed live 2026-07-14: pressures 3.25/3.28/3.25/3.25 bar vs vehicleState tire_min_bar 3.25.

type TripPlan

type TripPlan struct {
	Status                  string
	ChargeStationsAvailable bool
	SoCBelowLimit           bool
	Routes                  []TripRoute
	// Simulated is true for a plan that did not come from the gateway
	// (fixtures or the synthetic planner on preview). The SPA badges it.
	Simulated bool `json:"simulated,omitempty"`
}

TripPlan is the cleaned-up response. The gateway returns one or more candidate routes; first is typically the recommended one.

type TripPlanner added in v0.37.0

type TripPlanner interface {
	PlanTrip(ctx context.Context, in PlanTripInput) (*TripPlan, error)
}

TripPlanner is what the trip-plan handlers need from a client: one planned trip for one input. *LiveClient implements it against the gateway; the replay client implements it from fixtures and a synthetic planner. The stub and mock clients do not, so the handlers still answer 404 for them.

type TripRoute

type TripRoute struct {
	DestinationReached       bool
	TotalChargingDurationSec int
	TotalDriveDurationSec    int
	TotalTripDurationSec     int
	TotalDriveDistanceMeters float64
	ArrivalSoC               float64
	ArrivalReachableMeters   float64
	EnergyConsumptionKWh     float64
	BatteryEmptyToDestMeters float64
	BatteryEmptyLat          float64
	BatteryEmptyLon          float64
	// RouteResponseRaw holds the gateway's geometry payload verbatim
	// so the SPA / map renderer can decode it without a round-trip.
	// Shape is opaque to us (provider-specific encoded polyline +
	// metadata); we forward it as-is.
	RouteResponseRaw any
	Waypoints        []PlannedWaypoint
}

TripRoute is one candidate route. Charging stops are inside Waypoints (filtered to entries whose waypointType identifies a charger).

type UpstreamError

type UpstreamError struct {
	Class      ErrorClass
	Op         string // GraphQL operationName, e.g. "Login"
	HTTPStatus int    // 0 when the error is pre-HTTP (network, marshal)
	ExtCode    string // GraphQL extensions.code, when present
	// Reason is a short human hint for the user-facing UI or for
	// logs. For ClassUserAction it lands in users.needs_reauth_reason.
	Reason string
	// Cause is the underlying error. Preserved so callers can
	// keep using errors.Is on standard sentinels.
	Cause error
}

UpstreamError is the structured error every outbound Rivian call returns when the upstream rejects us. It wraps the underlying cause (so errors.Is / errors.As keep working with net.OpError, context.Canceled, etc.) and carries the extras that make the retry/needs_reauth logic trivial to write.

The struct is deliberately public so the api layer can unwrap it and turn 429/ClassRateLimited into a 503-with-Retry-After, ClassUserAction into a 401 that tells the client to open the Settings → Rivian pane, etc. Stringer is suitable for logs.

func (*UpstreamError) Error

func (e *UpstreamError) Error() string

Error implements error. Format is terse on purpose; the Class and Op travel separately through structured logging. The wrapped Cause is included so string-matching tests and log scrapers that look for upstream GraphQL messages keep working.

func (*UpstreamError) Unwrap

func (e *UpstreamError) Unwrap() error

Unwrap returns the underlying cause so errors.Is(err, context.Canceled), errors.Is(err, ErrUpstreamPaused), and similar continue to work through the UpstreamError wrapper.

type Vehicle

type Vehicle struct {
	ID        string `json:"id"`
	VIN       string `json:"vin"`
	Name      string `json:"name"`
	Model     string `json:"model"` // "R1T" | "R1S"
	ModelYear int    `json:"model_year,omitempty"`
	Make      string `json:"make,omitempty"`
	TrimID    string `json:"trim_id,omitempty"`   // e.g. "PKG-ADV", "LRG-DM-PRFM"
	TrimName  string `json:"trim_name,omitempty"` // e.g. "Adventure Package"
	// PackKWh is the usable battery capacity inferred from model /
	// trim. Used by the live recorder to estimate energy from SoC
	// deltas when Rivian's live session feed doesn't report power.
	PackKWh float64 `json:"pack_kwh,omitempty"`
	// ImageURL is a pre-rendered 3/4 view of the configured vehicle,
	// populated lazily via GetVehicleImages. Empty string when not
	// yet fetched or when the Rivian image service didn't return one.
	// This is a convenience pick (hero image) — the full set of
	// rendered angles (front, rear, side, interior, etc.) is in
	// Images.
	ImageURL string `json:"image_url,omitempty"`
	// Images is the full set of configurator-rendered images Rivian
	// returns for this vehicle — typically 6-8 angles including
	// 3/4 front, side profile, rear, interior cabin, wheel detail,
	// etc. Each entry carries a `placement` tag describing the
	// camera angle so the UI can build a gallery or swap the hero
	// image on hover.
	Images []VehicleImage `json:"images,omitempty"`
	// OTA is the current and available software release with release-notes
	// links (stored by the OTA sync, served by /api/vehicles); nil until the
	// first fetch.
	OTA *OTAInfo `json:"ota,omitempty"`
	// FieldSupport says which live fields to trust on this model: reported,
	// estimate, best_effort or unavailable (see FieldSupport).
	FieldSupport map[string]string `json:"field_support,omitempty"`
}

Vehicle is a minimum-useful projection of a Rivian vehicle. More fields will be added as we flesh out the client.

type VehicleImage

type VehicleImage struct {
	VehicleID  string `json:"vehicle_id"`
	OrderID    string `json:"order_id,omitempty"`
	URL        string `json:"url"`
	Extension  string `json:"extension,omitempty"`
	Resolution string `json:"resolution,omitempty"`
	Size       string `json:"size,omitempty"`
	Design     string `json:"design,omitempty"`
	Placement  string `json:"placement,omitempty"`
}

VehicleImage is one pre-rendered configurator image.

type WakeSafety added in v0.32.0

type WakeSafety struct {
	CaptureOff     bool
	RestQuiet      bool
	HoldAsleep     bool
	HoldCheckEvery time.Duration
	HoldMax        time.Duration
}

WakeSafety is one vehicle's switches. The zero value changes nothing.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL