UNPKG

node-red-contrib-smartnora

Version:

Google Smart Home integration via Smart Nora https://smart-nora.eu/

284 lines (283 loc) 13.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RateLimitingError = exports.UnauthenticatedError = exports.FirebaseSync = void 0; const tslib_1 = require("tslib"); const https_1 = require("https"); const nora_firebase_common_1 = require("@andrei-tatar/nora-firebase-common"); const auth_1 = require("firebase/auth"); const database_1 = require("firebase/database"); const rxjs_1 = require("rxjs"); const operators_1 = require("rxjs/operators"); const __1 = require(".."); const config_1 = require("../config"); const fetch_1 = require("./fetch"); const device_1 = require("./device"); const media_device_1 = require("./media-device"); const scene_device_1 = require("./scene-device"); class FirebaseSync { constructor(app, group, logger) { this.app = app; this.group = group; this.logger = logger; this.agent = new https_1.Agent({ keepAlive: true, keepAliveMsecs: 15000, }); this.devices$ = new rxjs_1.BehaviorSubject([]); this.jobQueue$ = new rxjs_1.Subject(); this.lastSyncHash = '~'; this.sync$ = this.devices$.pipe((0, operators_1.debounceTime)(500), (0, operators_1.delayWhen)(() => { const miliseconds = Math.round(Math.random() * 200) * 50; return (0, rxjs_1.timer)(miliseconds); }), (0, operators_1.switchMap)(devices => (0, rxjs_1.concat)((0, rxjs_1.defer)(() => this.syncDevices(devices.map(d => d.device))), (0, rxjs_1.merge)(...devices.map(d => d.connectedAndSynced$)))), (0, operators_1.retry)({ delay: err => { var _a; if (err instanceof UnauthenticatedError) { return (0, rxjs_1.throwError)(() => err); } const seconds = Math.round(Math.random() * 1200) / 20 + 30; (_a = this.logger) === null || _a === void 0 ? void 0 : _a.warn(`nora: ${this.group} - unhandled error (trying again in ${seconds} sec): ${err.message}\n${err.stack}`); return (0, rxjs_1.timer)(seconds * 1000); } }), (0, operators_1.ignoreElements)(), (0, __1.singleton)()); this.handleJobs$ = this.jobQueue$.pipe((0, operators_1.groupBy)(this.getJobId), (0, operators_1.mergeMap)(jobsByType => jobsByType.pipe((0, __1.rateLimitSlidingWindow)(60000, 12, this.mergeJob))), (0, operators_1.mergeMap)(job => (0, rxjs_1.concat)(this.handleJob(job), (0, rxjs_1.defer)(() => { job.resolve(); return rxjs_1.EMPTY; })).pipe((0, operators_1.catchError)(err => { job.reject(err); return rxjs_1.EMPTY; })), 1), (0, operators_1.ignoreElements)(), (0, __1.publishReplayRefCountWithDelay)(1000)); this.groupUpdateHeartbeat$ = (0, rxjs_1.combineLatest)([ this.getOffset(), (0, rxjs_1.timer)(0, nora_firebase_common_1.HEARTBEAT_TIMEOUT_SEC * 1000), ]).pipe((0, operators_1.map)(([{ offset }]) => new Date().getTime() + offset), (0, operators_1.switchMap)(timestamp => (0, database_1.set)(this.groupHeartbeat, timestamp)), (0, __1.retryWithBackoff)({ logError: (err) => { var _a; return (_a = this.logger) === null || _a === void 0 ? void 0 : _a.warn(`nora: while sending heartbeat: ${err.message}`); }, })).pipe((0, operators_1.ignoreElements)()); this.connected$ = new rxjs_1.Observable(observer => (0, database_1.onValue)(this.connected, s => observer.next(!!s.val()))).pipe((0, operators_1.distinctUntilChanged)(), (0, operators_1.tap)(connected => { var _a; return (_a = this.logger) === null || _a === void 0 ? void 0 : _a.info(`nora: ${this.group} - ${connected ? 'connected' : 'disconnected'}`); }), (0, operators_1.switchMap)(connected => connected ? (0, rxjs_1.merge)(this.handleJobs$, this.sync$, this.groupUpdateHeartbeat$, (0, rxjs_1.of)(connected)) : (0, rxjs_1.of)(connected)), (0, __1.singleton)()); const user = (0, auth_1.getAuth)(this.app).currentUser; if (!user) { throw new UnauthenticatedError(); } this.uid = user.uid; this.db = (0, database_1.getDatabase)(app); this.states = (0, database_1.ref)(this.db, `device_states/${this.uid}/${this.group}`); this.noraSpecific = (0, database_1.ref)(this.db, `device_nora/${this.uid}/${this.group}`); this.groupHeartbeat = (0, database_1.ref)(this.db, `user/${this.uid}/version/${this.group}/heartbeat`); this.connected = (0, database_1.ref)(this.db, '.info/connected'); } withDevice(device, { ctx, disableValidationErrors = false } = {}) { return new rxjs_1.Observable(observer => { const cloudId = `${this.group}|${device.id}`; const firebaseDevice = (0, nora_firebase_common_1.isScene)(device) ? new scene_device_1.FirebaseSceneDevice(cloudId, this, device, this.logger, disableValidationErrors) : (0, nora_firebase_common_1.isTransportControlDevice)(device) || (0, nora_firebase_common_1.isChannelDevice)(device) ? new media_device_1.FirebaseMediaDevice(cloudId, this, device, this.logger, disableValidationErrors) : new device_1.FirebaseDevice(cloudId, this, device, this.logger, disableValidationErrors); observer.next(firebaseDevice); this.devices$.next(this.devices$.value.concat(firebaseDevice)); return () => this.devices$.next(this.devices$.value.filter(d => d !== firebaseDevice)); }).pipe((0, operators_1.switchMap)(d => ctx ? (0, rxjs_1.merge)(d.error$.pipe((0, operators_1.tap)(ctx === null || ctx === void 0 ? void 0 : ctx.error$), (0, operators_1.ignoreElements)()), d.local$.pipe((0, operators_1.tap)(ctx === null || ctx === void 0 ? void 0 : ctx.local$), (0, operators_1.ignoreElements)()), d.state$.pipe((0, operators_1.map)(s => s.online), (0, operators_1.tap)(ctx === null || ctx === void 0 ? void 0 : ctx.online$), (0, operators_1.ignoreElements)()), (0, rxjs_1.of)(d)) : (0, rxjs_1.of)(d))); } async updateState(deviceId, state) { await this.queueJob({ type: 'report-state', deviceId, update: state, }); } async sendNotification(notification) { await this.queueJob({ type: 'notify', notification, }); } async sendGoogleHomeNotification(deviceId, notification) { await this.queueJob({ type: 'notify-home', deviceId, notification, }); } watchForActions(identifier) { const actionRef = (0, database_1.ref)(this.db, `user/${this.uid}/actions/${identifier}`); return new rxjs_1.Observable(observer => (0, database_1.onValue)(actionRef, s => { const value = s.val(); if (value) { observer.next(value.action); } })).pipe((0, operators_1.switchMap)(async (v) => { await (0, database_1.remove)(actionRef); return v; })); } async syncDevices(devices) { var _a, _b; const syncAttributes = devices.map((_a) => { var { state: _ } = _a, allExceptState = tslib_1.__rest(_a, ["state"]); return allExceptState; }); syncAttributes.sort((a, b) => a.id.localeCompare(b.id)); const hash = (0, __1.getHash)(syncAttributes); if (this.lastSyncHash === hash) { (_a = this.logger) === null || _a === void 0 ? void 0 : _a.info(`nora: ${this.group} - no device changes`); return; } await this.queueJob({ type: 'sync', devices, }); this.lastSyncHash = hash; (_b = this.logger) === null || _b === void 0 ? void 0 : _b.info(`nora: ${this.group} - synced ${this.devices$.value.length} device(s)`); } getJobId(j) { switch (j.job.type) { case 'report-state': return `${j.job.type}-${j.job.deviceId}`; default: return j.job.type; } } mergeJob(current, previous) { switch (current.job.type) { case 'sync': previous.resolve(); return current; case 'report-state': if (previous.job.type !== 'report-state') { throw new Error('can\'t merge jobs with different types'); } previous.resolve(); return { job: { type: current.job.type, deviceId: current.job.deviceId, update: Object.assign(Object.assign({}, previous.job.update), current.job.update), }, resolve: current.resolve, reject: current.reject, }; case 'notify': previous.reject(new RateLimitingError('too many notifications per sec')); return current; case 'notify-home': previous.resolve(); return current; } } handleJob({ job }) { switch (job.type) { case 'sync': return this.doHttpCall({ path: 'sync', body: job.devices, }); case 'report-state': return this.doHttpCall({ path: 'update-state', query: `id=${encodeURIComponent(job.deviceId)}`, body: job.update, }); case 'notify': return this.doHttpCall({ path: 'notify', body: job.notification, }); case 'notify-home': return this.doHttpCall({ path: 'home-notify', body: job.notification, query: `id=${encodeURIComponent(job.deviceId)}`, }); default: return rxjs_1.EMPTY; } } async queueJob(job) { return new Promise((resolve, reject) => { this.jobQueue$.next({ job, resolve, reject }); }); } doHttpCall({ path, query = '', body, }) { return (0, rxjs_1.defer)(async () => { const user = (0, auth_1.getAuth)(this.app).currentUser; if (!user) { throw new UnauthenticatedError(); } const token = await user.getIdToken(); const url = `${config_1.API_ENDPOINT}/client/${path}?group=${encodeURIComponent(this.group)}&${query}`; const response = await (0, fetch_1.fetch)(url, { method: 'POST', agent: this.agent, headers: { 'authorization': `Bearer ${token}`, 'user-agent': `${config_1.USER_AGENT}:${this.uid}`, }, body, }); if (!response.ok) { throw new __1.HttpError(response.status, await response.text()); } return response; }).pipe((0, __1.retryWithBackoff)({ maxRetryCount: 3, shouldRetry: (err) => !(err instanceof __1.HttpError) || this.shouldRetryRequest(err.statusCode), }), (0, operators_1.ignoreElements)()); } shouldRetryRequest(status) { if (status === 429) { return true; } const h = Math.floor(status / 100); return h !== 2 && h !== 4; } getOffset() { return (0, rxjs_1.defer)(async () => { const user = (0, auth_1.getAuth)(this.app).currentUser; if (!user) { throw new UnauthenticatedError(); } const token = await user.getIdToken(); const response = await (0, fetch_1.fetch)(`${config_1.API_ENDPOINT}/client/offset?timestamp=${new Date().getTime()}`, { method: 'GET', agent: this.agent, headers: { 'authorization': `Bearer ${token}`, 'user-agent': `${config_1.USER_AGENT}:${this.uid}`, }, }); const { offset } = await response.json(); return { offset }; }).pipe((0, operators_1.concatMap)((value) => (0, rxjs_1.timer)(0, nora_firebase_common_1.HEARTBEAT_TIMEOUT_SEC * 1000).pipe((0, __1.scanWithFactory)((ctx, _) => { const now = new Date().getTime(); if (ctx.last !== -1) { const delta = Math.abs(((now - ctx.last) / 1000) - nora_firebase_common_1.HEARTBEAT_TIMEOUT_SEC); if (delta > 10) { throw new Error('offset changed'); } } ctx.last = now; return ctx; }, () => ({ last: -1 })), (0, operators_1.ignoreElements)(), (0, operators_1.startWith)(value)))); } } exports.FirebaseSync = FirebaseSync; class UnauthenticatedError extends Error { constructor() { super('No user authenticated'); } } exports.UnauthenticatedError = UnauthenticatedError; class RateLimitingError extends Error { constructor(msg) { super(msg); } } exports.RateLimitingError = RateLimitingError;