UNPKG

homebridge-sense-energy-monitor

Version:

Enhanced Homebridge plugin for Sense Home Energy Monitor with comprehensive API integration and real-time monitoring

461 lines 18.2 kB
import { EventEmitter } from 'node:events'; import fs from 'node:fs'; import path from 'node:path'; import WebSocket from 'ws'; import { PLUGIN_VERSION } from './settings.js'; import { generateTotp } from './totp.js'; const API_URL = 'https://api.sense.com/apiservice/api/v1/'; const WS_URL = 'wss://clientrt.sense.com/monitors/'; const REQUEST_TIMEOUT_MS = 30_000; const WS_RECONNECT_BASE_MS = 30_000; const WS_RECONNECT_MAX_MS = 300_000; const WS_HEARTBEAT_INTERVAL_MS = 30_000; const WS_STALE_MS = 60_000; const DATA_EMIT_THROTTLE_MS = 5_000; const AUTH_CACHE_FILE = 'sense_auth.json'; const DEVICE_MIN_WATTS = 5; export class SenseApi extends EventEmitter { monitorId; realtime = { power: 0, solarPower: 0, voltage: [], frequency: 0, devices: [] }; log; username; password; mfaEnabled; mfaSecret; storagePath; accessToken = null; refreshToken = null; userId = null; accountId = null; monitors = []; refreshPromise = null; ws = null; wsReconnectTimer = null; wsReconnectDelay = WS_RECONNECT_BASE_MS; wsHeartbeatTimer = null; lastMessageAt = 0; lastDataEmitAt = 0; streamWanted = false; constructor(options) { super(); this.log = options.log; this.username = options.username; this.password = options.password; this.monitorId = options.monitorId != null ? String(options.monitorId) : null; this.mfaEnabled = options.mfaEnabled === true; this.mfaSecret = options.mfaSecret ?? null; this.storagePath = options.storagePath ?? null; this.loadAuthCache(); } get isAuthenticated() { return this.accessToken !== null; } // ---------------------------------------------------------------- auth /** * Sense authentication. Invariants (do not change — hard-won in v2.3.x): * - Step 1 is form-encoded and never includes a TOTP code. * - An MFA challenge arrives as a NON-2xx response whose JSON body carries * `status: 'mfa_required'` and an `mfa_token`. * - Step 2 posts form fields `mfa_token` and `totp` (exact name). */ async authenticate() { try { const { status, body } = await this.fetchJson('authenticate', { form: { email: this.username, password: this.password }, }); const auth = body; if (status >= 200 && status < 300 && auth?.authorized) { this.handleAuthSuccess(auth); return; } if (auth?.mfa_token && (auth.status === 'mfa_required' || auth.error_reason?.includes('Multi-factor'))) { await this.validateMfa(auth.mfa_token); return; } if (auth?.status === 'mfa_required' || auth?.error_reason?.includes('Multi-factor')) { throw new Error(this.mfaGuidance()); } throw new Error(`Authentication failed (HTTP ${status}): ${auth?.error_reason ?? 'invalid credentials'}`); } catch (error) { this.emit('authentication_failed', error); throw error; } } async validateMfa(mfaToken) { if (!this.mfaEnabled || !this.mfaSecret) { throw new Error(this.mfaGuidance()); } const totp = generateTotp(this.mfaSecret); this.log.debug('Validating MFA with generated TOTP code'); const { status, body } = await this.fetchJson('authenticate/mfa', { form: { mfa_token: mfaToken, totp }, }); const auth = body; if (status >= 200 && status < 300 && auth?.authorized) { this.log.debug('MFA validation successful'); this.handleAuthSuccess(auth); return; } throw new Error(`MFA validation failed (HTTP ${status}): ${auth?.error_reason ?? 'invalid TOTP code'}`); } mfaGuidance() { if (!this.mfaEnabled) { return 'MFA is required for this account. Enable MFA in the plugin configuration and provide your TOTP secret.'; } if (!this.mfaSecret) { return 'MFA is enabled but no TOTP secret was provided. Enter the TOTP secret from your authenticator app setup.'; } return 'MFA authentication failed: unable to obtain an MFA token from the Sense API.'; } handleAuthSuccess(auth) { this.accessToken = auth.access_token ?? null; this.refreshToken = auth.refresh_token ?? null; this.userId = auth.user_id ?? null; this.accountId = auth.account_id ?? null; this.monitors = auth.monitors ?? []; if (!this.monitorId && this.monitors.length > 0) { this.monitorId = String(this.monitors[0].id); } this.saveAuthCache(); this.log.debug('Sense authentication successful'); this.emit('authenticated'); } /** * Reactive token refresh, shared by all callers via a single in-flight * promise: try the lightweight renew endpoint first, fall back to a full * re-authentication (with a fresh TOTP when MFA is on). */ refreshAuth() { this.refreshPromise ??= this.doRefreshAuth().finally(() => { this.refreshPromise = null; }); return this.refreshPromise; } async doRefreshAuth() { if (this.refreshToken && this.userId !== null) { try { const { status, body } = await this.fetchJson('renew', { form: { user_id: String(this.userId), refresh_token: this.refreshToken }, }); const auth = body; if (status >= 200 && status < 300 && auth?.access_token) { this.accessToken = auth.access_token; if (auth.refresh_token) { this.refreshToken = auth.refresh_token; } this.saveAuthCache(); this.log.debug('Sense access token renewed'); return; } this.log.debug(`Token renew rejected (HTTP ${status}); falling back to full re-authentication`); } catch (error) { this.log.debug(`Token renew failed (${error.message}); falling back to full re-authentication`); } } await this.authenticate(); } // ------------------------------------------------------------- requests async fetchJson(endpoint, options = {}) { const headers = { 'User-Agent': `homebridge-sense-energy-monitor/${PLUGIN_VERSION}`, 'X-Sense-Protocol': '3', 'cache-control': 'no-cache', }; if (this.accessToken) { headers.Authorization = `Bearer ${this.accessToken}`; } let body; let method = options.method ?? 'GET'; if (options.form) { method = options.method ?? 'POST'; headers['Content-Type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(options.form).toString(); } const response = await fetch(API_URL + endpoint, { method, headers, body, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); const text = await response.text(); let parsed = null; if (text) { try { parsed = JSON.parse(text); } catch { parsed = null; } } return { status: response.status, body: parsed }; } /** Authenticated GET with 401 → refresh → single retry. */ async request(endpoint) { if (!this.accessToken) { await this.refreshAuth(); } let result = await this.fetchJson(endpoint); if (result.status === 401) { this.log.debug(`HTTP 401 from ${endpoint} — refreshing token`); await this.refreshAuth(); result = await this.fetchJson(endpoint); } if (result.status < 200 || result.status >= 300) { const reason = result.body?.error_reason ?? 'request failed'; throw new Error(`HTTP ${result.status} from ${endpoint}: ${reason}`); } return result.body; } // ----------------------------------------------------------------- data /** * Cheap authenticated probe against the monitor status endpoint. Returns * no power data (the endpoint has none) — its job is validating the * session, self-healing an expired token via the 401 → refresh path. */ async validateSession() { await this.request(`app/monitors/${this.monitorId}/status`); } /** * One-shot realtime sample — fallback for when the persistent WebSocket * is disabled. Sense has no HTTP endpoint for live power (the status * endpoint carries none), so this briefly connects to the realtime feed * and takes the first power frame. */ async updateRealtime(timeoutMs = 15_000) { await this.validateSession(); await new Promise((resolve, reject) => { const ws = new WebSocket(`${WS_URL}${this.monitorId}/realtimefeed?access_token=${this.accessToken}`); const timer = setTimeout(() => { ws.terminate(); reject(new Error('Timed out waiting for a realtime sample')); }, timeoutMs); ws.on('message', (raw) => { let message; try { message = JSON.parse(raw.toString()); } catch { return; // keep waiting for a parsable frame } if (message.type === 'realtime_update' && message.payload) { clearTimeout(timer); ws.close(); this.applyRealtimePayload(message.payload); resolve(); } }); ws.on('error', (error) => { clearTimeout(timer); reject(error); }); }); } /** Daily consumption/production trends (kWh). */ async updateTrends() { const start = new Date(); start.setHours(0, 0, 0, 0); const query = new URLSearchParams({ monitor_id: String(this.monitorId), scale: 'DAY', start: start.toISOString(), }); const trends = await this.request(`app/history/trends?${query.toString()}`); const data = { dailyUsageKwh: trends.consumption?.total ?? 0, dailyProductionKwh: trends.production?.total ?? 0, }; this.emit('trend_update', data); return data; } // ------------------------------------------------------------ websocket openStream() { this.streamWanted = true; if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; } if (!this.accessToken || !this.monitorId) { this.log.warn('Cannot open Sense realtime stream: not authenticated yet'); this.scheduleReconnect(); return; } // Note: the URL embeds the access token — never log it. const wsUrl = `${WS_URL}${this.monitorId}/realtimefeed?access_token=${this.accessToken}`; this.log.debug('Opening Sense realtime WebSocket'); this.ws = new WebSocket(wsUrl); this.ws.on('open', () => { this.log.debug('Sense realtime WebSocket connected'); this.lastMessageAt = Date.now(); this.emit('websocket_open'); this.startHeartbeat(); }); this.ws.on('message', (raw) => { this.lastMessageAt = Date.now(); this.wsReconnectDelay = WS_RECONNECT_BASE_MS; try { const message = JSON.parse(raw.toString()); // Only realtime frames carry power data; applying other frame // types (hello, monitor_info, …) would zero the readings. if (message.type === 'realtime_update' && message.payload) { this.applyRealtimePayload(message.payload); } } catch (error) { this.log.debug(`Ignoring unparsable WebSocket frame: ${error.message}`); } }); this.ws.on('pong', () => { this.lastMessageAt = Date.now(); }); this.ws.on('close', (code) => { this.log.debug(`Sense realtime WebSocket closed (code ${code})`); this.emit('websocket_close', code); this.ws = null; if (this.streamWanted) { this.scheduleReconnect(); } }); this.ws.on('error', (error) => { this.log.warn(`Sense realtime WebSocket error: ${error.message}`); this.emit('websocket_error', error); // 'close' follows and handles the reconnect. }); } closeStream() { this.streamWanted = false; if (this.wsReconnectTimer) { clearTimeout(this.wsReconnectTimer); this.wsReconnectTimer = null; } this.stopHeartbeat(); if (this.ws) { this.ws.removeAllListeners(); this.ws.close(); this.ws = null; } } startHeartbeat() { if (this.wsHeartbeatTimer) { return; } this.wsHeartbeatTimer = setInterval(() => { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return; } const silentFor = Date.now() - this.lastMessageAt; if (silentFor > WS_STALE_MS) { this.log.warn(`Sense realtime stream stale for ${Math.round(silentFor / 1000)}s — reconnecting`); this.ws.terminate(); } else if (silentFor > WS_HEARTBEAT_INTERVAL_MS) { this.ws.ping(); } }, WS_HEARTBEAT_INTERVAL_MS); } stopHeartbeat() { if (this.wsHeartbeatTimer) { clearInterval(this.wsHeartbeatTimer); this.wsHeartbeatTimer = null; } } scheduleReconnect() { if (this.wsReconnectTimer || !this.streamWanted) { return; } this.log.debug(`Scheduling Sense stream reconnect in ${Math.round(this.wsReconnectDelay / 1000)}s`); this.wsReconnectTimer = setTimeout(() => { this.wsReconnectTimer = null; void this.reconnect(); }, this.wsReconnectDelay); this.wsReconnectDelay = Math.min(this.wsReconnectDelay * 2, WS_RECONNECT_MAX_MS); } async reconnect() { try { // Self-heals an expired token via the 401-refresh path so the // stream never reconnects with a stale token. await this.validateSession(); } catch (error) { this.log.warn(`Sense reconnect probe failed: ${error.message}`); this.scheduleReconnect(); return; } this.openStream(); } applyRealtimePayload(payload) { const devices = (payload.devices ?? []) .filter((device) => device && typeof device.name === 'string' && typeof device.w === 'number' && device.w > DEVICE_MIN_WATTS) .map((device) => ({ name: device.name, power: Math.round(device.w) })); this.realtime = { power: Math.round(payload.w ?? payload.d_w ?? 0), solarPower: Math.round(payload.solar_w ?? 0), voltage: payload.voltage ?? [], frequency: payload.hz ?? 0, devices, }; const now = Date.now(); if (now - this.lastDataEmitAt >= DATA_EMIT_THROTTLE_MS) { this.lastDataEmitAt = now; this.emit('data', this.realtime); } } // ------------------------------------------------------------ auth cache authCachePath() { return this.storagePath ? path.join(this.storagePath, AUTH_CACHE_FILE) : null; } saveAuthCache() { const file = this.authCachePath(); if (!file) { return; } try { const cache = { access_token: this.accessToken ?? undefined, refresh_token: this.refreshToken ?? undefined, user_id: this.userId ?? undefined, account_id: this.accountId ?? undefined, monitor_id: this.monitorId, monitors: this.monitors, issued_at: Date.now(), }; fs.writeFileSync(file, JSON.stringify(cache, null, 2), { mode: 0o600 }); } catch (error) { this.log.warn(`Failed to cache Sense authentication: ${error.message}`); } } loadAuthCache() { const file = this.authCachePath(); if (!file) { return; } try { if (!fs.existsSync(file)) { return; } const cache = JSON.parse(fs.readFileSync(file, 'utf8')); // Trust the cached token unconditionally; an expired one heals // itself through the 401 → renew → re-authenticate path. this.accessToken = cache.access_token ?? null; this.refreshToken = cache.refresh_token ?? null; this.userId = cache.user_id ?? null; this.accountId = cache.account_id ?? null; // v2.x cache files stored monitor_id as a number; HomeKit's // SerialNumber characteristic requires a string. this.monitorId = this.monitorId ?? (cache.monitor_id != null ? String(cache.monitor_id) : null); this.monitors = cache.monitors ?? []; if (this.accessToken) { this.log.debug('Loaded cached Sense authentication'); } } catch (error) { this.log.warn(`Failed to load cached Sense authentication: ${error.message}`); } } destroy() { this.closeStream(); this.removeAllListeners(); } } //# sourceMappingURL=senseApi.js.map