UNPKG

homebridge-nibe

Version:
328 lines (284 loc) 9.31 kB
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); } }