homebridge-sense-energy-monitor
Version:
Enhanced Homebridge plugin for Sense Home Energy Monitor with comprehensive API integration and real-time monitoring
1,033 lines (881 loc) • 39.7 kB
JavaScript
// Enhanced Homebridge Sense Energy Monitor Platform Plugin
// This is a COMPLETE REWRITE - Dynamic Platform Version 2.4.0
const https = require('https');
const WebSocket = require('ws');
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
let Service, Characteristic, UUIDGen, FakeGatoHistoryService, homebridge;
const PLATFORM_NAME = 'SenseEnergyMonitor';
const PLUGIN_NAME = 'homebridge-sense-energy-monitor';
module.exports = function(homebridgeInstance) {
homebridge = homebridgeInstance;
Service = homebridge.hap.Service;
Characteristic = homebridge.hap.Characteristic;
UUIDGen = homebridge.hap.uuid;
try {
FakeGatoHistoryService = require('fakegato-history')(homebridge);
} catch (error) {
// FakeGato is optional
}
homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, SenseEnergyMonitorPlatform, true);
};
class SenseAPI extends EventEmitter {
constructor(username, password, monitor_id = null, verbose = false, storagePath = null, mfaEnabled = false, mfaSecret = null) {
super();
this.username = username;
this.password = password;
this.monitor_id = monitor_id;
this.verbose = verbose;
this.storagePath = storagePath;
this.mfaEnabled = mfaEnabled;
this.mfaSecret = mfaSecret;
this.access_token = null;
this.user_id = null;
this.account_id = null;
this.monitors = [];
this.devices = [];
this.authenticated = false;
this.realtime_data = {};
this.trend_data = {};
this.rate_limit = 30000;
this.last_realtime_call = 0;
this.auth_timeout = 15 * 60 * 1000;
this.last_auth_time = 0;
this.ws = null;
this.ws_reconnect_timeout = null;
this.ws_reconnect_delay = 30000;
this.ws_max_reconnect_delay = 300000;
this.API_URL = 'https://api.sense.com/apiservice/api/v1/';
this.WS_URL = 'wss://clientrt.sense.com/monitors/';
this.active_power = 0;
this.active_solar_power = 0;
this.active_voltage = [];
this.active_frequency = 0;
this.daily_usage = 0;
this.daily_production = 0;
this.weekly_usage = 0;
this.monthly_usage = 0;
this.yearly_usage = 0;
this.active_devices = [];
// Initialize active_devices as empty array to prevent undefined errors
if (!Array.isArray(this.active_devices)) {
this.active_devices = [];
}
// Load cached authentication if available
this.loadCachedAuth();
}
log(message) {
if (this.verbose) {
const now = new Date();
const timestamp = `${now.toLocaleDateString('en-GB')}, ${now.toLocaleTimeString('en-GB')}`;
console.log(`[${timestamp}] [SenseAPI] ${message}`);
}
}
error(message, error = null) {
const now = new Date();
const timestamp = `${now.toLocaleDateString('en-GB')}, ${now.toLocaleTimeString('en-GB')}`;
if (error) {
console.error(`[${timestamp}] [SenseAPI] ERROR: ${message}`, error);
} else {
console.error(`[${timestamp}] [SenseAPI] ERROR: ${message}`);
}
}
async authenticate() {
const auth_data = {
email: this.username,
password: this.password
};
try {
// Step 1: Initial authentication (never include TOTP in first request)
const response = await this.makeRequest('authenticate', 'POST', auth_data);
if (response.authorized) {
// Direct authentication successful (no MFA required)
return this.handleSuccessfulAuth(response);
}
throw new Error('Authentication failed - invalid credentials');
} catch (error) {
// Check if this is a parsable response with MFA requirement
if (error.response && error.response.status === 'mfa_required' && error.response.mfa_token) {
this.log('MFA required, proceeding with two-factor authentication...');
return await this.validateMFA(error.response.mfa_token);
}
// Check for MFA requirement in error message (fallback)
if (error.message && error.message.includes('Multi-factor authentication required')) {
// Try to extract MFA token from the error response if available
let mfaToken = null;
try {
if (error.responseData) {
const parsedError = JSON.parse(error.responseData);
if (parsedError.mfa_token) {
mfaToken = parsedError.mfa_token;
}
}
} catch (parseError) {
// Ignore parsing errors
}
if (mfaToken) {
this.log('MFA required, proceeding with two-factor authentication...');
return await this.validateMFA(mfaToken);
}
// No MFA token available - provide user guidance
if (!this.mfaEnabled) {
this.error('MFA is required for this account. Please enable MFA in the plugin configuration and provide your TOTP secret.');
} else if (!this.mfaSecret) {
this.error('MFA is enabled but no TOTP secret was provided. Please enter your TOTP secret from your authenticator app setup.');
} else {
this.error('MFA authentication failed. Unable to extract MFA token from response.');
}
}
this.error(`Authentication error: ${error.message}`, error);
this.authenticated = false;
this.emit('authentication_failed', error);
throw error;
}
}
async validateMFA(mfaToken) {
if (!this.mfaEnabled || !this.mfaSecret) {
const error = new Error('MFA is required but no TOTP secret provided. Please enable MFA and provide your TOTP secret.');
this.error(error.message);
this.emit('authentication_failed', error);
throw error;
}
try {
// Generate fresh TOTP code
const totpCode = this.generateTOTPCode();
this.log('Validating MFA with TOTP code...');
// Step 2: MFA validation with the token
const mfaData = {
mfa_token: mfaToken,
totp: totpCode // Note: API expects 'totp', not 'totp_code'
};
const response = await this.makeRequest('authenticate/mfa', 'POST', mfaData);
if (response.authorized) {
this.log('MFA validation successful');
return this.handleSuccessfulAuth(response);
}
throw new Error('MFA validation failed - invalid TOTP code');
} catch (error) {
this.error(`MFA validation error: ${error.message}`, error);
this.authenticated = false;
this.emit('authentication_failed', error);
throw error;
}
}
handleSuccessfulAuth(response) {
this.access_token = response.access_token;
this.user_id = response.user_id;
this.account_id = response.account_id;
this.monitors = response.monitors || [];
this.authenticated = true;
this.last_auth_time = Date.now();
if (!this.monitor_id && this.monitors.length > 0) {
this.monitor_id = this.monitors[0].id;
}
this.saveCachedAuth();
this.log('Authentication successful');
this.emit('authenticated');
return true;
}
async ensureAuthenticated() {
const now = Date.now();
if (!this.authenticated || (now - this.last_auth_time) > this.auth_timeout) {
this.log('Re-authenticating...');
await this.authenticate();
}
}
generateTOTPCode() {
if (!this.mfaSecret) {
throw new Error('No TOTP secret available');
}
const crypto = require('crypto');
// Remove spaces and make uppercase
const secret = this.mfaSecret.replace(/\s/g, '').toUpperCase();
// Base32 decode
const base32Decode = (encoded) => {
const base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
let hex = '';
for (let i = 0; i < encoded.length; i++) {
const val = base32chars.indexOf(encoded.charAt(i));
if (val === -1) continue;
bits += val.toString(2).padStart(5, '0');
}
for (let i = 0; i + 8 <= bits.length; i += 8) {
const chunk = bits.substring(i, i + 8);
hex += parseInt(chunk, 2).toString(16).padStart(2, '0');
}
return Buffer.from(hex, 'hex');
};
const key = base32Decode(secret);
// Get current time counter
const timeCounter = Math.floor(Date.now() / 1000 / 30);
// Convert counter to buffer
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeUInt32BE(Math.floor(timeCounter / 0x100000000), 0);
counterBuffer.writeUInt32BE(timeCounter & 0xffffffff, 4);
// Generate HMAC
const hmac = crypto.createHmac('sha1', key);
hmac.update(counterBuffer);
const hash = hmac.digest();
// Get offset
const offset = hash[hash.length - 1] & 0xf;
// Get 4 bytes from hash starting at offset
const binary =
((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
// Get 6-digit code
const otp = binary % 1000000;
const code = otp.toString().padStart(6, '0');
this.log(`Generated TOTP code: ${code.substring(0, 2)}****`);
return code;
}
async makeRequest(endpoint, method = 'GET', data = null) {
const url = this.API_URL + endpoint;
const options = {
method,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'homebridge-sense-energy-monitor/2.4.0',
'X-Sense-Protocol': '3',
'cache-control': 'no-cache'
},
timeout: 30000
};
if (this.access_token) {
options.headers.Authorization = `Bearer ${this.access_token}`;
}
let postData = null;
if (data && (endpoint === 'authenticate' || endpoint === 'authenticate/mfa')) {
// Handle form data for authentication endpoints
const params = new URLSearchParams();
Object.keys(data).forEach(key => {
params.append(key, data[key]);
});
postData = params.toString();
options.headers['Content-Length'] = Buffer.byteLength(postData);
} else if (data) {
options.headers['Content-Type'] = 'application/json';
postData = JSON.stringify(data);
}
return new Promise((resolve, reject) => {
const req = https.request(url, options, (res) => {
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
});
res.on('end', () => {
try {
const parsedData = JSON.parse(responseData);
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(parsedData);
} else {
// For MFA-related errors, include the parsed response data
const error = new Error(`HTTP ${res.statusCode}: ${parsedData.error_reason || 'Unknown error'}`);
error.responseData = responseData;
error.response = parsedData;
reject(error);
}
} catch (parseError) {
const error = new Error(`Failed to parse response: ${parseError.message}`);
error.responseData = responseData;
reject(error);
}
});
});
req.on('error', (error) => {
reject(new Error(`Request failed: ${error.message}`));
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timeout after 30 seconds'));
});
if (postData) {
req.write(postData);
}
req.end();
});
}
async getDevices() {
try {
await this.ensureAuthenticated();
const response = await this.makeRequest(`app/monitors/${this.monitor_id}/devices`);
this.devices = response || [];
this.log(`Retrieved ${this.devices.length} devices`);
return this.devices;
} catch (error) {
try {
this.log('Trying alternative devices endpoint...');
const response = await this.makeRequest(`monitors/${this.monitor_id}/devices`);
this.devices = response || [];
this.log(`Retrieved ${this.devices.length} devices (alternative endpoint)`);
return this.devices;
} catch (altError) {
this.error(`Error fetching devices: ${error.message}`, error);
this.devices = [];
return this.devices;
}
}
}
async updateRealtime() {
try {
await this.ensureAuthenticated();
const now = Date.now();
if (now - this.last_realtime_call < this.rate_limit) {
this.log('Rate limited - skipping realtime update');
return;
}
const response = await this.makeRequest(`app/monitors/${this.monitor_id}/status`);
this.last_realtime_call = now;
if (response) {
this.active_power = Math.round(response.w || 0);
this.active_solar_power = Math.round(response.solar_w || 0);
this.active_voltage = response.voltage || [120];
this.active_frequency = response.hz || 60;
this.emit('realtime_update', {
power: this.active_power,
solar_power: this.active_solar_power,
voltage: this.active_voltage,
frequency: this.active_frequency
});
}
} catch (error) {
this.error(`Error updating realtime data: ${error.message}`, error);
this.emit('realtime_error', error);
}
}
async updateTrendData() {
try {
await this.ensureAuthenticated();
const response = await this.makeRequest(`app/monitors/${this.monitor_id}/status`);
if (response) {
this.daily_usage = response.daily_usage || 0;
this.daily_production = response.daily_production || 0;
this.weekly_usage = response.weekly_usage || 0;
this.monthly_usage = response.monthly_usage || 0;
this.yearly_usage = response.yearly_usage || 0;
this.emit('trend_update', {
daily_usage: this.daily_usage,
daily_production: this.daily_production,
weekly_usage: this.weekly_usage,
monthly_usage: this.monthly_usage,
yearly_usage: this.yearly_usage
});
}
} catch (error) {
this.error(`Error updating trend data: ${error.message}`, error);
this.emit('trend_error', error);
}
}
openStream() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.log('WebSocket already open');
return;
}
if (!this.authenticated || !this.access_token) {
this.error('Cannot open WebSocket - not authenticated');
return;
}
try {
const wsUrl = `${this.WS_URL}${this.monitor_id}/realtimefeed?access_token=${this.access_token}`;
this.log('Opening WebSocket connection');
this.ws = new WebSocket(wsUrl);
this.ws.on('open', () => {
this.log('WebSocket connected');
this.emit('websocket_open');
this.ws_reconnect_delay = 30000;
if (this.ws_reconnect_timeout) {
clearTimeout(this.ws_reconnect_timeout);
this.ws_reconnect_timeout = null;
}
});
this.ws.on('message', (data) => {
try {
const parsedData = JSON.parse(data);
this.handleWebSocketData(parsedData);
} catch (error) {
this.error(`Error parsing WebSocket data: ${error.message}`, error);
}
});
this.ws.on('close', (code, reason) => {
this.log(`WebSocket closed: ${code} ${reason}`);
this.emit('websocket_close', { code, reason });
this.scheduleReconnect();
});
this.ws.on('error', (error) => {
this.error(`WebSocket error: ${error.message}`, error);
this.emit('websocket_error', error);
this.scheduleReconnect();
});
} catch (error) {
this.error(`Failed to create WebSocket connection: ${error.message}`, error);
this.scheduleReconnect();
}
}
scheduleReconnect() {
if (this.ws_reconnect_timeout) {
return; // Already scheduled
}
this.log(`Scheduling WebSocket reconnect in ${this.ws_reconnect_delay}ms`);
this.ws_reconnect_timeout = setTimeout(() => {
this.ws_reconnect_timeout = null;
this.openStream();
}, this.ws_reconnect_delay);
// Exponential backoff with max delay
this.ws_reconnect_delay = Math.min(this.ws_reconnect_delay * 2, this.ws_max_reconnect_delay);
}
closeStream() {
if (this.ws_reconnect_timeout) {
clearTimeout(this.ws_reconnect_timeout);
this.ws_reconnect_timeout = null;
}
if (this.ws) {
this.log('Closing WebSocket connection');
this.ws.close();
this.ws = null;
}
}
handleWebSocketData(data) {
try {
if (data.payload) {
this.active_power = Math.round(data.payload.w || data.payload.d_w || 0);
this.active_solar_power = Math.round(data.payload.solar_w || 0);
this.active_voltage = data.payload.voltage || [120];
this.active_frequency = data.payload.hz || 60;
// Initialize active_devices as empty array
this.active_devices = [];
if (data.payload.devices && Array.isArray(data.payload.devices)) {
this.active_devices = data.payload.devices
.filter(device => {
return device &&
typeof device === 'object' &&
device.name &&
typeof device.w === 'number' &&
device.w > 5;
})
.map(device => ({
name: device.name,
power: Math.round(device.w)
}));
}
this.emit('data', {
power: this.active_power,
solar_power: this.active_solar_power,
voltage: this.active_voltage,
frequency: this.active_frequency,
devices: this.active_devices
});
}
} catch (error) {
this.error(`Error handling WebSocket data: ${error.message}`, error);
}
}
saveCachedAuth() {
if (!this.storagePath) {
return;
}
try {
const authData = {
access_token: this.access_token,
user_id: this.user_id,
account_id: this.account_id,
monitor_id: this.monitor_id,
last_auth_time: this.last_auth_time,
monitors: this.monitors
};
const authFile = path.join(this.storagePath, 'sense_auth.json');
fs.writeFileSync(authFile, JSON.stringify(authData, null, 2));
this.log('Authentication cached');
} catch (error) {
this.error(`Failed to cache authentication: ${error.message}`, error);
}
}
loadCachedAuth() {
if (!this.storagePath) {
return;
}
try {
const authFile = path.join(this.storagePath, 'sense_auth.json');
if (fs.existsSync(authFile)) {
const authData = JSON.parse(fs.readFileSync(authFile, 'utf8'));
// Check if cached auth is still valid (within timeout)
const now = Date.now();
if (now - authData.last_auth_time < this.auth_timeout) {
this.access_token = authData.access_token;
this.user_id = authData.user_id;
this.account_id = authData.account_id;
this.monitor_id = authData.monitor_id || this.monitor_id;
this.last_auth_time = authData.last_auth_time;
this.monitors = authData.monitors || [];
this.authenticated = true;
this.log('Loaded cached authentication');
}
}
} catch (error) {
this.error(`Failed to load cached authentication: ${error.message}`, error);
}
}
destroy() {
this.closeStream();
this.removeAllListeners();
}
}
class SenseEnergyMonitorPlatform {
constructor(log, config, api) {
this.log = log;
this.config = config;
this.api = api;
this.accessories = [];
this.accessoriesToRemove = [];
this.senseAPI = null;
this.isConfigured = false;
this.log.info('Initializing Sense Energy Monitor Platform...');
// Validate required configuration
if (!this.validateConfig()) {
this.log.error('Plugin not configured correctly. Please check your configuration.');
return;
}
this.isConfigured = true;
this.name = this.config.name || 'Sense Energy Monitor';
this.username = this.config.username;
this.password = this.config.password;
this.monitor_id = this.config.monitor_id || null;
this.pollingInterval = (this.config.pollingInterval || 60) * 1000;
this.deviceLoggingInterval = (this.config.deviceLoggingInterval || 2) * 60 * 1000;
this.useWebSocket = this.config.useWebSocket !== false;
this.includeSolar = this.config.includeSolar !== false;
this.includeDevices = this.config.includeDevices !== false;
this.maxDevices = Math.min(this.config.maxDevices || 20, 50);
this.devicePowerThreshold = this.config.devicePowerThreshold || 10;
this.enableHistory = this.config.enableHistory !== false;
this.verbose = this.config.verbose !== false; // Default to true
// Get storage path for caching
this.storagePath = this.api?.user?.storagePath();
if (this.api) {
this.api.on('didFinishLaunching', () => {
this.log.info('Platform finished launching, initializing accessories...');
this.initializePlatform();
});
this.api.on('shutdown', () => {
this.log.info('Homebridge is shutting down, cleaning up...');
this.cleanup();
});
}
}
validateConfig() {
if (!this.config) {
this.log.error('No configuration provided');
return false;
}
if (!this.config.username || !this.config.password) {
this.log.error('Username and password are required');
return false;
}
if (typeof this.config.username !== 'string' || typeof this.config.password !== 'string') {
this.log.error('Username and password must be strings');
return false;
}
if (this.config.pollingInterval && (this.config.pollingInterval < 30 || this.config.pollingInterval > 3600)) {
this.log.warn('Polling interval should be between 30 and 3600 seconds, using default 60');
this.config.pollingInterval = 60;
}
return true;
}
async initializePlatform() {
if (!this.isConfigured) {
this.log.error('Platform not configured, skipping initialization');
return;
}
try {
// NUCLEAR OPTION: Remove ALL existing accessories on startup
if (this.accessories.length > 0) {
this.log.warn(`Removing ${this.accessories.length} existing accessories to prevent callback conflicts`);
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, this.accessories);
this.accessories = [];
}
// Also remove any cached accessories that were ignored
if (this.accessoriesToRemove && this.accessoriesToRemove.length > 0) {
this.log.warn(`Removing ${this.accessoriesToRemove.length} cached accessories to prevent callback conflicts`);
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, this.accessoriesToRemove);
this.accessoriesToRemove = [];
}
// Initialize Sense API
this.senseAPI = new SenseAPI(
this.username,
this.password,
this.monitor_id,
this.verbose,
this.storagePath,
this.config.mfaEnabled || false,
this.config.mfaSecret || null
);
this.setupEventListeners();
// Authenticate and discover devices
await this.senseAPI.authenticate();
// Wait a bit before creating accessories
setTimeout(async () => {
await this.discoverAccessories();
if (this.useWebSocket) {
this.senseAPI.openStream();
}
// Start periodic updates
this.startPeriodicUpdates();
}, 3000);
} catch (error) {
this.log.error('Failed to initialize platform:', error.message);
// Schedule retry
setTimeout(() => {
this.log.info('Retrying platform initialization...');
this.initializePlatform();
}, 120000);
}
}
setupEventListeners() {
this.senseAPI.on('authenticated', () => {
this.log.info('Sense API authenticated successfully');
});
this.senseAPI.on('authentication_failed', (error) => {
this.log.error('Sense API authentication failed:', error.message);
if (error.message && error.message.includes('Multi-factor authentication required')) {
this.log.error('Please update your configuration with MFA settings:');
this.log.error('1. Set "mfaEnabled" to true');
this.log.error('2. Enter the 6-digit code from your authenticator app in "mfaCode"');
this.log.error('3. Restart Homebridge after updating the configuration');
}
});
this.senseAPI.on('data', (data) => {
this.updateAccessories(data);
});
this.senseAPI.on('realtime_update', (data) => {
this.updateAccessories(data);
});
this.senseAPI.on('trend_update', (data) => {
this.log.debug('Trend data updated:', data);
});
this.senseAPI.on('websocket_error', (error) => {
this.log.warn('WebSocket error:', error.message);
});
this.senseAPI.on('realtime_error', (error) => {
this.log.warn('Realtime data error:', error.message);
});
this.senseAPI.on('trend_error', (error) => {
this.log.warn('Trend data error:', error.message);
});
}
async discoverAccessories() {
try {
// Create main energy monitor accessory only
const mainUUID = UUIDGen.generate('sense-main-monitor-v2');
const mainAccessory = new this.api.platformAccessory(this.name, mainUUID);
this.configureMainAccessory(mainAccessory);
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [mainAccessory]);
this.accessories.push(mainAccessory);
this.log.info('Created main energy monitor accessory');
// Individual device accessories removed - feature was causing callback conflicts
} catch (error) {
this.log.error('Error discovering accessories:', error.message);
throw error;
}
}
configureMainAccessory(accessory) {
try {
// Information Service
const informationService = accessory.getService(Service.AccessoryInformation) ||
accessory.addService(Service.AccessoryInformation);
informationService
.setCharacteristic(Characteristic.Manufacturer, 'Sense Labs')
.setCharacteristic(Characteristic.Model, 'Energy Monitor')
.setCharacteristic(Characteristic.SerialNumber, this.monitor_id || 'Unknown')
.setCharacteristic(Characteristic.FirmwareRevision, '2.4.0');
// Remove existing outlet service if it exists to start fresh
const existingOutletService = accessory.getService(Service.Outlet);
if (existingOutletService) {
accessory.removeService(existingOutletService);
this.log.info('Removed existing outlet service to prevent conflicts');
}
// Create fresh outlet service
const outletService = accessory.addService(Service.Outlet, this.name);
// Use the simplest possible characteristic handlers
outletService
.getCharacteristic(Characteristic.On)
.onGet(() => {
const power = this.senseAPI?.active_power || 0;
return power > this.devicePowerThreshold;
})
.updateValue(false); // Set initial value
outletService
.getCharacteristic(Characteristic.OutletInUse)
.onGet(() => {
const power = this.senseAPI?.active_power || 0;
return power > this.devicePowerThreshold;
})
.updateValue(false); // Set initial value
// Add history service if enabled and available
if (this.enableHistory && FakeGatoHistoryService && !accessory.historyService) {
try {
accessory.historyService = new FakeGatoHistoryService('energy', accessory, {
storage: 'fs',
path: this.storagePath
});
} catch (historyError) {
this.log.warn('Failed to create history service:', historyError.message);
}
}
accessory.context = {
type: 'main',
configured: true,
version: '2.4.0'
};
this.log.info('Main accessory configured successfully');
} catch (error) {
this.log.error('Error configuring main accessory:', error.message);
throw error;
}
}
clearExistingHandlers(accessory) {
try {
// Clear handlers from outlet service if it exists
const outletService = accessory.getService(Service.Outlet);
if (outletService) {
const onCharacteristic = outletService.getCharacteristic(Characteristic.On);
const outletInUseCharacteristic = outletService.getCharacteristic(Characteristic.OutletInUse);
if (onCharacteristic) {
onCharacteristic.removeAllListeners();
}
if (outletInUseCharacteristic) {
outletInUseCharacteristic.removeAllListeners();
}
}
} catch (error) {
this.log.warn('Error clearing existing handlers:', error.message);
}
}
addCustomCharacteristics(_service) {
// These would be custom characteristics for power monitoring
// For now, we'll use standard characteristics where possible
// You could add custom characteristics here for:
// - Power consumption in watts
// - Voltage
// - Current
// - Energy consumption in kWh
// - Solar production
}
// Individual device accessories removed - was causing callback conflicts
updateAccessories(data) {
try {
// Validate incoming data
if (!data || typeof data !== 'object') {
this.log.warn('Invalid data received for accessory update');
return;
}
// Update main accessory
const mainAccessory = this.accessories.find(acc => acc.context.type === 'main');
if (mainAccessory) {
const outletService = mainAccessory.getService(Service.Outlet);
if (outletService) {
try {
const power = typeof data.power === 'number' ? data.power : 0;
const isOn = Boolean(power > this.devicePowerThreshold);
outletService.updateCharacteristic(Characteristic.On, isOn);
outletService.updateCharacteristic(Characteristic.OutletInUse, isOn);
} catch (error) {
this.log.error('Error updating main accessory characteristics:', error.message);
}
}
// Add history entry
if (mainAccessory.historyService) {
try {
const power = typeof data.power === 'number' ? data.power : 0;
const voltage = (Array.isArray(data.voltage) && data.voltage[0]) ? data.voltage[0] : 120;
const current = voltage > 0 ? (power / voltage) : 0;
mainAccessory.historyService.addEntry({
time: Math.round(Date.now() / 1000),
power: power,
voltage: voltage,
current: current
});
} catch (error) {
this.log.error('Error adding history entry:', error.message);
}
}
}
// Log periodic status
if (this.verbose) {
const now = Date.now();
if (!this.lastLogTime || (now - this.lastLogTime) > 30000) {
this.lastLogTime = now;
const power = typeof data.power === 'number' ? data.power : 0;
const solarPower = typeof data.solar_power === 'number' ? data.solar_power : 0;
const deviceCount = Array.isArray(data.devices) ? data.devices.length : 0;
this.log.info(`Power: ${power}W, Solar: ${solarPower}W, Active devices: ${deviceCount}`);
}
}
} catch (error) {
this.log.error('Error updating accessories:', error.message);
}
}
startPeriodicUpdates() {
// Update trend data periodically
if (this.pollingInterval > 0) {
setInterval(async() => {
try {
await this.senseAPI.updateTrendData();
if (!this.useWebSocket) {
await this.senseAPI.updateRealtime();
}
} catch (error) {
this.log.warn('Periodic update failed:', error.message);
}
}, this.pollingInterval);
}
// Log device status periodically
if (this.verbose && this.deviceLoggingInterval > 0) {
setInterval(() => {
if (this.senseAPI.active_devices.length > 0) {
this.log.info(`Active devices: ${this.senseAPI.active_devices.map(d => `${d.name}(${d.power}W)`).join(', ')}`);
}
}, this.deviceLoggingInterval);
}
}
configureAccessory(accessory) {
// NUCLEAR OPTION: Don't configure any cached accessories
// This prevents callback conflicts from old cached accessories
this.log.warn(`Ignoring cached accessory to prevent conflicts: ${accessory.displayName}`);
// Add to list for removal
if (!this.accessoriesToRemove) {
this.accessoriesToRemove = [];
}
this.accessoriesToRemove.push(accessory);
}
// Method to clean up problematic cached accessories
cleanupCachedAccessories() {
try {
// Remove accessories that might have old callback-style handlers
const problematicAccessories = this.accessories.filter(accessory => {
// Look for accessories without the 'configured' flag
return !accessory.context.configured;
});
if (problematicAccessories.length > 0) {
this.log.info(`Cleaning up ${problematicAccessories.length} potentially problematic cached accessories`);
problematicAccessories.forEach(accessory => {
try {
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.accessories = this.accessories.filter(acc => acc.UUID !== accessory.UUID);
this.log.info(`Removed cached accessory: ${accessory.displayName}`);
} catch (error) {
this.log.error(`Error removing cached accessory ${accessory.displayName}:`, error.message);
}
});
}
} catch (error) {
this.log.error('Error during accessory cleanup:', error.message);
}
}
cleanup() {
if (this.senseAPI) {
this.senseAPI.destroy();
}
}
}