homebridge-nibe
Version:
Homebridge plugin for Nibe services
328 lines (284 loc) • 9.31 kB
text/typescript
import axios, {AxiosError} from 'axios';
import {EventEmitter} from 'events';
import * as dataDomain from '../DataDomain';
import {Data, DataFetcher} from '../DataDomain';
import {Logger} from '../PlatformDomain';
import * as api from './MyUplinkApiModel';
import {Cache} from '../util/Cache';
import moment from 'moment';
interface Options {
clientId: string;
clientSecret: string;
interval: number;
language: string;
showApiResponse: boolean;
}
interface Session extends api.Session {
expires_at?: number;
}
const consts = {
baseUrl: 'https://api.myuplink.com',
scope: 'READSYSTEM WRITESYSTEM',
grantType: 'client_credentials',
timeout: 45000,
userAgent: 'homebridge-nibe',
renewBeforeExpiry: 5 * 60 * 1000,
allowedParameters: [40067,40004,44362,40013,40014,40008,40025,40026,40075,40183,48132,43437],
};
export class MyUplinkApiFetcher extends EventEmitter implements DataFetcher {
private options: Options;
private log: Logger;
private interval: NodeJS.Timeout | null | undefined;
private active: boolean | undefined;
private systems: api.System[] | null | undefined;
private auth: Session | null | undefined;
private cache: Cache = new Cache();
private currentlySetting: [] = [];
constructor(options: Options, log: Logger) {
super();
this.options = options;
this.log = log;
axios.defaults.baseURL = consts.baseUrl;
axios.defaults.headers.common['user-agent'] = consts.userAgent;
axios.defaults.timeout = consts.timeout;
}
start(): void {
if (this.interval != null) {
return;
}
this.active = false;
const exec = (): void => {
if (this.active) {
return;
}
this.active = true;
this.fetch().then(() => {
this.active = false;
});
};
this.interval = setInterval(exec, <number>this.options.interval * 1000);
exec();
}
stop(): void {
if (this.interval == null) {
return;
}
clearInterval(this.interval);
this.interval = null;
}
private async fetch(): Promise<void> {
this.log.debug('Fetch data.');
try {
if (this.isTokenExpired()) {
this.log.debug('Token is expired / expires soon - refreshing');
const token = await this.getToken();
this.setSession(token);
}
if (this.systems == null) {
this.systems = await this.fetchSystems();
}
for (const system of this.systems) {
const subscriptions = await this.fetchPremiumSubscriptions(system.systemId);
for (const device of system.devices) {
const deviceInfo = await this.fetchDeviceInfo(device.id);
try {
const parameters = await this.fetchData(device);
const data = MyUplinkApiFetcher.mapData(system, subscriptions, device, deviceInfo, parameters);
if (data) {
this.log.debug(`Prepared data:\n${JSON.stringify(data)}`);
this._onData(data);
}
} catch (error) {
this._onError(error);
}
}
}
this.log.debug('All data fetched.');
} catch (error) {
this._onError(error);
}
}
private async getToken(): Promise<Session> {
this.log.debug('token()');
const body = {
client_id: this.options.clientId,
client_secret: this.options.clientSecret,
grant_type: consts.grantType,
scope: consts.scope,
};
const url = '/oauth/token';
try {
const now = Date.now();
const { data } = await axios.post<Session>(
url, new URLSearchParams(body).toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
if(this.options.showApiResponse) {
this.log.info('Nibe data from '+url+': ' +JSON.stringify(data));
}
const expiresIn = data.expires_in ?? 3600;
data.expires_at = now + expiresIn * 1000;
return data;
} catch (error) {
throw this.checkError(url, error);
}
}
private async fetchSystems(): Promise<api.System[]> {
this.log.debug('Fetch units.');
const response = await this.cache.get<api.SystemMeResponse>(
'/v2/systems/me',
30,
'MINUTES',
async () => {
return await this.getFromMyUplink<api.SystemMeResponse>('/v2/systems/me');
},
);
this.log.debug(`${response.systems.length} units fetched.`);
return response.systems;
}
private async fetchDeviceInfo(id): Promise<api.DeviceInfo> {
this.log.debug('Fetch device info.');
return await this.getFromMyUplink<api.DeviceInfo>(`/v2/devices/${id}`);
}
private async fetchPremiumSubscriptions(id): Promise<string[]> {
this.log.debug('Fetch premium subscriptions info.');
const response = await this.cache.get<api.Subscriptions>(
`/v2/systems/${id}/subscriptions`,
10,
'MINUTES',
async () => {
return await this.getFromMyUplink<api.Subscriptions>(`/v2/systems/${id}/subscriptions`);
},
);
if (!response) {
return [];
}
return response.subscriptions
.filter(s => moment(s.validUntil).isAfter(moment()))
.map(s => s.type);
}
private async fetchData(device: api.Device): Promise<api.Parameter[]> {
this.log.debug('Fetch units.');
const response = await this.getFromMyUplink<api.Parameter[]>(
`/v2/devices/${device.id}/points`, {
parameters: consts.allowedParameters.join(','),
},
);
this.log.debug(`${response.length} parameters fetched.`);
return response;
}
static mapData(system: api.System, subscriptions: string[], device: api.Device, deviceInfo: api.DeviceInfo, response: api.Parameter[]): Data {
return {
system: {
systemId: system.systemId,
name: system.name,
premiumSubscriptions: subscriptions,
},
device: {
id: device.id,
name: device.product.name,
serialNumber: device.product.serialNumber,
firmwareUpdateAvailable: deviceInfo.firmware?.currentFwVersion !== deviceInfo.firmware?.desiredFwVersion,
},
parameters: response.map(p => {
return {
id: p.parameterId,
name: p.parameterName,
unit: p.parameterUnit,
value: p.value,
};
}),
};
}
private async getFromMyUplink<T>(url: string, params: object = {}): Promise<T> {
this.log.debug(`GET ${url}, params: ${JSON.stringify(params)}`);
try {
const { data } = await axios.get<T>(url, {
headers: {
Authorization: 'Bearer ' + this.getSession('access_token'),
},
params,
});
if(this.options.showApiResponse) {
this.log.info('Nibe data from '+url+': ' +JSON.stringify(data));
}
return data;
} catch (error) {
throw this.checkError(url, error);
}
}
public async setValue(deviceId: string, paramId: string, value: any): Promise<void> {
const key = deviceId+paramId+JSON.stringify(value);
if (this.currentlySetting[key]) {
return;
}
this.active = true;
this.currentlySetting[key] = true;
const url = `/v2/devices/${deviceId}/points`;
const body = {};
body[paramId] = value;
this.log.debug(`PUT ${url}, params: ${JSON.stringify(body)}`);
try {
axios.patch(url, body, {
headers: {
Authorization: 'Bearer ' + this.getSession('access_token'),
},
}).then(result => {
if(this.options.showApiResponse) {
this.log.info('Nibe data from '+url+': ' +JSON.stringify(result.data));
}
}).finally(() => {
delete this.currentlySetting[key];
this.active = false;
this.fetch();
});
} catch (error) {
this.log.error(`error from ${url}: ${JSON.stringify(error)}`);
}
}
private checkError(url: string, error: unknown): unknown {
this.log.error(`error from ${url}`);
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response != null) {
if (axiosError.response.status === 401) {
this.clearSession();
}
if (axiosError.response.data != null) {
const responseText = JSON.stringify(axiosError.response.data, null, ' ');
const errorMessage = `${axiosError.response.statusText}: ${responseText}`;
return new Error(errorMessage);
} else {
return new Error(axiosError.response.statusText);
}
}
}
return error;
}
private getSession(key: keyof Session): string | number | undefined | null {
this.log.debug('Get session.');
return this.auth ? this.auth[key] : null;
}
private setSession(auth: Session): void {
this.log.debug('Set session.');
this.auth = auth;
}
private clearSession(): void {
this.log.debug('Clear session.');
this.setSession({});
}
private isTokenExpired(): boolean {
const expired = (Number(this.getSession('expires_at')) || 0) < Date.now() + consts.renewBeforeExpiry;
this.log.debug('Is token expired: ' + expired);
return expired;
}
private _onData(data: dataDomain.Data): void {
this.emit('data', data);
}
private _onError(error: unknown): void {
this.emit('error', error);
}
}