homebridge-smartone
Version:
Homebridge plugin for SmartOne devices
590 lines (495 loc) • 19.8 kB
JavaScript
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);
};