UNPKG

homebridge-smartone

Version:

Homebridge plugin for SmartOne devices

590 lines (495 loc) 19.8 kB
const axios = require("axios"); const fs = require("fs"); const path = require("path"); const DEFAULT_AUTH_URL = "https://auth-us-east-1.smartone-solutions.com/connect/token"; const DEFAULT_API_BASE_URL = "https://api-us-east-1.smartone-solutions.com"; const CLIENT_ID = "bdf1af15-da1f-4389-b3eb-111e2feb00b6"; const CLIENT_SECRET = ""; const REDIRECT_URI = "smartone://login"; let Service, Characteristic, api; // Global token refresh lock to prevent race conditions let tokenRefreshPromise = null; class SmartOnePlatform { constructor(log, config, api) { this.log = log; this.config = config; this.api = api; this.accessories = []; this.smartOneAPI = new SmartOneAPI( log, config.credentials?.refreshToken, config.credentials?.authUrl, config.credentials?.apiBaseUrl ); this.api.on('didFinishLaunching', () => { this.discoverDevices(); }); } configureAccessory(accessory) { this.log.info('Loading accessory from cache:', accessory.displayName); this.accessories.push(accessory); } async discoverDevices() { this.log.info('Discovering SmartOne devices...'); if (!this.config.devices || !Array.isArray(this.config.devices)) { this.log.warn('No devices configured'); return; } for (const deviceConfig of this.config.devices) { const uuid = this.api.hap.uuid.generate(deviceConfig.name); const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid); if (existingAccessory) { this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName); if (deviceConfig.type === 'thermostat') { new SmartOneThermostat(this.log, deviceConfig, this.api, existingAccessory, this.smartOneAPI); } else if (deviceConfig.type === 'doorlock') { new SmartOneDoorLock(this.log, deviceConfig, this.api, existingAccessory, this.smartOneAPI); } } else { this.log.info('Adding new accessory:', deviceConfig.name); const accessory = new this.api.platformAccessory(deviceConfig.name, uuid); if (deviceConfig.type === 'thermostat') { new SmartOneThermostat(this.log, deviceConfig, this.api, accessory, this.smartOneAPI); } else if (deviceConfig.type === 'doorlock') { new SmartOneDoorLock(this.log, deviceConfig, this.api, accessory, this.smartOneAPI); } this.api.registerPlatformAccessories('homebridge-smartone', 'SmartOne', [accessory]); } } } } class SmartOneAPI { constructor(log, refreshToken, authUrl = null, apiBaseUrl = null) { this.log = log; this.refreshToken = refreshToken; this.authUrl = authUrl || DEFAULT_AUTH_URL; this.apiBaseUrl = apiBaseUrl || DEFAULT_API_BASE_URL; this.tokensFile = path.join(api.user.storagePath(), 'smartone-tokens.json'); this.tokens = this.loadTokens(); } loadTokens() { if (fs.existsSync(this.tokensFile)) { const tokens = JSON.parse(fs.readFileSync(this.tokensFile, "utf-8")); this.log.info('Loaded tokens from:', this.tokensFile); this.log.info('Tokens:', JSON.stringify(tokens, null, 2)); // Use config refresh token if provided and different from stored // if (this.refreshToken && this.refreshToken !== tokens.refresh_token) { // this.log.info('Updating refresh token from config'); // tokens.refresh_token = this.refreshToken; // this.saveTokens(tokens); // } return tokens; } else { this.log.info('No tokens file found at:', this.tokensFile); this.log.info('Creating new tokens with refresh token from config'); const newTokens = { access_token: null, refresh_token: this.refreshToken || "", expires_at: 0 }; this.log.info('New tokens:', JSON.stringify(newTokens, null, 2)); // Save tokens to file immediately if refresh token is provided if (this.refreshToken) { this.log.info('Saving initial tokens to file'); this.saveTokens(newTokens); } return newTokens; } } saveTokens(tokens) { this.log.info('Saving tokens to:', this.tokensFile); fs.writeFileSync(this.tokensFile, JSON.stringify(tokens, null, 2)); } async refreshAccessToken() { // If a refresh is already in progress, wait for it if (tokenRefreshPromise) { this.log.info('Token refresh already in progress, waiting...'); try { await tokenRefreshPromise; // Reload tokens after the refresh completes this.tokens = this.loadTokens(); return this.tokens.access_token; } catch (error) { this.log.error('Failed to wait for token refresh:', error.message); throw error; } } // Start the refresh process tokenRefreshPromise = this.performTokenRefresh(); try { const result = await tokenRefreshPromise; return result; } finally { // Clear the promise when done tokenRefreshPromise = null; } } async performTokenRefresh() { try { this.log.info('Starting token refresh...'); const params = new URLSearchParams(); params.append("client_id", CLIENT_ID); if (CLIENT_SECRET) params.append("client_secret", CLIENT_SECRET); params.append("refresh_token", this.tokens.refresh_token); params.append("grant_type", "refresh_token"); params.append("redirect_uri", REDIRECT_URI); params.append("scope", "offline_access openid profile email ApiGateway"); const response = await axios.post(this.authUrl, params.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded" } }); const data = response.data; this.tokens.access_token = data.access_token; this.tokens.refresh_token = data.refresh_token; this.tokens.expires_at = Math.floor(Date.now() / 1000) + data.expires_in; this.log.info('Token refresh successful'); this.log.info('New tokens:', JSON.stringify(this.tokens, null, 2)); this.saveTokens(this.tokens); return this.tokens.access_token; } catch (err) { this.log.error("Failed to refresh token:", err.response?.data || err.message); throw err; } } async getAccessToken() { const now = Math.floor(Date.now() / 1000); if (!this.tokens.access_token || now >= this.tokens.expires_at) { return await this.refreshAccessToken(); } return this.tokens.access_token; } async getDevices() { const token = await this.getAccessToken(); const response = await axios.get(`${this.apiBaseUrl}/SmartDeviceService/api/devices/list`, { headers: { Authorization: `Bearer ${token}` } }); return response.data.devices; } async setDoorLock(state) { const token = await this.getAccessToken(); const devices = await this.getDevices(); const doorLock = devices.find(device => device.type === 'DoorLock'); if (!doorLock) { throw new Error('No door lock found'); } await axios.post( `${this.apiBaseUrl}/SmartDeviceService/api/devices/doorLock/set`, { state, deviceId: doorLock.id }, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } } ); } async setThermostat(coolingSetPoint, fanSpeed = null, hvacMode = null, scene = null) { const token = await this.getAccessToken(); const devices = await this.getDevices(); const thermostat = devices.find(device => device.type === 'Thermostat'); if (!thermostat) { throw new Error('No thermostat found'); } const resourceId = "qLnLY1x6aYQg1If3YoucaV5F3I3MH7YrHLdE1RUB05CBcXVKH1"; const payload = { resourceId, deviceId: thermostat.id }; if (coolingSetPoint !== null) { payload.coolingSetPoint = parseInt(coolingSetPoint); } if (fanSpeed !== null) { payload.fanSpeed = fanSpeed; } if (hvacMode !== null) { payload.hvacMode = hvacMode; } if (scene !== null) { payload.scene = scene; } await axios.post( `${this.apiBaseUrl}/SmartDeviceService/api/Devices/thermostat/set`, payload, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } } ); } } class SmartOneThermostat { constructor(log, config, api, accessory, smartOneAPI) { this.log = log; this.config = config; this.accessory = accessory; this.api = smartOneAPI; this.name = config.name || "SmartOne Thermostat"; this.currentTemperature = 21; this.targetTemperature = 21; this.currentHeatingCoolingState = Characteristic.CurrentHeatingCoolingState.COOL; this.targetHeatingCoolingState = Characteristic.TargetHeatingCoolingState.COOL; this.fanSpeed = "auto"; this.scene = "Normal"; this.accessory.getService(Service.AccessoryInformation) .setCharacteristic(Characteristic.Manufacturer, 'SmartOne') .setCharacteristic(Characteristic.Model, 'Thermostat') .setCharacteristic(Characteristic.SerialNumber, 'FCU01'); this.service = this.accessory.getService(Service.Thermostat) || this.accessory.addService(Service.Thermostat, this.name); this.service .getCharacteristic(Characteristic.CurrentHeatingCoolingState) .onGet(this.getCurrentHeatingCoolingState.bind(this)); this.service .getCharacteristic(Characteristic.TargetHeatingCoolingState) .setProps({ validValues: [ Characteristic.TargetHeatingCoolingState.OFF, Characteristic.TargetHeatingCoolingState.HEAT, Characteristic.TargetHeatingCoolingState.COOL ] }) .onGet(this.getTargetHeatingCoolingState.bind(this)) .onSet(this.setTargetHeatingCoolingState.bind(this)); this.service .getCharacteristic(Characteristic.CurrentTemperature) .onGet(this.getCurrentTemperature.bind(this)); this.service .getCharacteristic(Characteristic.TargetTemperature) .setProps({ minValue: 19, maxValue: 25, minStep: 1 }) .onGet(this.getTargetTemperature.bind(this)) .onSet(this.setTargetTemperature.bind(this)); this.service .getCharacteristic(Characteristic.TemperatureDisplayUnits) .onGet(this.getTemperatureDisplayUnits.bind(this)); // Create a separate fan service for fan speed control this.fanService = this.accessory.getService(Service.Fan) || this.accessory.addService(Service.Fan, this.name + " Fan"); this.fanService .getCharacteristic(Characteristic.On) .onGet(() => this.fanSpeed !== "auto") .onSet(async (value) => { if (!value) { await this.setFanSpeed(25); // Set to auto when turned off } }); this.fanService .getCharacteristic(Characteristic.RotationSpeed) .onGet(this.getFanSpeed.bind(this)) .onSet(this.setFanSpeed.bind(this)); // Create a separate switch for Away/Normal scene control this.awaySwitch = this.accessory.getService(Service.Switch) || this.accessory.addService(Service.Switch, this.name + " Away Mode"); this.awaySwitch .getCharacteristic(Characteristic.On) .onGet(() => this.scene === "Away") .onSet(this.setAwayMode.bind(this)); this.updateDeviceStatus(); setInterval(() => this.updateDeviceStatus(), 30000); } async updateDeviceStatus() { try { const devices = await this.api.getDevices(); const thermostat = devices.find(device => device.type === 'Thermostat'); if (thermostat) { this.currentTemperature = parseInt(thermostat.temperature) || 21; this.targetTemperature = thermostat.coolingSetPoint || 21; this.fanSpeed = thermostat.fanSpeed || "auto"; this.scene = thermostat.scene || "Normal"; // Update HVAC mode based on device state const hvacMode = thermostat.hvacMode || "cool"; this.currentHeatingCoolingState = this.hvacModeToCurrentState(hvacMode); this.targetHeatingCoolingState = this.hvacModeToTargetState(hvacMode); this.service.updateCharacteristic(Characteristic.CurrentTemperature, this.currentTemperature); this.service.updateCharacteristic(Characteristic.TargetTemperature, this.targetTemperature); this.service.updateCharacteristic(Characteristic.CurrentHeatingCoolingState, this.currentHeatingCoolingState); this.service.updateCharacteristic(Characteristic.TargetHeatingCoolingState, this.targetHeatingCoolingState); // Update fan service this.fanService.updateCharacteristic(Characteristic.On, this.fanSpeed !== "auto"); this.fanService.updateCharacteristic(Characteristic.RotationSpeed, this.fanSpeedToPercentage(this.fanSpeed)); // Update away switch this.awaySwitch.updateCharacteristic(Characteristic.On, this.scene === "Away"); } } catch (error) { this.log.error('Error updating thermostat status:', error.message); } } getCurrentHeatingCoolingState() { return this.currentHeatingCoolingState; } getTargetHeatingCoolingState() { return this.targetHeatingCoolingState; } async setTargetHeatingCoolingState(value) { this.targetHeatingCoolingState = value; const hvacMode = this.targetStateToHvacMode(value); try { await this.api.setThermostat(null, null, hvacMode); this.log.info(`Set HVAC mode to ${hvacMode}`); } catch (error) { this.log.error('Error setting HVAC mode:', error.message); throw error; } } getCurrentTemperature() { return this.currentTemperature; } getTargetTemperature() { return this.targetTemperature; } async setTargetTemperature(value) { this.targetTemperature = value; try { await this.api.setThermostat(value); this.log.info(`Set thermostat to ${value}°C`); } catch (error) { this.log.error('Error setting thermostat:', error.message); throw error; } } getTemperatureDisplayUnits() { return Characteristic.TemperatureDisplayUnits.CELSIUS; } fanSpeedToPercentage(fanSpeed) { switch (fanSpeed) { case "auto": return 25; case "low": return 50; case "medium": return 75; case "high": return 100; default: return 25; } } percentageToFanSpeed(percentage) { if (percentage <= 25) return "auto"; if (percentage <= 50) return "low"; if (percentage <= 75) return "medium"; return "high"; } getFanSpeed() { return this.fanSpeedToPercentage(this.fanSpeed); } async setFanSpeed(value) { const fanSpeed = this.percentageToFanSpeed(value); this.fanSpeed = fanSpeed; try { await this.api.setThermostat(null, fanSpeed); this.log.info(`Set fan speed to ${fanSpeed} (${value}%)`); } catch (error) { this.log.error('Error setting fan speed:', error.message); throw error; } } hvacModeToCurrentState(hvacMode) { switch (hvacMode) { case "heat": return Characteristic.CurrentHeatingCoolingState.HEAT; case "cool": return Characteristic.CurrentHeatingCoolingState.COOL; case "ventilation": return Characteristic.CurrentHeatingCoolingState.OFF; default: return Characteristic.CurrentHeatingCoolingState.COOL; } } hvacModeToTargetState(hvacMode) { switch (hvacMode) { case "heat": return Characteristic.TargetHeatingCoolingState.HEAT; case "cool": return Characteristic.TargetHeatingCoolingState.COOL; case "ventilation": return Characteristic.TargetHeatingCoolingState.OFF; default: return Characteristic.TargetHeatingCoolingState.COOL; } } targetStateToHvacMode(targetState) { switch (targetState) { case Characteristic.TargetHeatingCoolingState.HEAT: return "heat"; case Characteristic.TargetHeatingCoolingState.COOL: return "cool"; case Characteristic.TargetHeatingCoolingState.OFF: return "ventilation"; default: return "cool"; } } async setAwayMode(value) { const scene = value ? "Away" : "Normal"; this.scene = scene; try { await this.api.setThermostat(null, null, null, scene); this.log.info(`Set scene to ${scene}`); } catch (error) { this.log.error('Error setting scene:', error.message); throw error; } } } class SmartOneDoorLock { constructor(log, config, api, accessory, smartOneAPI) { this.log = log; this.config = config; this.accessory = accessory; this.api = smartOneAPI; this.name = config.name || "SmartOne Door Lock"; this.lockCurrentState = Characteristic.LockCurrentState.SECURED; this.lockTargetState = Characteristic.LockTargetState.SECURED; this.accessory.getService(Service.AccessoryInformation) .setCharacteristic(Characteristic.Manufacturer, 'SmartOne') .setCharacteristic(Characteristic.Model, 'Door Lock') .setCharacteristic(Characteristic.SerialNumber, 'DoorLock01'); // Use switch service for lock control this.lockService = this.accessory.getService(Service.Switch) || this.accessory.addService(Service.Switch, this.name); // Handle the switch for lock on/off control this.lockService .getCharacteristic(Characteristic.On) .onGet(() => this.lockCurrentState === Characteristic.LockCurrentState.SECURED) .onSet(async (value) => { const targetState = value ? Characteristic.LockTargetState.SECURED : Characteristic.LockTargetState.UNSECURED; await this.setLockTargetState(targetState); }); this.updateDeviceStatus(); setInterval(() => this.updateDeviceStatus(), 30000); } async updateDeviceStatus() { try { const devices = await this.api.getDevices(); const doorLock = devices.find(device => device.type === 'DoorLock'); if (doorLock) { switch (doorLock.state) { case 'lock': this.lockCurrentState = Characteristic.LockCurrentState.SECURED; break; case 'unlock': this.lockCurrentState = Characteristic.LockCurrentState.UNSECURED; break; default: this.lockCurrentState = Characteristic.LockCurrentState.SECURED; } this.lockService.updateCharacteristic(Characteristic.On, this.lockCurrentState === Characteristic.LockCurrentState.SECURED); } } catch (error) { this.log.error('Error updating door lock status:', error.message); } } getLockCurrentState() { return this.lockCurrentState; } getLockTargetState() { return this.lockTargetState; } async setLockTargetState(value) { this.lockTargetState = value; const state = value === Characteristic.LockTargetState.SECURED ? 'lock' : 'unlock'; try { await this.api.setDoorLock(state); this.log.info(`Set door lock to ${state}`); setTimeout(() => { this.lockCurrentState = value === Characteristic.LockTargetState.SECURED ? Characteristic.LockCurrentState.SECURED : Characteristic.LockCurrentState.UNSECURED; this.lockService.updateCharacteristic(Characteristic.On, this.lockCurrentState === Characteristic.LockCurrentState.SECURED); }, 1000); } catch (error) { this.log.error('Error setting door lock:', error.message); throw error; } } } module.exports = (homebridgeApi) => { api = homebridgeApi; Service = api.hap.Service; Characteristic = api.hap.Characteristic; api.registerPlatform('SmartOne', SmartOnePlatform); };