rivian

package
v0.18.78 Latest Latest
Warning

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

Go to latest
Published: May 15, 2026 License: MIT Imports: 31 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 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 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.

Variables

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 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 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.

Functions

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. Data comes from Rivian's owner-facing spec sheets and community teardowns — the GraphQL API exposes model / modelYear / trimOption.optionId but NOT pack size. Unknown combinations return DefaultPackKWh so the SoC-delta fallback keeps working.

Rivian trim optionId strings observed in the wild:

Gen 1 (2022–2024):
  "LRG-DM-PRFM"  R1T/R1S Large Dual-Motor Performance (131 kWh usable)
  "LRG-DM-STD"   R1T/R1S Large Dual-Motor Standard   (131 kWh usable)
  "LRG-QM"       R1T/R1S Large Quad-Motor            (131 kWh usable)
  "STD-DM"       R1T/R1S Standard Dual-Motor (LFP ~105 kWh usable)
  "MAX-DM"       R1T/R1S Max pack Dual-Motor (~180 kWh usable)
  "MAX-QM"       R1T/R1S Max pack Quad-Motor (~180 kWh usable)
Gen 2 (2025+):
  "G2-STD-DM"    Gen2 Standard+ (~92.5 kWh usable)
  "G2-LRG-DM"    Gen2 Large (~141.5 kWh usable)
  "G2-MAX-DM"    Gen2 Max (~180 kWh usable)
  "G2-TRI-MTR"   Gen2 Tri-Motor
  "G2-QUAD"      Gen2 Quad-Motor (Max pack)

Model year ≥ 2025 or a trim prefix of "G2-" indicates Gen 2. For unambiguously-Gen1 Large trims (2022–2024 model year) we return 131, not 141.5 — those are the same trim code shared across generations only because Rivian reused the configurator option IDs.

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 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.

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

	// 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 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 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 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 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"`
}

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) 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) Email

func (c *LiveClient) Email() string

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

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) 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) 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) 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) Restore

func (c *LiveClient) Restore(s Session)

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

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) 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) 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) 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) 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) 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"`
}

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
}

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 is wired only in live mode. Mock and stub paths don't have a recorder, so they don't need this layer.

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) 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) 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) 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) 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) 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 only when the account registry hands back a non-LiveClient (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) Unsubscribe

func (r *MonitorRegistry) Unsubscribe(rivianVehicleID string)

Unsubscribe routes the same way as EnsureSubscribed.

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 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
}

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
}

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 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) 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) 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 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 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 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" | ""
	// (unknown). Mirrors home-assistant-rivian's powerState sensor.
	PowerState string `json:"power_state"`
	// 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"`
}

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.

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 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) 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) 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) 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) 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) 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) 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) 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. The cache entry is left intact so a subsequent Latest() call returns the last known state for the short window between Unsubscribe and the goroutine actually exiting; once Unsubscribe is called we should not be the source of new state, but stale-but-coherent is better than blanking the UI mid-rebalance.

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.

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 TripPlan

type TripPlan struct {
	Status                  string
	ChargeStationsAvailable bool
	SoCBelowLimit           bool
	Routes                  []TripRoute
}

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

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"`
}

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.

Jump to

Keyboard shortcuts

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