UNPKG

homebridge-flume

Version:

Homebridge plugin to integrate Flume devices into HomeKit.

299 lines 11.2 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 '../lang/en.js'; import { MINUTE, SECOND } from '../tools/time.js'; const URL_AUTH = 'https://api.flumetech.com/oauth/token'; const URL_GET_LOCATIONS = 'https://api.flumewater.com/users/%s/locations'; const URL_GET_DEVICES = 'https://api.flumetech.com/users/%s/devices?list_shared=true'; const URL_GET_DEVICE = 'https://api.flumetech.com/users/%s/devices/%s'; const URL_WATER_USAGE = 'https://api.flumetech.com/users/%s/devices/%s/query'; const URL_LEAK_INFO = 'https://api.flumetech.com/users/%s/devices/%s/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 { username; password; clientId; clientSecret; refreshInterval; units; storagePath; log; verbose; _auth; locationNames = new Map(); _devices = new Map(); retryIndex = 0; syncTimer = null; lastFullRefresh = 0; constructor(username, password, clientId, clientSecret, refreshInterval, units, storagePath, log, verbose) { this.username = username; this.password = password; this.clientId = clientId; this.clientSecret = clientSecret; this.refreshInterval = refreshInterval; this.units = units; this.storagePath = storagePath; this.log = log; this.verbose = verbose; this.auth = Auth.load(this.storagePath, this.clientId); } static async connect(username, password, clientId, clientSecret, refreshInterval, units, storagePath, log, verbose) { const api = new FlumeAPI(username, password, clientId, clientSecret, refreshInterval, units, storagePath, log, verbose); let shouldContinue = true; if (!api.auth) { shouldContinue = await api.authenticate(); } if (shouldContinue) { 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 || !res.data.data[0]) { this.logHTTP("debug" /* LogLevel.DEBUG */, caller, JSON.stringify(res.data)); throw new Error(strings.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.log.warn(strings.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; } set auth(value) { this._auth = value; if (this._auth) { this._auth.save(this.storagePath, this.clientId); } } async authenticate() { const data = { grant_type: 'password', client_id: this.clientId, client_secret: this.clientSecret, username: this.username, password: this.password, }; const tokenData = await this.do(this.authenticate.name, data, false, true, URL_AUTH); if (!tokenData) { return false; } this.auth = new Auth(tokenData); return true; } async authRefresh() { if (!this.auth?.refresh) { this.logHTTP("debug" /* LogLevel.DEBUG */, this.authRefresh.name, strings.noRefreshToken); return await this.authenticate(); } const data = { grant_type: 'refresh_token', client_id: this.clientId, client_secret: this.clientSecret, refresh_token: this.auth.refresh, }; const tokenData = await this.do(this.authRefresh.name, data, false, true, URL_AUTH); if (!tokenData) { return false; } this.auth = new Auth(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(() => { this.synchronizeData(); }, MINUTE * this.refreshInterval); } async getLocations() { await this.refreshAuthIfNecessary(); 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 getDevices() { await this.refreshAuthIfNecessary(); 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_GET_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.units, }, { request_id: 'month', bucket: 'MON', since_datetime: startOfCurrMonth, operation: 'SUM', units: this.units, }, { request_id: 'lastMonth', bucket: 'MON', since_datetime: startOfLastMonth, until_datetime: startOfCurrMonth, operation: 'SUM', units: this.units, }, ], }; 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() { await this.refreshAuthIfNecessary(); 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(); } const leakData = await this.getLeakData(id); device.update(deviceData, leakData, usageData); } ; } logHTTP(level, caller, message) { this.log.log(level, '[HTTP %s()] %s', caller, message); } // eslint-disable-next-line @typescript-eslint/no-explicit-any logIfVerbose(caller, data) { if (!this.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.redacted}"`); }); this.logHTTP("info" /* LogLevel.INFO */, caller, message); } } //# sourceMappingURL=api.js.map