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
JavaScript
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