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
- Variables
- func InferPackKWh(model, trimID string, modelYear int) float64
- func IsAuthFlavoredReason(reason string) bool
- func IsUpstream(err error, class ErrorClass) bool
- func WithPriority(ctx context.Context) context.Context
- type Account
- type AccountRegistry
- type BatteryTemp
- type BatteryTempCallback
- type Breaker
- type BreakerConfig
- type BreakerObserver
- type BreakerState
- type ChargeCloseHook
- type ChargingFrame
- type Client
- type Credentials
- type DriveCloseHook
- type DriveDynamicsCallback
- type DriveDynamicsFrame
- type DynamicsGNSS
- type DynamicsGNSSCallback
- type ElevationLookup
- type ErrRateLimited
- type ErrorClass
- type LiveChargeSnapshot
- type LiveClient
- func (c *LiveClient) AuthReady() <-chan struct{}
- func (c *LiveClient) Authenticated() bool
- func (c *LiveClient) ChargingFieldProbe(ctx context.Context, field, vehicleID string) (map[string]any, error)
- func (c *LiveClient) ChargingFieldProbeWithSelection(ctx context.Context, field, vehicleID, selection string) (map[string]any, error)
- func (c *LiveClient) ChargingSchemaProbe(ctx context.Context) (map[string]any, error)
- func (c *LiveClient) Email() string
- func (c *LiveClient) IntrospectEnum(ctx context.Context, typeName string) (map[string]any, error)
- func (c *LiveClient) IntrospectInputType(ctx context.Context, typeName string) (map[string]any, error)
- func (c *LiveClient) LiveSession(ctx context.Context, vehicleID string) (*LiveSession, error)
- func (c *LiveClient) Login(ctx context.Context, creds Credentials) error
- func (c *LiveClient) Logout()
- func (c *LiveClient) MFAPending() bool
- func (c *LiveClient) NeedsReauth() (bool, string)
- func (c *LiveClient) PendingSnapshot() (PendingMFA, bool)
- func (c *LiveClient) PlanTrip(ctx context.Context, in PlanTripInput) (*TripPlan, error)
- func (c *LiveClient) PlanTripRaw(ctx context.Context, variables map[string]any) (map[string]any, error)
- func (c *LiveClient) ProbeBatteryTemperature(ctx context.Context, vehicleID string) (*BatteryTemp, error)
- func (c *LiveClient) ProbeParallaxRaw(ctx context.Context, vehicleID string, rvms []string, maxFrames int) ([]ParallaxRawFrame, error)
- func (c *LiveClient) RawGraphQL(ctx context.Context, operationName, query string, variables map[string]any) (map[string]any, error)
- func (c *LiveClient) RecentChargingFrames(vehicleID string) []ChargingFrame
- func (c *LiveClient) RecordChargingEvent(vehicleID, event, errMsg string)
- func (c *LiveClient) RecordChargingFrame(vehicleID string, raw []byte)
- func (c *LiveClient) RefreshSession(ctx context.Context) error
- func (c *LiveClient) Restore(s Session)
- func (c *LiveClient) RestorePending(p PendingMFA)
- func (c *LiveClient) SetNeedsReauth(needs bool, reason string)
- func (c *LiveClient) Snapshot() Session
- func (c *LiveClient) State(ctx context.Context, vehicleID string) (*State, error)
- func (c *LiveClient) StateRaw(ctx context.Context, vehicleID string) (map[string]any, error)
- func (c *LiveClient) SubscribeBatteryState(ctx context.Context, vehicleID string, cb BatteryTempCallback) error
- func (c *LiveClient) SubscribeChargingSession(ctx context.Context, vehicleID string, cb LiveSessionCallback) error
- func (c *LiveClient) SubscribeDriveDynamics(ctx context.Context, vehicleID string, extra []string, ...) error
- func (c *LiveClient) SubscribeDynamicsGNSS(ctx context.Context, vehicleID string, cb DynamicsGNSSCallback) error
- func (c *LiveClient) SubscribeParallaxCharging(ctx context.Context, vehicleID string, cb LiveSessionCallback) error
- func (c *LiveClient) SubscribeVehicleState(ctx context.Context, vehicleID string, cb StateCallback) error
- func (c *LiveClient) SupportedFeatures(ctx context.Context) (map[string]map[string]string, error)
- func (c *LiveClient) VehicleImages(ctx context.Context) ([]VehicleImage, error)
- func (c *LiveClient) VehicleSupportsParallax(ctx context.Context, vehicleID string) (bool, error)
- func (c *LiveClient) Vehicles(ctx context.Context) ([]Vehicle, error)
- func (c *LiveClient) WithBreaker(b *Breaker) *LiveClient
- func (c *LiveClient) WithEndpoint(url string) *LiveClient
- func (c *LiveClient) WithHTTPClient(h *http.Client) *LiveClient
- func (c *LiveClient) WithRateLimit(rl RateLimiter) *LiveClient
- func (c *LiveClient) WithReauthSink(sink func(ctx context.Context, reason string)) *LiveClient
- func (c *LiveClient) WithRivoltVersion(v string) *LiveClient
- func (c *LiveClient) WithUpstreamGate(gate func(ctx context.Context) error) *LiveClient
- type LiveDrive
- type LiveDriveSnapshot
- type LiveSession
- type LiveSessionCallback
- type LiveStateSnapshot
- type LiveStateStore
- type MockClient
- func (c *MockClient) AuthReady() <-chan struct{}
- func (c *MockClient) Authenticated() bool
- func (c *MockClient) Email() string
- func (c *MockClient) Login(_ context.Context, cr Credentials) error
- func (c *MockClient) Logout()
- func (c *MockClient) MFAPending() bool
- func (c *MockClient) NeedsReauth() (bool, string)
- func (c *MockClient) Restore(s Session)
- func (c *MockClient) SetState(vehicleID string, s State)
- func (c *MockClient) SetVehicles(vs []Vehicle)
- func (c *MockClient) Snapshot() Session
- func (c *MockClient) State(_ context.Context, vehicleID string) (*State, error)
- func (c *MockClient) Vehicles(_ context.Context) ([]Vehicle, error)
- type MonitorRegistry
- func (r *MonitorRegistry) AllVehicleInfo() []Vehicle
- func (r *MonitorRegistry) EnsureSubscribed(rivianVehicleID string)
- func (r *MonitorRegistry) For(uid uuid.UUID) *StateMonitor
- func (r *MonitorRegistry) SetBatteryCapacityHookFactory(factory func(uid uuid.UUID) func(vehicleID string, kwh float64))
- func (r *MonitorRegistry) SetChargeCloseHookFactory(factory func(uid uuid.UUID) ChargeCloseHook)
- func (r *MonitorRegistry) SetDriveCloseHookFactory(factory func(uid uuid.UUID) DriveCloseHook)
- func (r *MonitorRegistry) SetElevationLookup(e ElevationLookup)
- func (r *MonitorRegistry) SetLiveStateStoreFactory(factory func(uid uuid.UUID) LiveStateStore)
- func (r *MonitorRegistry) SetParent(ctx context.Context)
- func (r *MonitorRegistry) SetRouteFiller(rf RouteFiller)
- func (r *MonitorRegistry) SetSessionRehydrator(fn func(ctx context.Context, uid uuid.UUID) bool)
- func (r *MonitorRegistry) SetSleepObserver(o SleepObserver)
- func (r *MonitorRegistry) SetSleepSafe(fn func(vehicleID string) bool)
- func (r *MonitorRegistry) Start(ctx context.Context, uid uuid.UUID) *StateMonitor
- func (r *MonitorRegistry) Stop(uid uuid.UUID)
- func (r *MonitorRegistry) Unsubscribe(rivianVehicleID string)
- type NetworkPreference
- type ParallaxRawFrame
- type PendingMFA
- type PlanTripInput
- type PlanTripWaypoint
- type PlannedWaypoint
- type PriceLookup
- type RateLimiter
- type RedisLiveStateStore
- func (s *RedisLiveStateStore) Delete(ctx context.Context, vehicleID string) error
- func (s *RedisLiveStateStore) GetState(ctx context.Context, vehicleID string) (*State, error)
- func (s *RedisLiveStateStore) Load(ctx context.Context, vehicleID string) (LiveStateSnapshot, bool, error)
- func (s *RedisLiveStateStore) PutState(ctx context.Context, vehicleID string, st *State, ttl time.Duration) error
- func (s *RedisLiveStateStore) Save(ctx context.Context, vehicleID string, snap LiveStateSnapshot, ...) error
- type Replayer
- type RouteFiller
- type Session
- type SleepObserver
- type State
- type StateCallback
- type StateMonitor
- func (m *StateMonitor) ActiveDrive(vehicleID string) *LiveDrive
- func (m *StateMonitor) AllVehicleInfo() []Vehicle
- func (m *StateMonitor) EnsureSubscribed(vehicleID string)
- func (m *StateMonitor) Latest(vehicleID string) (*State, time.Time)
- func (m *StateMonitor) LatestLiveSession(vehicleID string) *LiveSession
- func (m *StateMonitor) PackKWhFor(vehicleID string) float64
- func (m *StateMonitor) Prime(vehicleID string, st *State)
- func (m *StateMonitor) RefreshVehicleInfo(ctx context.Context) error
- func (m *StateMonitor) RemoteLatest(ctx context.Context, vehicleID string) *State
- func (m *StateMonitor) SetBatteryCapacityHook(hook func(vehicleID string, kwh float64))
- func (m *StateMonitor) SetChargeCloseHook(h ChargeCloseHook)
- func (m *StateMonitor) SetDriveCloseHook(h DriveCloseHook)
- func (m *StateMonitor) SetElevationLookup(e ElevationLookup)
- func (m *StateMonitor) SetLiveStateStore(s LiveStateStore)
- func (m *StateMonitor) SetPriceLookup(fn PriceLookup)
- func (m *StateMonitor) SetRouteFiller(r RouteFiller)
- func (m *StateMonitor) SetSleepObserver(o SleepObserver)
- func (m *StateMonitor) SetSleepSafe(fn func(vehicleID string) bool)
- func (m *StateMonitor) SetStores(samplesStore *samples.Store, drivesStore *drives.Store, ...)
- func (m *StateMonitor) Start(ctx context.Context)
- func (m *StateMonitor) Unsubscribe(vehicleID string)
- func (m *StateMonitor) VehicleInfo(vehicleID string) *Vehicle
- type StubClient
- type TirePressure
- type TripPlan
- type TripRoute
- type UpstreamError
- type Vehicle
- type VehicleImage
Constants ¶
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.
const DefaultChargingEndpoint = "https://rivian.com/api/gql/chrg/user/graphql"
DefaultChargingEndpoint hosts getLiveSessionHistory and friends.
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.
const DefaultClientVersion = "3.6.0-3989"
DefaultClientVersion is paired with DefaultClientName; keep them in sync to avoid a never-shipped hybrid fingerprint.
const DefaultEndpoint = "https://rivian.com/api/gql/gateway/graphql"
DefaultEndpoint is the Rivian Owner App GraphQL gateway. Unofficial.
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.
const DefaultUserAgent = "RivianApp/4400 CFNetwork/1498.700.2 Darwin/23.6.0"
DefaultUserAgent matches the iOS app verbatim; the gateway 403s on modified UAs.
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.
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.
const FeatureStatusAvailable = "AVAILABLE"
FeatureStatusAvailable is the only supportedFeatures status the app treats as "on" (per UserVehicleKt.isFeatureSupported).
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.
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 ¶
var CaptureRVMs = []string{
rvmTrailer,
rvmLowVoltBatt, rvmOtaState, rvmOtaDeploy, 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 is a nested wrapper still on the raw default case. Graduate each into the base list above once the preview logs confirm the mapping.
var ErrMFARequired = errors.New("rivian: MFA code required")
ErrMFARequired signals an OTP challenge; resubmit Login on the same client with Credentials.OTP populated.
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.
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).
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."
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.
var ErrUpstreamPaused = errors.New("rivian: upstream paused by operator")
ErrUpstreamPaused is returned when the operator kill switch is on.
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 ¶
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 IsAuthFlavoredReason ¶ added in v0.20.0
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 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").
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:
- rivianHydrateMW restoring the request user's session into a shared LiveClient — last-write-wins across concurrent users.
- 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 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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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"`
// 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"`
// 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 (*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 ¶
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) IntrospectEnum ¶ added in v0.19.4
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) 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) 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) 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 ¶
State returns the current vehicle snapshot. Battery %, distances km, temps °C.
func (*LiveClient) StateRaw ¶
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 []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.
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) SupportedFeatures ¶ added in v0.27.0
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
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) 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
// 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.
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) 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) 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
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) 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 ¶
NetworkPreference biases the planner toward or away from a charging network. Preference is an integer rank: lower = more preferred.
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 — the app's protobuf classes are R8-obfuscated and the nested layouts don't read cleanly from smali).
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 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
// 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 ¶
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 ¶
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) GetState ¶ added in v0.27.2
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 ¶
func (s *RedisLiveStateStore) Load(ctx context.Context, vehicleID string) (LiveStateSnapshot, bool, error)
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 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
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) Drives ¶ added in v0.30.4
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.
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 SleepObserver ¶ added in v0.31.2
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 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"`
// 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"`
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.
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) 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.
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) 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) 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) 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 ¶
func (*StubClient) Login(context.Context, Credentials) error
Login returns ErrNotImplemented.
type TirePressure ¶ added in v0.27.4
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
}
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.