node-red-contrib-smartnora
Version:
Google Smart Home integration via Smart Nora https://smart-nora.eu/
284 lines (283 loc) • 13.4 kB
JavaScript
"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;