homebridge-flume
Version:
Homebridge plugin to integrate Flume devices into HomeKit.
299 lines • 11.2 kB
JavaScript
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