UNPKG

homebridge-mopar

Version:

Homebridge plugin for Mopar vehicles (Chrysler, Dodge, Jeep, Ram, Fiat, Alfa Romeo) with Uconnect

1,236 lines (1,063 loc) 46.8 kB
/** * Homebridge Mopar Plugin * * Supports all Mopar vehicles (Chrysler, Dodge, Jeep, Ram, Fiat, Alfa Romeo) * Uses automated login with Puppeteer for reliable authentication */ const fs = require('fs').promises; const path = require('path'); const MoparAuth = require('./auth'); const MoparAPI = require('./api'); const ConfigValidator = require('./config-validator'); const RateLimiter = require('./rate-limiter'); const PLATFORM_NAME = 'Mopar'; const PLUGIN_NAME = 'homebridge-mopar'; let Service, Characteristic, Accessory, UUIDGen; module.exports = (homebridge) => { Service = homebridge.hap.Service; Characteristic = homebridge.hap.Characteristic; Accessory = homebridge.platformAccessory; UUIDGen = homebridge.hap.uuid; homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, MoparPlatform); }; class MoparPlatform { constructor(log, config, api) { this.log = log; this.config = config; this.api = api; this.accessories = []; // Configuration this.email = config.email; this.password = config.password; this.pin = config.pin; this.debugMode = config.debug || false; // Authentication and API clients this.auth = null; this.moparAPI = null; // Rate limiter for API protection this.rateLimiter = new RateLimiter(); // Login mutex to prevent concurrent authentication attempts this.loginInProgress = false; this.loginPromise = null; // Cache file path in Homebridge's persist directory this.cacheDir = api?.user?.storagePath() || '/var/lib/homebridge'; this.vehicleCacheFile = path.join(this.cacheDir, 'homebridge-mopar-vehicles.json'); // Background retry state this.backgroundRetryActive = false; this.backgroundRetryInterval = null; this.backgroundRetryCount = 0; if (api) { api.on('didFinishLaunching', async () => { this.log('Mopar plugin finished launching'); if (this.debugMode) { this.log('Debug logging enabled'); } await this.initialize(); }); } } // Debug logging helper debug(message) { if (this.debugMode) { this.log(`[DEBUG] ${message}`); } } async initialize() { try { // Validate configuration with comprehensive validator const validation = ConfigValidator.validate(this.config); if (!validation.valid) { ConfigValidator.logErrors(validation.errors, this.log); this.log.error(''); this.log.error('Example configuration:'); this.log.error('{'); this.log.error(' "platform": "Mopar",'); this.log.error(' "name": "Mopar",'); this.log.error(' "email": "your-email@example.com",'); this.log.error(' "password": "your-password",'); this.log.error(' "pin": "1234"'); this.log.error('}'); return; } this.log('Authenticating with Mopar.com...'); // Initialize authentication this.auth = new MoparAuth(this.email, this.password, this.log.bind(this), this.debugMode); // Login and get cookies const cookies = await this.auth.login(); this.log('Authentication successful!'); // Debug: log cookie names before passing to API const cookieNames = Object.keys(cookies); this.debug(`Passing ${cookieNames.length} cookies to API`); const gltCookies = cookieNames.filter((name) => name.startsWith('glt_')); this.debug(`glt_ cookies found: ${gltCookies.join(', ')}`); // Initialize API client with cookies this.moparAPI = new MoparAPI(cookies, this.log.bind(this), this.debugMode); // Initialize API session this.log('Initializing API session...'); await this.moparAPI.initialize(); // Discover vehicles await this.discoverVehicles(); // Schedule cookie refresh (every 20 hours) this.scheduleCookieRefresh(); } catch (error) { // User-friendly error messages if (error.message.includes('Cannot reach Mopar.com') || error.code === 'ENOTFOUND') { this.log.error('========================================'); this.log.error('CANNOT REACH MOPAR.COM'); this.log.error('========================================'); this.log.error('Please check your internet connection'); this.log.error('Verify you can access https://www.mopar.com in your browser'); } else if (error.message.includes('Login failed') || error.message.includes('credentials')) { this.log.error('========================================'); this.log.error('LOGIN FAILED'); this.log.error('========================================'); this.log.error('Please verify your Mopar.com credentials in config.json:'); this.log.error('- Email address must be correct'); this.log.error('- Password must match your Mopar.com account'); this.log.error('- Test login at https://www.mopar.com/en-us/sign-in.html'); } else if (error.message.includes('Profile request failed') || error.message.includes('Unauthorized')) { this.log.error('========================================'); this.log.error('SESSION ERROR'); this.log.error('========================================'); this.log.error('Mopar API rejected the session'); this.log.error('This is usually temporary - the plugin will retry automatically'); this.log.error('If this persists, restart Homebridge'); } else { this.log.error('========================================'); this.log.error('INITIALIZATION FAILED'); this.log.error('========================================'); this.log.error(`Error: ${error.message}`); this.log.error('Please check your credentials and restart Homebridge'); this.log.error('Enable debug mode in config for more details'); } this.debug(`Full error: ${error.stack}`); } } async discoverVehicles() { try { this.log('Discovering vehicles...'); // Check for cached vehicles first for faster startup const cachedVehicles = await this.loadVehicleCache(); const hasCache = cachedVehicles && cachedVehicles.length > 0; // Try API with reduced retries for faster startup const vehicles = await this.getVehiclesWithFastRetry(); if (vehicles.length === 0) { if (hasCache) { this.log.warn('API returned no vehicles, using cache and starting background refresh...'); this.log(`Using ${cachedVehicles.length} cached vehicle(s)`); cachedVehicles.forEach((vehicle) => { this.addVehicleAccessory(vehicle); }); // Start background retry to refresh when API becomes available this.startBackgroundRefresh(); return; } else { this.log.warn('No vehicles from API and no cache available'); return; } } // Success! Stop any background refresh and save to cache this.stopBackgroundRefresh(); this.log(`Found ${vehicles.length} vehicle(s)`); await this.saveVehicleCache(vehicles); vehicles.forEach((vehicle) => { this.addVehicleAccessory(vehicle); }); } catch (error) { // Use API's friendly error logging if available if (this.moparAPI && this.moparAPI.logFriendlyError) { this.moparAPI.logFriendlyError('Vehicle discovery', error); } else { this.log.error('Failed to discover vehicles:', error.message); } // Try cache as last resort const cachedVehicles = await this.loadVehicleCache(); if (cachedVehicles && cachedVehicles.length > 0) { this.log(`Using ${cachedVehicles.length} cached vehicle(s) after error`); cachedVehicles.forEach((vehicle) => { this.addVehicleAccessory(vehicle); }); // Start background retry this.startBackgroundRefresh(); } } } async getVehiclesWithFastRetry() { // Faster retry logic for startup - try 3 times with shorter delays const maxAttempts = 3; const retryDelay = 2000; // 2 seconds for (let attempt = 1; attempt <= maxAttempts; attempt++) { if (attempt > 1) { this.debug(`Quick retry ${attempt}/${maxAttempts}...`); await new Promise((resolve) => setTimeout(resolve, retryDelay)); } try { // Use quick method without built-in retries for faster startup const vehicles = await this.moparAPI.getVehiclesQuick(); if (vehicles.length > 0) { this.log(`Found ${vehicles.length} vehicle(s) on quick attempt ${attempt}`); return vehicles; } this.debug(`Quick attempt ${attempt}: empty response`); } catch (error) { this.debug(`Quick attempt ${attempt} failed: ${error.message}`); } } this.debug('Quick discovery returned no vehicles, will use cache if available'); return []; } startBackgroundRefresh() { if (this.backgroundRetryActive) { this.log('Background refresh already running'); return; } this.backgroundRetryActive = true; this.backgroundRetryCount = 0; this.log('Starting background API refresh (will retry for up to 1 hour)'); const MAX_BACKGROUND_RETRIES = 12; // 12 attempts × 5 min = 1 hour const attemptRefresh = async () => { if (!this.backgroundRetryActive) return; this.backgroundRetryCount++; // Stop after max retries if (this.backgroundRetryCount > MAX_BACKGROUND_RETRIES) { this.log.warn(`Background refresh: Max retries reached (${MAX_BACKGROUND_RETRIES} attempts), giving up.`); this.log.warn('Vehicle data will be refreshed on next Homebridge restart or scheduled cookie refresh.'); this.stopBackgroundRefresh(); return; } try { this.log( `Background refresh: Attempt ${this.backgroundRetryCount}/${MAX_BACKGROUND_RETRIES} - Performing fresh login...` ); // Fresh Puppeteer login to get new cookies and establish new backend session const cookies = await this.auth.login(); this.moparAPI.setCookies(cookies); await this.moparAPI.initialize(); // Calls getProfile() internally const vehicles = await this.moparAPI.getVehicles(); if (vehicles.length > 0) { this.log(`Background refresh: Success! Found ${vehicles.length} vehicle(s)`); await this.saveVehicleCache(vehicles); // Update existing accessories with fresh data vehicles.forEach((vehicle) => { const accessory = this.accessories.find((acc) => acc.context.vehicle.vin === vehicle.vin); if (accessory) { accessory.context.vehicle = vehicle; this.log(`Updated data for ${vehicle.year} ${vehicle.make} ${vehicle.model}`); } }); // Stop background refresh since we succeeded this.stopBackgroundRefresh(); } else { this.log('Background refresh: API still returning empty, will retry with fresh login...'); } } catch (error) { if (error.message.includes('Cannot reach') || error.code === 'ENOTFOUND') { this.log.warn('Background refresh: Cannot reach Mopar API - Check internet connection'); } else if (error.message.includes('Login') || error.message.includes('credentials')) { this.log.error('Background refresh: Login failed - Check your credentials'); } else { this.log.warn(`Background refresh: Failed (${error.message}), will retry with fresh login...`); } this.debug(`Background refresh error: ${error.stack}`); } }; // Try immediately, then every 5 minutes attemptRefresh(); this.backgroundRetryInterval = setInterval(attemptRefresh, 5 * 60 * 1000); } stopBackgroundRefresh() { if (this.backgroundRetryInterval) { clearInterval(this.backgroundRetryInterval); this.backgroundRetryInterval = null; } if (this.backgroundRetryActive) { this.backgroundRetryActive = false; this.log('Background refresh stopped'); } } async saveVehicleCache(vehicles) { try { const cacheData = { timestamp: Date.now(), vehicles: vehicles, }; await fs.writeFile(this.vehicleCacheFile, JSON.stringify(cacheData, null, 2)); this.log('Vehicle data cached successfully'); } catch (error) { this.log.warn('Failed to cache vehicle data:', error.message); } } async loadVehicleCache() { try { const data = await fs.readFile(this.vehicleCacheFile, 'utf8'); const cache = JSON.parse(data); const ageHours = (Date.now() - cache.timestamp) / (1000 * 60 * 60); this.log(`Loaded cached vehicle data (${ageHours.toFixed(1)} hours old)`); return cache.vehicles; } catch (error) { // Cache file doesn't exist or is invalid - not an error return null; } } addVehicleAccessory(vehicle) { const name = `${vehicle.year} ${vehicle.make} ${vehicle.model}`; const uuid = UUIDGen.generate(vehicle.vin); let accessory = this.accessories.find((acc) => acc.UUID === uuid); if (!accessory) { this.log(`Adding: ${name}`); accessory = new Accessory(name, uuid); accessory.context.vehicle = vehicle; this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); this.accessories.push(accessory); } else { this.log(`Restoring: ${name}`); accessory.context.vehicle = vehicle; // Remove all services except AccessoryInformation const servicesToRemove = accessory.services.filter((s) => s.UUID !== Service.AccessoryInformation.UUID); servicesToRemove.forEach((s) => { this.log(`Removing old service: ${s.displayName || s.subtype || 'unknown'}`); accessory.removeService(s); }); // Initialize default states accessory.context.lockCurrentState = Characteristic.LockCurrentState.UNKNOWN; accessory.context.lockTargetState = Characteristic.LockTargetState.UNSECURED; accessory.context.unlockCurrentState = Characteristic.LockCurrentState.UNKNOWN; accessory.context.unlockTargetState = Characteristic.LockTargetState.SECURED; accessory.context.startEngineState = false; accessory.context.stopEngineState = true; } // Set accessory information const infoService = accessory.getService(Service.AccessoryInformation); if (infoService) { const model = vehicle.year + ' ' + vehicle.model; infoService .setCharacteristic(Characteristic.Manufacturer, vehicle.make) .setCharacteristic(Characteristic.Model, model) .setCharacteristic(Characteristic.SerialNumber, vehicle.vin); } // Configure services this.log(`Configuring services for ${name}...`); this.configureLockService(accessory, vehicle); this.configureStartService(accessory, vehicle); this.configureBatteryService(accessory, vehicle); this.configureDoorSensors(accessory, vehicle); this.configureHornLightsSwitch(accessory, vehicle); this.configureClimateSwitch(accessory, vehicle); const serviceCount = accessory.services.length; this.log(`${name} configured with ${serviceCount} services`); // Start periodic status updates this.startStatusUpdates(accessory, vehicle); } configureLockService(accessory, vehicle) { const name = vehicle.title || `${vehicle.year} ${vehicle.make} ${vehicle.model}`; // Lock service const lockDisplayName = `${name} Lock`; const lockService = accessory.addService(Service.LockMechanism, lockDisplayName, vehicle.vin + '-lock'); lockService.getCharacteristic(Characteristic.LockCurrentState).onGet(() => { return accessory.context.lockCurrentState || Characteristic.LockCurrentState.UNKNOWN; }); lockService .getCharacteristic(Characteristic.LockTargetState) .onGet(() => { return accessory.context.lockTargetState || Characteristic.LockTargetState.UNSECURED; }) .onSet(async (value) => { if (value === Characteristic.LockTargetState.SECURED) { // Prevent duplicate calls const now = Date.now(); const lastCall = accessory.context.lastLockCall || 0; if (now - lastCall < 10000) { this.log('Ignoring duplicate lock command (within 10s)'); return; } accessory.context.lastLockCall = now; accessory.context.lockTargetState = value; accessory.context.lockCurrentState = Characteristic.LockCurrentState.UNKNOWN; const success = await this.sendCommand(vehicle.vin, 'LOCK'); if (success) { accessory.context.lockCurrentState = Characteristic.LockCurrentState.SECURED; setTimeout(() => { accessory.context.lockTargetState = Characteristic.LockTargetState.UNSECURED; accessory.context.lockCurrentState = Characteristic.LockCurrentState.UNKNOWN; lockService.updateCharacteristic( Characteristic.LockCurrentState, Characteristic.LockCurrentState.UNKNOWN ); lockService.updateCharacteristic( Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED ); }, 3000); } else { accessory.context.lockTargetState = Characteristic.LockTargetState.UNSECURED; lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); } } }); // Unlock service const unlockDisplayName = `${name} Unlock`; const unlockService = accessory.addService(Service.LockMechanism, unlockDisplayName, vehicle.vin + '-unlock'); unlockService.getCharacteristic(Characteristic.LockCurrentState).onGet(() => { return accessory.context.unlockCurrentState || Characteristic.LockCurrentState.UNKNOWN; }); unlockService .getCharacteristic(Characteristic.LockTargetState) .onGet(() => { return accessory.context.unlockTargetState || Characteristic.LockTargetState.SECURED; }) .onSet(async (value) => { if (value === Characteristic.LockTargetState.UNSECURED) { // Prevent duplicate calls const now = Date.now(); const lastCall = accessory.context.lastUnlockCall || 0; if (now - lastCall < 10000) { this.log('Ignoring duplicate unlock command (within 10s)'); return; } accessory.context.lastUnlockCall = now; accessory.context.unlockTargetState = value; accessory.context.unlockCurrentState = Characteristic.LockCurrentState.UNKNOWN; const success = await this.sendCommand(vehicle.vin, 'UNLOCK'); if (success) { accessory.context.unlockCurrentState = Characteristic.LockCurrentState.UNSECURED; setTimeout(() => { accessory.context.unlockTargetState = Characteristic.LockTargetState.SECURED; accessory.context.unlockCurrentState = Characteristic.LockCurrentState.UNKNOWN; unlockService.updateCharacteristic( Characteristic.LockCurrentState, Characteristic.LockCurrentState.UNKNOWN ); unlockService.updateCharacteristic( Characteristic.LockTargetState, Characteristic.LockTargetState.SECURED ); }, 3000); } else { accessory.context.unlockTargetState = Characteristic.LockTargetState.SECURED; unlockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.SECURED); } } }); } configureStartService(accessory, vehicle) { const name = vehicle.title || `${vehicle.year} ${vehicle.make} ${vehicle.model}`; // Start engine service const startDisplayName = `${name} Start Engine`; const startService = accessory.addService(Service.Switch, startDisplayName, vehicle.vin + '-start'); startService .getCharacteristic(Characteristic.On) .onGet(() => { return accessory.context.startEngineState || false; }) .onSet(async (value) => { if (value) { const now = Date.now(); const lastCall = accessory.context.lastStartCall || 0; if (now - lastCall < 10000) { this.log('Ignoring duplicate start command (within 10s)'); return; } accessory.context.lastStartCall = now; accessory.context.startEngineState = true; const success = await this.startEngine(vehicle.vin); if (success) { setTimeout(() => { accessory.context.startEngineState = false; startService.updateCharacteristic(Characteristic.On, false); }, 3000); } else { accessory.context.startEngineState = false; startService.updateCharacteristic(Characteristic.On, false); } } }); // Stop engine service const stopDisplayName = `${name} Stop Engine`; const stopService = accessory.addService(Service.Switch, stopDisplayName, vehicle.vin + '-stop'); stopService .getCharacteristic(Characteristic.On) .onGet(() => { return accessory.context.stopEngineState || false; }) .onSet(async (value) => { if (!value) { const now = Date.now(); const lastCall = accessory.context.lastStopCall || 0; if (now - lastCall < 10000) { this.log('Ignoring duplicate stop command (within 10s)'); return; } accessory.context.lastStopCall = now; accessory.context.stopEngineState = false; const success = await this.stopEngine(vehicle.vin); if (success) { setTimeout(() => { accessory.context.stopEngineState = true; stopService.updateCharacteristic(Characteristic.On, true); }, 3000); } else { accessory.context.stopEngineState = true; stopService.updateCharacteristic(Characteristic.On, true); } } }); } configureDoorSensors(accessory, _vehicle) { // Front left door let frontLeftDoor = accessory.getServiceById(Service.ContactSensor, 'door-fl'); if (!frontLeftDoor) { frontLeftDoor = accessory.addService(Service.ContactSensor, 'Front Left Door', 'door-fl'); } frontLeftDoor .getCharacteristic(Characteristic.ContactSensorState) .onGet(() => accessory.context.doorStatus?.frontLeft === 'OPEN' ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED ); // Front right door let frontRightDoor = accessory.getServiceById(Service.ContactSensor, 'door-fr'); if (!frontRightDoor) { frontRightDoor = accessory.addService(Service.ContactSensor, 'Front Right Door', 'door-fr'); } frontRightDoor .getCharacteristic(Characteristic.ContactSensorState) .onGet(() => accessory.context.doorStatus?.frontRight === 'OPEN' ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED ); // Rear left door let rearLeftDoor = accessory.getServiceById(Service.ContactSensor, 'door-rl'); if (!rearLeftDoor) { rearLeftDoor = accessory.addService(Service.ContactSensor, 'Rear Left Door', 'door-rl'); } rearLeftDoor .getCharacteristic(Characteristic.ContactSensorState) .onGet(() => accessory.context.doorStatus?.rearLeft === 'OPEN' ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED ); // Rear right door let rearRightDoor = accessory.getServiceById(Service.ContactSensor, 'door-rr'); if (!rearRightDoor) { rearRightDoor = accessory.addService(Service.ContactSensor, 'Rear Right Door', 'door-rr'); } rearRightDoor .getCharacteristic(Characteristic.ContactSensorState) .onGet(() => accessory.context.doorStatus?.rearRight === 'OPEN' ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED ); // Trunk let trunk = accessory.getServiceById(Service.ContactSensor, 'trunk'); if (!trunk) { trunk = accessory.addService(Service.ContactSensor, 'Trunk', 'trunk'); } trunk .getCharacteristic(Characteristic.ContactSensorState) .onGet(() => accessory.context.doorStatus?.trunk === 'OPEN' ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED ); } configureBatteryService(accessory, vehicle) { const name = vehicle.title || `${vehicle.year} ${vehicle.make} ${vehicle.model}`; const displayName = `${name} Battery`; const batteryService = accessory.addService(Service.Battery, displayName, vehicle.vin + '-battery'); batteryService.getCharacteristic(Characteristic.BatteryLevel).onGet(() => accessory.context.batteryLevel || 100); batteryService.getCharacteristic(Characteristic.StatusLowBattery).onGet(() => { const level = accessory.context.batteryLevel || 100; return level < 20 ? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL; }); batteryService .getCharacteristic(Characteristic.ChargingState) .onGet(() => accessory.context.charging ? Characteristic.ChargingState.CHARGING : Characteristic.ChargingState.NOT_CHARGING ); } configureHornLightsSwitch(accessory, vehicle) { const name = vehicle.title || `${vehicle.year} ${vehicle.make} ${vehicle.model}`; const displayName = `${name} Horn & Lights`; const hornLightsService = accessory.addService(Service.Switch, displayName, vehicle.vin + '-horn'); hornLightsService .getCharacteristic(Characteristic.On) .onGet(() => false) .onSet(async (value) => { if (value) { const now = Date.now(); const lastCall = accessory.context.lastHornCall || 0; if (now - lastCall < 10000) { this.log('Ignoring duplicate horn command (within 10s)'); hornLightsService.updateCharacteristic(Characteristic.On, false); return; } accessory.context.lastHornCall = now; await this.hornAndLights(vehicle.vin); // Auto turn off after 1 second setTimeout(() => { hornLightsService.updateCharacteristic(Characteristic.On, false); }, 1000); } }); } configureClimateSwitch(accessory, vehicle) { const name = vehicle.title || `${vehicle.year} ${vehicle.make} ${vehicle.model}`; const displayName = `${name} Climate`; const climateService = accessory.addService(Service.Switch, displayName, vehicle.vin + '-climate'); climateService .getCharacteristic(Characteristic.On) .onGet(() => accessory.context.climateActive || false) .onSet(async (value) => { const now = Date.now(); const lastCall = accessory.context.lastClimateCall || 0; if (now - lastCall < 10000) { this.log('Ignoring duplicate climate command (within 10s)'); return; } accessory.context.lastClimateCall = now; if (value) { await this.setClimate(vehicle.vin, 72); accessory.context.climateActive = true; } else { accessory.context.climateActive = false; } }); } /** * Execute a command with automatic retry on authentication failure * @param {string} commandName - Name of the command for logging * @param {Function} commandFunc - Async function that executes the command * @returns {boolean} True if command succeeded */ async executeCommandWithRetry(commandName, commandFunc) { try { await this.ensureAuthenticated(); const result = await commandFunc(); return result; } catch (error) { // If session expired (403/401), force re-auth and retry once if (error.response?.status === 403 || error.response?.status === 401) { this.log.warn(`${commandName} failed due to expired session, re-authenticating and retrying...`); // Force fresh login by invalidating cached cookies this.auth.lastLogin = null; // ensureAuthenticated() will use the mutex to prevent concurrent logins await this.ensureAuthenticated(); try { const retryResult = await commandFunc(); this.log(`${commandName} succeeded on retry after re-authentication`); return retryResult; } catch (retryError) { this.logDetailedError(commandName, retryError, 'retry'); return false; } } // If server error (500), session cookies may have expired - re-auth and retry if (error.response?.status === 500) { this.log.warn( `${commandName} failed with server error (likely session expiry), re-authenticating and retrying...` ); this.logDetailedError(commandName, error); // Force fresh login - session cookies (JSESSIONID, etc.) likely expired this.auth.lastLogin = null; // ensureAuthenticated() will use the mutex to prevent concurrent logins await this.ensureAuthenticated(); try { const retryResult = await commandFunc(); this.log(`${commandName} succeeded on retry after re-authentication`); return retryResult; } catch (retryError) { this.log.error(`${commandName} failed on retry after server error`); this.logDetailedError(commandName, retryError, 'retry'); return false; } } this.logDetailedError(commandName, error); return false; } } logDetailedError(commandName, error, context = null) { const prefix = context ? `${commandName} failed on ${context}:` : `${commandName} failed:`; // Log basic error message this.log.error(`${prefix} ${error.message}`); // If it's an HTTP error, log additional details if (error.response) { const status = error.response.status; const statusText = error.response.statusText; this.log.error(` HTTP ${status} ${statusText}`); // Log URL if available if (error.config?.url) { this.log.error(` URL: ${error.config.url}`); } // Log response body if available and not too large if (error.response.data) { const dataStr = typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data); if (dataStr.length < 500) { this.log.error(` Response: ${dataStr}`); } else { this.log.error(` Response: ${dataStr.substring(0, 500)}... (truncated)`); } } // Log request body if available (useful for debugging 400 errors) if (error.config?.data) { const reqData = typeof error.config.data === 'string' ? error.config.data : JSON.stringify(error.config.data); if (reqData.length < 200) { this.debug(` Request body: ${reqData}`); } } } } async sendCommand(vin, action) { // Check rate limit const commandType = action.toLowerCase(); const rateLimitCheck = this.rateLimiter.canExecute(commandType, vin); if (!rateLimitCheck.allowed) { this.log.warn('========================================'); this.log.warn('RATE LIMIT EXCEEDED'); this.log.warn('========================================'); this.log.warn(`${action} command blocked to protect your Mopar account`); this.log.warn(`Limit: ${rateLimitCheck.limit}`); this.log.warn(`Please wait ${rateLimitCheck.waitMinutes} minute(s) before trying again`); this.log.warn('This prevents Mopar from blocking your account for excessive API use'); return false; } return this.executeCommandWithRetry(action, async () => { this.log(`Sending ${action} to ${vin}...`); const serviceRequestId = await this.moparAPI.sendCommand(vin, action, this.pin); this.log('Command sent, polling for status...'); const result = await this.moparAPI.pollCommandStatus(vin, action, serviceRequestId); if (result.success) { this.log(`${action} SUCCESS!`); return true; } else { this.log.error(`${action} failed: ${result.status}`); return false; } }); } async startEngine(vin) { // Check rate limit const rateLimitCheck = this.rateLimiter.canExecute('start', vin); if (!rateLimitCheck.allowed) { this.log.warn('========================================'); this.log.warn('RATE LIMIT EXCEEDED'); this.log.warn('========================================'); this.log.warn('Remote start blocked to protect your Mopar account'); this.log.warn(`Limit: ${rateLimitCheck.limit}`); this.log.warn(`Please wait ${rateLimitCheck.waitMinutes} minute(s) before trying again`); this.log.warn('Excessive remote starts can drain battery and may trigger account restrictions'); return false; } const accessory = this.accessories.find((acc) => acc.context.vehicle.vin === vin); return this.executeCommandWithRetry('Engine START', async () => { this.log(`Starting engine for ${vin}...`); const serviceRequestId = await this.moparAPI.startEngine(vin, this.pin); const result = await this.moparAPI.pollCommandStatus(vin, 'START', serviceRequestId); if (result.success) { this.log('Engine START SUCCESS!'); if (accessory) { accessory.context.engineRunning = true; } return true; } else { this.log.error(`Engine START failed: ${result.status || 'Unknown error'}`); if (accessory) { accessory.context.engineRunning = false; } return false; } }); } async stopEngine(vin) { // Check rate limit const rateLimitCheck = this.rateLimiter.canExecute('stop', vin); if (!rateLimitCheck.allowed) { this.log.warn(`Stop engine command rate limited - please wait ${rateLimitCheck.waitMinutes} minute(s)`); return false; } const accessory = this.accessories.find((acc) => acc.context.vehicle.vin === vin); return this.executeCommandWithRetry('Engine STOP', async () => { this.log(`Stopping engine for ${vin}...`); const serviceRequestId = await this.moparAPI.stopEngine(vin, this.pin); const result = await this.moparAPI.pollCommandStatus(vin, 'STOP', serviceRequestId); if (result.success) { this.log('Engine STOP SUCCESS!'); if (accessory) { accessory.context.engineRunning = false; } return true; } else { this.log.error(`Engine STOP failed: ${result.status || 'Unknown error'}`); return false; } }); } async hornAndLights(vin) { // Check rate limit const rateLimitCheck = this.rateLimiter.canExecute('hornLights', vin); if (!rateLimitCheck.allowed) { this.log.warn(`Horn and lights command rate limited - please wait ${rateLimitCheck.waitMinutes} minute(s)`); this.log.warn(`Limit: ${rateLimitCheck.limit}`); return false; } return this.executeCommandWithRetry('Horn and Lights', async () => { this.log(`Activating horn and lights for ${vin}...`); const serviceRequestId = await this.moparAPI.hornAndLights(vin); const result = await this.moparAPI.pollCommandStatus(vin, 'HORNLIGHTS', serviceRequestId); if (result.success) { this.log('Horn and lights SUCCESS!'); return true; } else { this.log.error(`Horn and lights failed: ${result.status || 'Unknown error'}`); return false; } }); } async setClimate(vin, temperature) { // Check rate limit const rateLimitCheck = this.rateLimiter.canExecute('climate', vin); if (!rateLimitCheck.allowed) { this.log.warn(`Climate control command rate limited - please wait ${rateLimitCheck.waitMinutes} minute(s)`); this.log.warn(`Limit: ${rateLimitCheck.limit}`); return false; } return this.executeCommandWithRetry('Climate Control', async () => { this.log(`Setting climate to ${temperature}°F for ${vin}...`); const serviceRequestId = await this.moparAPI.setClimate(vin, this.pin, temperature); const result = await this.moparAPI.pollCommandStatus(vin, 'CLIMATE', serviceRequestId); if (result.success) { this.log(`Climate set to ${temperature}°F SUCCESS!`); return true; } else { this.log.error(`Climate failed: ${result.status || 'Unknown error'}`); return false; } }); } startStatusUpdates(accessory, vehicle) { // Update vehicle status every 5 minutes const updateInterval = 5 * 60 * 1000; const updateStatus = async () => { try { await this.ensureAuthenticated(); const status = await this.moparAPI.getVehicleStatus(vehicle.vin); // If status endpoint isn't available, skip updates silently if (!status || status.available === false) { return; } // Update door status if (status.doorStatus) { accessory.context.doorStatus = status.doorStatus; // Update contact sensors const frontLeftDoor = accessory.getService('door-fl'); if (frontLeftDoor) { frontLeftDoor.updateCharacteristic( Characteristic.ContactSensorState, status.doorStatus.frontLeft === 'OPEN' ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED ); } const frontRightDoor = accessory.getService('door-fr'); if (frontRightDoor) { frontRightDoor.updateCharacteristic( Characteristic.ContactSensorState, status.doorStatus.frontRight === 'OPEN' ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED ); } const rearLeftDoor = accessory.getService('door-rl'); if (rearLeftDoor) { rearLeftDoor.updateCharacteristic( Characteristic.ContactSensorState, status.doorStatus.rearLeft === 'OPEN' ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED ); } const rearRightDoor = accessory.getService('door-rr'); if (rearRightDoor) { rearRightDoor.updateCharacteristic( Characteristic.ContactSensorState, status.doorStatus.rearRight === 'OPEN' ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED ); } const trunk = accessory.getService('trunk'); if (trunk) { trunk.updateCharacteristic( Characteristic.ContactSensorState, status.doorStatus.trunk === 'OPEN' ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED ); } } // Update battery level if (status.batteryLevel !== undefined) { accessory.context.batteryLevel = status.batteryLevel; const batteryService = accessory.getService(Service.Battery); if (batteryService) { batteryService.updateCharacteristic(Characteristic.BatteryLevel, status.batteryLevel); batteryService.updateCharacteristic( Characteristic.StatusLowBattery, status.batteryLevel < 20 ? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL ); } } // Update lock status if (status.lockStatus) { accessory.context.lockStatus = status.lockStatus; const currentState = status.lockStatus === 'LOCKED' ? Characteristic.LockCurrentState.SECURED : Characteristic.LockCurrentState.UNSECURED; const lockService = accessory.getServiceById(Service.LockMechanism, vehicle.vin + '-lock'); if (lockService) { lockService.updateCharacteristic(Characteristic.LockCurrentState, currentState); } const unlockService = accessory.getServiceById(Service.LockMechanism, vehicle.vin + '-unlock'); if (unlockService) { unlockService.updateCharacteristic(Characteristic.LockCurrentState, currentState); } } // Update engine status (for informational purposes only) if (status.engineRunning !== undefined) { accessory.context.engineRunning = status.engineRunning; } } catch (error) { this.log.error('Failed to update vehicle status:', error.message); } }; // Initial update setTimeout(() => updateStatus(), 10000); // Wait 10 seconds after startup // Periodic updates setInterval(updateStatus, updateInterval); } async ensureAuthenticated() { if (!this.auth.areCookiesValid()) { // If login is already in progress, wait for it to complete if (this.loginInProgress && this.loginPromise) { this.debug('Login already in progress, waiting...'); await this.loginPromise; return; } // Start new login and set mutex this.loginInProgress = true; this.loginPromise = (async () => { try { this.log('Session expired, re-authenticating...'); const cookies = await this.auth.login(); this.moparAPI.setCookies(cookies); await this.moparAPI.initialize(); } finally { this.loginInProgress = false; this.loginPromise = null; } })(); await this.loginPromise; } } scheduleCookieRefresh() { // Session refresh every 50 minutes (before 1hr session expiry) // This prevents HTTP 500 errors from expired session cookies const sessionRefreshInterval = 50 * 60 * 1000; // 50 minutes in ms setInterval(async () => { try { this.log('Scheduled session refresh (50min)...'); // If login is already in progress, skip this refresh if (this.loginInProgress && this.loginPromise) { this.log('Login already in progress, skipping session refresh'); await this.loginPromise; return; } // Use mutex to prevent concurrent logins this.loginInProgress = true; this.loginPromise = (async () => { try { const cookies = await this.auth.login(); this.moparAPI.setCookies(cookies); // Wait for cookies to settle and session to propagate on Mopar backend this.debug('Waiting 2 seconds for session to propagate...'); await new Promise((resolve) => setTimeout(resolve, 2000)); await this.moparAPI.initialize(); this.log('Session refresh successful'); } finally { this.loginInProgress = false; this.loginPromise = null; } })(); await this.loginPromise; } catch (error) { if (error.message.includes('Cannot reach') || error.code === 'ENOTFOUND') { this.log.warn('Session refresh failed: Cannot reach Mopar.com - Will retry later'); } else if (error.message.includes('timeout')) { this.log.warn('Session refresh timed out - Mopar.com may be slow, will retry later'); } else { this.log.error('Session refresh failed:', error.message); this.log.error('Your next command will trigger a fresh login'); } this.debug(`Session refresh error: ${error.stack}`); this.loginInProgress = false; this.loginPromise = null; } }, sessionRefreshInterval); // Cookie refresh every 20 hours (for long-term cookie validity) const cookieRefreshInterval = 20 * 60 * 60 * 1000; // 20 hours in ms setInterval(async () => { try { this.log('Scheduled cookie refresh (20hr)...'); // If login is already in progress, skip this refresh if (this.loginInProgress && this.loginPromise) { this.log('Login already in progress, skipping cookie refresh'); await this.loginPromise; return; } // Use mutex to prevent concurrent logins this.loginInProgress = true; this.loginPromise = (async () => { try { const cookies = await this.auth.login(); this.moparAPI.setCookies(cookies); // Wait for cookies to settle and session to propagate on Mopar backend this.debug('Waiting 2 seconds for session to propagate...'); await new Promise((resolve) => setTimeout(resolve, 2000)); await this.moparAPI.initialize(); this.log('Cookie refresh successful'); } finally { this.loginInProgress = false; this.loginPromise = null; } })(); await this.loginPromise; } catch (error) { if (error.message.includes('Cannot reach') || error.code === 'ENOTFOUND') { this.log.warn('Cookie refresh failed: Cannot reach Mopar.com - Will retry in 20 hours'); } else if (error.message.includes('timeout')) { this.log.warn('Cookie refresh timed out - Will retry in 20 hours'); } else { this.log.error('Cookie refresh failed:', error.message); this.log.error('Your next command will trigger a fresh login if needed'); } this.debug(`Cookie refresh error: ${error.stack}`); this.loginInProgress = false; this.loginPromise = null; } }, cookieRefreshInterval); this.log('Scheduled automatic session refresh every 50 minutes'); this.log('Scheduled automatic cookie refresh every 20 hours'); } configureAccessory(accessory) { this.accessories.push(accessory); } }