UNPKG

homebridge-flume

Version:

Homebridge plugin to integrate Flume devices into HomeKit.

310 lines 11.8 kB
import axios, { isAxiosError } from 'axios'; import { Auth } from './auth.js'; import { Device } from './device.js'; import * as Types from './types.js'; import { strings } from '../i18n/i18n.js'; import { MINUTE, SECOND } from '../tools/time.js'; const URL_BASE = 'https://api.flumewater.com'; const URL_USERS = `${URL_BASE}/users/%s`; const URL_DEVICES = `${URL_USERS}/devices`; const URL_DEVICE = `${URL_DEVICES}/%s`; const URL_AUTH = `${URL_BASE}/oauth/token`; const URL_GET_LOCATIONS = `${URL_USERS}/locations`; const URL_GET_DEVICES = `${URL_DEVICES}?list_shared=true`; const URL_GET_NOTIFICATIONS = `${URL_USERS}/notifications?device_id=%s`; const URL_WATER_USAGE = `${URL_DEVICE}/query`; const URL_LEAK_INFO = `${URL_DEVICE}/leaks/active`; const HTTP_TIMEOUT = 10 * SECOND; const HTTP_RETRY_CODES = [ 'ERR_NETWORK', // General network error in Axios 'ETIMEDOUT', // Request timed out 'ECONNREFUSED', // Connection refused by server '429', // Too Many Requests (rate limit) '500', // Internal Server Error '502', // Bad Gateway '503', // Service Unavailable '504', // Gateway Timeout ]; const FULL_REFRESH_INTERVAL = 15 * MINUTE; const RETRY_INTERVALS = [1, 2, 5, 10, 15, 30, 60]; export class FlumeAPI { config; persistPath; log; _auth; locationNames = new Map(); _devices = new Map(); retryIndex = 0; syncTimer = null; lastFullRefresh = 0; constructor(config, persistPath, log) { this.config = config; this.persistPath = persistPath; this.log = log; } static async connect(config, persistPath, log) { const api = new FlumeAPI(config, persistPath, log); api._auth = await Auth.load(persistPath, config.clientId); let shouldContinue = true; if (!api.auth) { shouldContinue = await api.authenticate(); } if (shouldContinue) { await api.refreshAuthIfNecessary(); await api.getLocations(); await api.getDevices(); await api.synchronizeData(); api.startSyncTimer(); } return api; } get devices() { return Array.from(this._devices.values()); } teardown() { if (this.syncTimer) { clearInterval(this.syncTimer); this.syncTimer = null; } } // eslint-disable-next-line @typescript-eslint/no-explicit-any async do(caller, // eslint-disable-next-line @typescript-eslint/no-explicit-any data, shouldReturnArray, shouldRetry, url, ...parameters) { parameters.forEach(param => { url = url.replace('%s', param ?? ''); }); let config; if (this.auth) { config = { headers: { Authorization: `Bearer ${this.auth.token}` }, timeout: HTTP_TIMEOUT }; } else { config = { timeout: HTTP_TIMEOUT }; } try { let res; if (data) { res = await axios.post(url, data, config); } else { res = await axios.get(url, config); } if (!res.data || !res.data.data || (!shouldReturnArray && !res.data.data[0])) { this.logIfVerbose(caller, JSON.stringify(res.data)); throw new Error(strings.errors.noDataReceived); } const returnValue = shouldReturnArray ? res.data.data : res.data.data[0]; this.logIfVerbose(caller, res.data); this.retryIndex = 0; return returnValue; } catch (err) { if (shouldRetry) { return this.retryIfPossible(err, caller, () => this.do(caller, data, shouldReturnArray, shouldRetry, url, ...parameters)); } else { this.logHTTP("warn" /* LogLevel.WARN */, caller, err.message); return null; } } } // eslint-disable-next-line @typescript-eslint/no-explicit-any async retryIfPossible(err, caller, retry) { if (!isAxiosError(err)) { this.logHTTP("warn" /* LogLevel.WARN */, caller, err.message); return null; } const errorCode = err.code || err.response?.status?.toString() || 'UNKNOWN'; if (!HTTP_RETRY_CODES.includes(errorCode) || this.retryIndex >= RETRY_INTERVALS.length) { this.logHTTP("warn" /* LogLevel.WARN */, caller, err.message); return null; } this.logHTTP("warn" /* LogLevel.WARN */, caller, strings.errors.httpRetry, RETRY_INTERVALS[this.retryIndex]); await new Promise(resolve => setTimeout(resolve, RETRY_INTERVALS[this.retryIndex] * MINUTE)); this.retryIndex += 1; return await retry(); } get auth() { return this._auth ?? null; } async saveTokenData(tokenData) { this._auth = new Auth(tokenData); if (this._auth) { await this._auth.save(this.persistPath, this.config.clientId); } } async authenticate() { const data = { grant_type: 'password', client_id: this.config.clientId, client_secret: this.config.clientSecret, username: this.config.username, password: this.config.password, }; const tokenData = await this.do(this.authenticate.name, data, false, true, URL_AUTH); if (!tokenData) { return false; } await this.saveTokenData(tokenData); return true; } async authRefresh() { if (!this.auth?.refresh) { this.logIfVerbose(this.authRefresh.name, strings.errors.noRefreshToken); return await this.authenticate(); } const data = { grant_type: 'refresh_token', client_id: this.config.clientId, client_secret: this.config.clientSecret, refresh_token: this.auth.refresh, }; const tokenData = await this.do(this.authRefresh.name, data, false, true, URL_AUTH); if (!tokenData) { return await this.authenticate(); } await this.saveTokenData(tokenData); return true; } async refreshAuthIfNecessary() { if (Date.now() > (this.auth?.expiry ?? 0)) { await this.authRefresh(); } } startSyncTimer() { this.teardown(); // Note the Flume API has a limit of 120 requests per hour this.syncTimer = setInterval(async () => { await this.refreshAuthIfNecessary(); await this.synchronizeData(); }, MINUTE * this.config.refreshInterval); } async getLocations() { const locationDatum = await this.do(this.getLocations.name, null, true, true, URL_GET_LOCATIONS, this.auth?.userId); if (!locationDatum) { return false; } locationDatum.forEach(locationData => { this.locationNames.set(locationData.id, locationData.name); }); return true; } async getUnreadNotifications(deviceId) { const notificationDatum = await this.do(this.getUnreadNotifications.name, null, true, true, URL_GET_NOTIFICATIONS, this.auth?.userId, deviceId); if (!notificationDatum) { return null; } const response = new Set(); notificationDatum.forEach(notification => { if (!notification.read) { response.add(notification.type); } }); return response; } async getDevices() { const deviceDatum = await this.do(this.getDevices.name, null, true, true, URL_GET_DEVICES, this.auth?.userId); if (!deviceDatum) { return false; } deviceDatum.forEach(deviceData => { if (deviceData.bridge_id) { const device = new Device(deviceData); this._devices.set(device.id, device); } }); return true; } async getDeviceData(deviceId) { const deviceData = await this.do(this.getDeviceData.name, null, false, false, URL_DEVICE, this.auth?.userId, deviceId); if (!deviceData) { return null; } return deviceData; } async getLeakData(deviceId) { const leakData = await this.do(this.getLeakData.name, null, false, false, URL_LEAK_INFO, this.auth?.userId, deviceId); if (!leakData) { return null; } return leakData; } async getUsageData(deviceId) { // Generate dates for the query data const now = new Date(); const pad = (n) => n.toString().padStart(2, '0'); const startOfToday = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} 00:00:00`; const startOfCurrMonth = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01 00:00:00`; const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const startOfLastMonth = `${lastMonth.getFullYear()}-${pad(lastMonth.getMonth() + 1)}-01 00:00:00`; const data = { queries: [ { request_id: 'today', bucket: 'DAY', since_datetime: startOfToday, operation: 'SUM', units: this.config.units ?? Types.VolumeUnits.GALLONS, }, { request_id: 'month', bucket: 'MON', since_datetime: startOfCurrMonth, operation: 'SUM', units: this.config.units ?? Types.VolumeUnits.GALLONS, }, { request_id: 'lastMonth', bucket: 'MON', since_datetime: startOfLastMonth, until_datetime: startOfCurrMonth, operation: 'SUM', units: this.config.units ?? Types.VolumeUnits.GALLONS, }, ], }; const usageData = await this.do(this.getUsageData.name, data, false, false, URL_WATER_USAGE, this.auth?.userId, deviceId); if (!usageData) { return null; } return usageData; } async synchronizeData() { for (const device of this._devices.values()) { const id = device.id; let deviceData = null; let usageData = null; if (Date.now() - this.lastFullRefresh > FULL_REFRESH_INTERVAL) { deviceData = await this.getDeviceData(id); usageData = await this.getUsageData(id); this.lastFullRefresh = Date.now(); } let leakData = null; let unreadNotifications = null; if (this.config.useNotifications) { unreadNotifications = await this.getUnreadNotifications(id); } else { leakData = await this.getLeakData(id); } device.update(leakData, unreadNotifications, deviceData, usageData); } ; } logHTTP(level, caller, message, ...parameters) { this.log.log(level, '[HTTP %s()] %s', caller, message, ...parameters); } // eslint-disable-next-line @typescript-eslint/no-explicit-any logIfVerbose(caller, data) { if (!this.config.verbose) { return; } let message = JSON.stringify(data); Types.SENSITIVE_KEYS.forEach(key => { const regex = new RegExp(`"${key}"\\s*:\\s*(".*?"|\\d+|true|false|null)`, 'gi'); message = message.replace(regex, `"${key}": "${strings.general.redacted}"`); }); this.logHTTP("info" /* LogLevel.INFO */, caller, message); } } //# sourceMappingURL=api.js.map