/**
* Rivian Connect App for Hubitat
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
* for the specific language governing permissions and limitations under the License.
*
* Handles authentication, vehicle discovery, and token management for Rivian vehicles.
*
* Inspired by the Home Assistant Rivian integration:
* https://github.com/bretterer/home-assistant-rivian (Apache 2.0 License)
*
*/
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import groovy.transform.Field
@Field static final String VERSION = "1.1.3"
@Field static final String RIVIAN_API_URL = "https://rivian.com/api/gql/gateway/graphql"
@Field static final String DRIVER_NAME = "Rivian Vehicle"
@Field static final String DRIVER_NAMESPACE = "jlupien"
definition(
name: "Rivian Connect",
namespace: "jlupien",
author: "Jeff Lupien",
description: "Connect and monitor your Rivian vehicles",
category: "Convenience",
iconUrl: "",
iconX2Url: "",
singleInstance: true
)
preferences {
page(name: "mainPage")
page(name: "credentialsPage")
page(name: "authProgressPage")
page(name: "otpPage")
page(name: "vehicleSelectPage")
page(name: "applyVehiclesPage")
}
// Set to true for debug logging during development
@Field static final boolean DEV_MODE = false
// ==================== Pages ====================
def mainPage() {
dynamicPage(name: "mainPage", title: "Rivian Connect", install: true, uninstall: true) {
section {
paragraph "Rivian Connect v${VERSION}"
paragraph "Connect your Rivian vehicles to Hubitat for monitoring and control."
}
if (state.authenticated) {
section("Status") {
paragraph "✓ Authenticated to Rivian"
if (state.vehicles) {
paragraph "Found ${state.vehicles.size()} vehicle(s)"
}
}
section("Configuration") {
href "vehicleSelectPage", title: "Vehicle Selection",
description: "Select which vehicles to add to Hubitat"
}
section("Actions") {
input "refreshVehicles", "button", title: "Refresh Vehicle List"
input "refreshTokens", "button", title: "Refresh Authentication"
input "logout", "button", title: "Logout"
}
} else {
section("Authentication") {
href "credentialsPage", title: "Login to Rivian",
description: "Enter your Rivian account credentials"
}
}
section("Installed Vehicles") {
def devices = getChildDevices()
if (devices) {
devices.each { device ->
paragraph "• ${device.displayName}"
}
} else {
paragraph "No vehicles installed yet"
}
}
}
}
def credentialsPage() {
logDebug "credentialsPage() called"
// Clear any previous errors when entering this page fresh
if (!settings.rivianEmail) {
state.loginError = null
}
dynamicPage(name: "credentialsPage", title: "Rivian Login", nextPage: "authProgressPage") {
section("Credentials") {
paragraph "Enter your Rivian account credentials. Your password is not stored - only authentication tokens are saved."
input "rivianEmail", "email", title: "Email", required: true, submitOnChange: false
input "rivianPassword", "password", title: "Password", required: true, submitOnChange: false
}
section {
paragraph "Click Next to login with these credentials."
}
if (state.loginError) {
section("Error") {
paragraph "${state.loginError}"
}
}
}
}
def authProgressPage() {
logInfo "authProgressPage() - Starting authentication"
logDebug "Email: ${settings.rivianEmail}, Password length: ${settings.rivianPassword?.length() ?: 0}"
// Perform synchronous-ish login attempt
state.authInProgress = true
state.loginError = null
state.otpRequired = false
// We need to do this synchronously for the page flow to work
// First create CSRF token
def csrfResult = createCsrfTokenSync()
logDebug "CSRF result: ${csrfResult}"
if (!csrfResult.success) {
state.loginError = "Failed to initialize session: ${csrfResult.error}"
state.authInProgress = false
logError "CSRF failed: ${csrfResult.error}"
return dynamicPage(name: "authProgressPage", title: "Login Failed", nextPage: "credentialsPage") {
section {
paragraph "Failed to initialize session. Please try again."
paragraph "Error: ${csrfResult.error}"
}
}
}
// Now authenticate
def authResult = authenticateSync(settings.rivianEmail, settings.rivianPassword)
logDebug "Auth result: ${authResult}"
state.authInProgress = false
if (authResult.success) {
state.authenticated = true
logInfo "Authentication successful!"
// Discover vehicles
discoverVehicles()
return dynamicPage(name: "authProgressPage", title: "Login Successful", nextPage: "mainPage") {
section {
paragraph "✓ Successfully logged in to Rivian!"
paragraph "Click Next to continue."
}
}
} else if (authResult.otpRequired) {
state.otpRequired = true
state.otpToken = authResult.otpToken
logInfo "OTP required"
return dynamicPage(name: "authProgressPage", title: "Verification Required", nextPage: "otpPage") {
section {
paragraph "Two-factor authentication is required."
paragraph "A verification code has been sent to your phone or email."
paragraph "Click Next to enter the code."
}
}
} else {
state.loginError = authResult.error ?: "Login failed"
logError "Authentication failed: ${authResult.error}"
return dynamicPage(name: "authProgressPage", title: "Login Failed", nextPage: "credentialsPage") {
section {
paragraph "Login failed. Please check your credentials and try again."
paragraph "Error: ${authResult.error}"
}
}
}
}
def otpPage() {
logDebug "otpPage() called"
// If we have an OTP code submitted, validate it
if (settings.otpCode && state.otpToken && !state.authenticated) {
logInfo "Validating OTP code"
def result = validateOtpSync(settings.rivianEmail, settings.otpCode, state.otpToken)
if (result.success) {
state.authenticated = true
state.otpRequired = false
logInfo "OTP validation successful!"
// Discover vehicles
discoverVehicles()
return dynamicPage(name: "otpPage", title: "Verification Successful", nextPage: "mainPage") {
section {
paragraph "✓ Successfully verified!"
paragraph "Click Next to continue."
}
}
} else {
state.otpError = result.error
logError "OTP validation failed: ${result.error}"
}
}
dynamicPage(name: "otpPage", title: "Verification Code", nextPage: "otpPage") {
section {
paragraph "Enter the verification code sent to your phone"
input "otpCode", "text", title: "Verification Code", required: true, submitOnChange: false
}
section {
paragraph "Click Next after entering the code."
}
if (state.otpError) {
section("Error") {
paragraph "${state.otpError}"
}
}
}
}
def vehicleSelectPage() {
logDebug "vehicleSelectPage() called"
logDebug "state.vehicles = ${state.vehicles}"
logDebug "state.vehicles size = ${state.vehicles?.size()}"
// Auto-discover vehicles if not yet loaded
if (!state.vehicles || state.vehicles.size() == 0) {
logDebug "vehicleSelectPage: No vehicles in state, discovering..."
discoverVehicles()
logDebug "After discovery, state.vehicles = ${state.vehicles}"
}
dynamicPage(name: "vehicleSelectPage", title: "Vehicle Selection", nextPage: "applyVehiclesPage", install: false) {
if (!state.vehicles || state.vehicles.size() == 0) {
section {
paragraph "No vehicles found. Please check your Rivian account or try logging in again."
}
} else {
section("Select Vehicles") {
paragraph "Select which vehicles to add to Hubitat, then click Next."
def options = [:]
state.vehicles.each { id, vehicle ->
options[id] = "${vehicle.name ?: vehicle.model} (${vehicle.vin})"
}
input "selectedVehicles", "enum", title: "Vehicles", options: options,
multiple: true, required: false, submitOnChange: true
}
// Show which devices will be created/removed
def selected = settings.selectedVehicles ?: []
def existing = getChildDevices().collect { it.getDataValue("vehicleId") }
def toCreate = selected.findAll { !existing.contains(it) }
def toRemove = existing.findAll { !selected.contains(it) }
if (toCreate || toRemove) {
section("Pending Changes") {
if (toCreate) {
toCreate.each { id ->
def v = state.vehicles[id]
paragraph "➕ Will create: ${v?.name ?: 'Vehicle'}"
}
}
if (toRemove) {
toRemove.each { id ->
def device = getChildDevices().find { it.getDataValue("vehicleId") == id }
paragraph "➖ Will remove: ${device?.displayName ?: 'Vehicle'}"
}
}
paragraph "Click Next to apply these changes"
}
}
}
}
}
def applyVehiclesPage() {
logDebug "applyVehiclesPage() called"
// Don't create devices here - that happens in installed()/updated() after Done is clicked
// Just show a preview of what will happen
def selected = settings.selectedVehicles ?: []
def existingDeviceIds = getChildDevices().collect { it.getDataValue("vehicleId") }
def toAdd = selected.findAll { !existingDeviceIds.contains(it) }
def toRemove = existingDeviceIds.findAll { !selected.contains(it) }
dynamicPage(name: "applyVehiclesPage", title: "Confirm Changes", nextPage: "mainPage") {
section {
paragraph "Important: You must click Done on the next page to save your changes and create the vehicle devices."
}
if (toAdd || toRemove) {
section("Pending Changes") {
toAdd.each { vehicleId ->
def vehicle = state.vehicles[vehicleId]
paragraph "➕ Will add: ${vehicle?.name ?: vehicle?.model ?: vehicleId}"
}
toRemove.each { vehicleId ->
def device = getChildDevices().find { it.getDataValue("vehicleId") == vehicleId }
paragraph "➖ Will remove: ${device?.displayName ?: vehicleId}"
}
}
} else if (selected) {
section("No Changes") {
paragraph "Your vehicle selection hasn't changed."
}
} else {
section("No Vehicles Selected") {
paragraph "No vehicles will be installed."
}
}
section {
paragraph "Click Next to return to the main page, then click Done to apply changes."
}
}
}
// ==================== Button Handlers ====================
def appButtonHandler(btn) {
switch(btn) {
case "doLogin":
doLogin()
break
case "submitOtp":
submitOtp()
break
case "refreshVehicles":
discoverVehicles()
break
case "refreshTokens":
refreshTokens()
break
case "logout":
doLogout()
break
case "applyVehicleSelection":
applyVehicleSelection()
break
}
}
// ==================== Lifecycle ====================
def installed() {
logInfo "Rivian Connect installed"
initialize()
// Apply vehicle selection on first install
applyVehicleSelection()
}
def updated() {
logInfo "Rivian Connect updated"
unschedule()
initialize()
// Apply any pending vehicle selection changes
applyVehicleSelection()
}
def initialize() {
logInfo "Initializing Rivian Connect v${VERSION}"
// Schedule token refresh every 12 hours (at minute 0 of hours 0 and 12)
schedule("0 0 0,12 * * ?", "refreshTokens")
}
def uninstalled() {
logInfo "Rivian Connect uninstalled"
getChildDevices().each { deleteChildDevice(it.deviceNetworkId) }
}
// ==================== Authentication ====================
def doLogout() {
logInfo "Logging out"
// Delete all child devices
getChildDevices().each { device ->
logInfo "Removing vehicle device: ${device.displayName}"
deleteChildDevice(device.deviceNetworkId)
}
// Clear all state
state.authenticated = false
state.accessToken = null
state.refreshToken = null
state.userSessionToken = null
state.csrfToken = null
state.appSessionToken = null
state.vehicles = null
state.otpRequired = false
state.otpToken = null
state.loginError = null
// Clear all user settings
app.removeSetting("selectedVehicles")
app.removeSetting("otpCode")
app.removeSetting("rivianEmail")
app.removeSetting("rivianPassword")
logInfo "Logout complete - all devices and settings cleared"
}
def createCsrfTokenSync() {
logDebug "createCsrfTokenSync() called"
def query = """mutation CreateCSRFToken { createCsrfToken { __typename csrfToken appSessionToken } }"""
try {
def result = graphqlRequestSync(query, [:], "CreateCSRFToken", false)
if (result.error) {
logError "CSRF token creation failed: ${result.error}"
return [success: false, error: result.error]
}
def data = result.response?.data?.createCsrfToken
if (data?.csrfToken) {
state.csrfToken = data.csrfToken
state.appSessionToken = data.appSessionToken
logDebug "CSRF token created successfully"
return [success: true]
} else {
logError "Invalid CSRF response: ${result.response}"
return [success: false, error: "Invalid response from Rivian"]
}
} catch (e) {
logError "CSRF token exception: ${e.message}"
return [success: false, error: e.message]
}
}
def authenticateSync(String email, String password) {
logDebug "authenticateSync() called for ${email}"
def query = """mutation Login(\$email: String!, \$password: String!) { login(email: \$email, password: \$password) { __typename ... on MobileLoginResponse { accessToken refreshToken userSessionToken } ... on MobileMFALoginResponse { otpToken } } }"""
def variables = [email: email, password: password]
try {
def result = graphqlRequestSync(query, variables, "Login", false)
if (result.error) {
return [success: false, error: result.error]
}
def loginData = result.response?.data?.login
if (!loginData) {
logError "No login data in response: ${result.response}"
return [success: false, error: "Invalid response from Rivian"]
}
logDebug "Login response type: ${loginData.__typename}"
if (loginData.__typename == "MobileMFALoginResponse") {
return [success: false, otpRequired: true, otpToken: loginData.otpToken]
} else if (loginData.__typename == "MobileLoginResponse") {
state.accessToken = loginData.accessToken
state.refreshToken = loginData.refreshToken
state.userSessionToken = loginData.userSessionToken
state.lastTokenRefresh = now()
return [success: true]
} else {
return [success: false, error: "Unknown response type: ${loginData.__typename}"]
}
} catch (e) {
logError "Authentication exception: ${e.message}"
return [success: false, error: e.message]
}
}
def validateOtpSync(String email, String otp, String otpToken) {
logDebug "validateOtpSync() called"
def query = """
mutation LoginWithOTP(\$email: String!, \$otpCode: String!, \$otpToken: String!) {
loginWithOTP(email: \$email, otpCode: \$otpCode, otpToken: \$otpToken) {
__typename
... on MobileLoginResponse {
accessToken
refreshToken
userSessionToken
}
}
}
"""
def variables = [email: email, otpCode: otp, otpToken: otpToken]
try {
def result = graphqlRequestSync(query, variables, "LoginWithOTP", false)
if (result.error) {
return [success: false, error: result.error]
}
def loginData = result.response?.data?.loginWithOTP
if (loginData?.__typename == "MobileLoginResponse") {
state.accessToken = loginData.accessToken
state.refreshToken = loginData.refreshToken
state.userSessionToken = loginData.userSessionToken
state.lastTokenRefresh = now()
return [success: true]
} else {
return [success: false, error: "OTP validation failed"]
}
} catch (e) {
logError "OTP validation exception: ${e.message}"
return [success: false, error: e.message]
}
}
def refreshTokens() {
logInfo "Refreshing authentication tokens"
createCsrfToken { success ->
if (success) {
state.lastTokenRefresh = now()
logInfo "Tokens refreshed successfully"
} else {
logError "Token refresh failed"
}
}
}
// ==================== Vehicle Discovery ====================
def discoverVehicles() {
logInfo "Discovering vehicles"
def query = """query GetCurrentUser { currentUser { id vehicles { id name vin vas { vasVehicleId vehiclePublicKey } vehicle { model modelYear vehicleState { supportedFeatures { name status } } } } } }"""
def result = graphqlRequestSync(query, [:], "GetCurrentUser", true)
if (result.error) {
logError "Vehicle discovery failed: ${result.error}"
return
}
logDebug "Raw API response: ${result.response}"
def user = result.response?.data?.currentUser
logDebug "currentUser: ${user}"
logDebug "currentUser.vehicles: ${user?.vehicles}"
if (!user?.vehicles) {
logWarn "No vehicles found in response"
state.vehicles = [:]
return
}
state.vehicles = [:]
user.vehicles.each { v ->
logDebug "Processing vehicle entry: ${v}"
def vehicle = v.vehicle ?: [:]
def vehicleData = [
id: v.id,
name: v.name,
vin: v.vin,
model: vehicle.model ?: "Rivian",
modelYear: vehicle.modelYear,
vasId: v.vas?.vasVehicleId,
publicKey: v.vas?.vehiclePublicKey,
supportedFeatures: vehicle.vehicleState?.supportedFeatures?.findAll {
it.status == "AVAILABLE"
}?.collect { it.name } ?: []
]
logDebug "Storing vehicle data: ${vehicleData}"
state.vehicles[v.id] = vehicleData
}
logDebug "Final state.vehicles: ${state.vehicles}"
logInfo "Discovered ${state.vehicles.size()} vehicle(s)"
}
def applyVehicleSelection() {
logInfo "Applying vehicle selection"
def selected = settings.selectedVehicles ?: []
logDebug "Selected vehicles: ${selected}"
def existingDevices = getChildDevices()
logDebug "Existing devices: ${existingDevices.collect { it.getDataValue('vehicleId') }}"
// Remove devices for deselected vehicles FIRST
existingDevices.each { device ->
def vehicleId = device.getDataValue("vehicleId")
logDebug "Checking device ${device.displayName} with vehicleId ${vehicleId}"
if (!selected || !selected.contains(vehicleId)) {
logInfo "Removing deselected vehicle: ${device.displayName} (vehicleId: ${vehicleId})"
try {
deleteChildDevice(device.deviceNetworkId)
} catch (e) {
logError "Failed to delete device ${device.displayName}: ${e.message}"
}
}
}
// Create devices for selected vehicles
selected.each { vehicleId ->
def vehicle = state.vehicles[vehicleId]
if (vehicle) {
createVehicleDevice(vehicle)
} else {
logWarn "Vehicle ${vehicleId} not found in state.vehicles"
}
}
}
def createVehicleDevice(Map vehicle) {
def dni = "rivian-${vehicle.vin}"
def existingDevice = getChildDevice(dni)
if (existingDevice) {
logDebug "Vehicle device already exists: ${vehicle.name}"
return existingDevice
}
logInfo "Creating vehicle device: ${vehicle.name ?: vehicle.model}"
try {
def device = addChildDevice(
DRIVER_NAMESPACE,
DRIVER_NAME,
dni,
[
name: "Rivian ${vehicle.model}",
label: vehicle.name ?: "Rivian ${vehicle.model}",
isComponent: false
]
)
device.updateDataValue("vehicleId", vehicle.id)
device.updateDataValue("vin", vehicle.vin)
device.updateDataValue("model", vehicle.model)
device.updateDataValue("modelYear", vehicle.modelYear?.toString() ?: "")
device.updateDataValue("vasId", vehicle.vasId ?: "")
logInfo "Created vehicle device: ${device.displayName}"
return device
} catch (e) {
logError "Failed to create vehicle device: ${e.message}"
return null
}
}
// ==================== GraphQL API ====================
def graphqlRequestSync(String query, Map variables, String operationName, boolean authenticated) {
logDebug "graphqlRequestSync: ${operationName}"
def headers = [
"Content-Type": "application/json",
"User-Agent": "RivianConnect/1.0 Hubitat",
"Apollographql-Client-Name": "com.rivian.android.consumer"
]
if (authenticated && state.accessToken) {
headers["Authorization"] = "Bearer ${state.accessToken}"
}
if (state.csrfToken) {
headers["Csrf-Token"] = state.csrfToken
}
if (state.appSessionToken) {
headers["A-Sess"] = state.appSessionToken
}
if (state.userSessionToken) {
headers["U-Sess"] = state.userSessionToken
}
def body = [
query: query,
operationName: operationName
]
if (variables) {
body.variables = variables
}
def bodyJson = JsonOutput.toJson(body)
def params = [
uri: RIVIAN_API_URL,
headers: headers,
body: bodyJson,
contentType: "application/json",
requestContentType: "application/json",
timeout: 30
]
logDebug "Making sync request to ${RIVIAN_API_URL}"
logDebug "Headers: ${headers.findAll { k, v -> k != 'Authorization' }}"
logDebug "Request body: ${bodyJson}"
try {
def responseData = null
httpPost(params) { resp ->
logDebug "Response status: ${resp.status}"
if (resp.status == 200) {
responseData = resp.data
logDebug "Response data received"
} else {
logError "HTTP error: ${resp.status}"
return [error: "HTTP ${resp.status}", response: null]
}
}
if (responseData) {
// Check for GraphQL errors
if (responseData.errors) {
def errorMsg = responseData.errors[0]?.message ?: "GraphQL error"
logError "GraphQL error: ${errorMsg}"
return [error: errorMsg, response: responseData]
}
return [error: null, response: responseData]
} else {
return [error: "No response data", response: null]
}
} catch (groovyx.net.http.HttpResponseException e) {
logError "HTTP exception: ${e.statusCode} - ${e.message}"
try {
def errorBody = e.response?.data
logError "Error response body: ${errorBody}"
} catch (ex) {
logDebug "Could not read error body: ${ex.message}"
}
return [error: "HTTP ${e.statusCode}: ${e.message}", response: null]
} catch (e) {
logError "Request exception: ${e.class.name} - ${e.message}"
logError "Exception details: ${e}"
return [error: e.message, response: null]
}
}
// ==================== Child Device Communication ====================
def getTokens() {
// Called by child devices to get authentication tokens
return [
accessToken: state.accessToken,
refreshToken: state.refreshToken,
userSessionToken: state.userSessionToken,
csrfToken: state.csrfToken,
appSessionToken: state.appSessionToken
]
}
def refreshVehicle(String vehicleId) {
logDebug "Refreshing vehicle: ${vehicleId}"
// Note: Rivian has deprecated HTTP polling for vehicleState
// Vehicle data is now obtained via WebSocket subscriptions only
// This method now triggers the driver to reconnect its WebSocket
def device = getChildDevices().find { it.getDataValue("vehicleId") == vehicleId }
if (device) {
logDebug "Triggering WebSocket reconnect for ${device.displayName}"
device.connectWebSocket()
} else {
logWarn "No device found for vehicle ${vehicleId}"
}
}
// Phase 2: Vehicle Commands
def sendVehicleCommand(String vehicleId, String command, Map params = [:]) {
logInfo "Sending command ${command} to vehicle ${vehicleId}"
// TODO: Implement in Phase 2
logWarn "Vehicle commands not yet implemented (Phase 2)"
}
// ==================== Logging ====================
def logInfo(msg) { log.info "Rivian Connect: ${msg}" }
def logWarn(msg) { log.warn "Rivian Connect: ${msg}" }
def logError(msg) { log.error "Rivian Connect: ${msg}" }
def logDebug(msg) { if (DEV_MODE || settings.logEnable) log.debug "Rivian Connect: ${msg}" }