UNPKG

obniz

Version:

obniz sdk for javascript

1,047 lines (1,046 loc) 34.5 kB
"use strict"; /** * @packageDocumentation * @module ObnizCore */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ObnizConnection = void 0; const eventemitter3_1 = __importDefault(require("eventemitter3")); const ws_1 = __importDefault(require("ws")); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const package_1 = __importDefault(require("../../package")); // pakcage.js will be created from package.json on build. const wscommand_1 = require("./libs/wscommand"); const ObnizError_1 = require("./ObnizError"); class ObnizConnection extends eventemitter3_1.default { constructor(id, options) { super(); this._measureTraffic = null; /** * This variable sets interval time to check connection state to obniz Device. * */ this.connectionCheckLoopInterval = null; this.socket = null; this.socket_local = null; this.wsCommandManager = wscommand_1.WSCommandManagerInstance; this._sendQueueTimer = null; this._sendQueue = null; this._waitForLocalConnectReadyTimer = null; this._sendPool = null; this._repeatInterval = 100; this._isLoopProcessing = false; this._nextLoopTimeout = null; this._nextPingTimeout = null; this._nextAutoConnectLoopTimeout = null; this._lastDataReceivedAt = 0; this._localConnectIp = null; this.isNode = typeof window === 'undefined'; this.id = id; this.socket = null; this.socket_local = null; this.debugprint = false; this.debugprintBinary = false; this._onConnectCalled = false; this.hw = undefined; this.firmware_ver = undefined; this.connectionState = 'closed'; // closed/connecting/connected/closing this.bufferdAmoundWarnBytes = 10 * 1000 * 1000; // 10M bytes this._connectionRetryCount = 0; if (!options) { options = {}; } this.options = { binary: options.binary === false ? false : true, local_connect: options.local_connect === false ? false : true, debug_dom_id: options.debug_dom_id || 'obniz-debug', auto_connect: options.auto_connect === false ? false : true, access_token: options.access_token || null, obniz_server: options.obniz_server || 'wss://obniz.io', reset_obniz_on_ws_disconnection: options.reset_obniz_on_ws_disconnection === false ? false : true, obnizid_dialog: options.obnizid_dialog === false ? false : true, }; if (this.autoConnect) { this._startAutoConnectLoopInBackground(); } } /** * obniz.js version */ static get version() { return package_1.default.version; } static isIpAddress(str) { const regex = /^((25[0-5]|(2[0-4]|1[0-9]|[1-9]|)[0-9])(\.(?!$)|$)){4}$/; return regex.exec(str) !== null; } get autoConnect() { return this.options.auto_connect; } set autoConnect(val) { const before = this.options.auto_connect; this.options.auto_connect = !!val; if (before !== this.options.auto_connect) { if (this.options.auto_connect) { this._startAutoConnectLoopInBackground(); } else { this._stopAutoConnectLoopInBackground(); } } } startCommandPool() { this._sendPool = []; } endCommandPool() { const pool = this._sendPool; this._sendPool = null; return pool; } /** * With this you wait until the connection to obniz Board succeeds. * * ```javascript * var obniz = new Obniz('1234-5678'); * * await obniz.connectWait(); * * obniz.io0.output(true); * await obniz.closeWait(); * * ``` * * * - with timeout * * ```javascript * var obniz = new Obniz('1234-5678'); * * await obniz.connectWait({timeout:10}); //timeout 10sec * * if(connected){ * obniz.io0.output(true); * await obniz.closeWait(); * } * ``` * * - with auto_connect:false * * If the param auto_connect is set as false, it will try to connect only once and, if unsuccessful, return false. * * ```javascript * var obniz = new Obniz('1234-5678',{auto_connect: false}); * * var connected = await obniz.connectWait(); //try once * * if(connected){ * obniz.io0.output(true); * await obniz.closeWait(); * } * ``` * * @param option.timeout timeout in seconds * @return False will be returned when connection is not established within a set timeout. */ async connectWait(option) { option = option || {}; const timeout = option.timeout || null; if (this.connectionState === 'connected') { return true; } if (!this.autoConnect) { // only try once try { await this.tryWsConnectOnceWait(); return true; } catch (e) { return false; } } return new Promise((resolve, reject) => { if (this._onConnectCalled) { resolve(true); return; } this.once('connect', () => { resolve(true); }); if (timeout) { setTimeout(() => { resolve(false); }, timeout * 1000); } }); } /** * You can connect to obniz Board manually by calling connect() when auto_connect is set to be false. * * ```javascript * var obniz = new Obniz('1234-5678', { auto_connect: false }); * * obniz.connect(); * obniz.onconnect = async function() { * obniz.io0.output(true); * } * ``` */ connect() { if (this.connectionState === 'connected' || this.connectionState === 'connecting') { return; } this.tryWsConnectOnceWait().catch((e) => { this.error(e); }); } /** * This closes the current connection. * You need to set auto_connect to false. Otherwise the connection will be recovered. * * ```javascript * var obniz = new Obniz('1234-5678', { * auto_connect: false, * reset_obniz_on_ws_disconnection: false * }); * * obniz.connect(); * obniz.onconnect = async function() { * obniz.io0.output(true); * obniz.close(); * } * ``` * * @deprecated replace with {@link closeWait} */ close() { // noinspection JSIgnoredPromiseFromCall this.closeWait().catch((e) => { // background this.error(e); }); } /** * This closes the current connection. * You need to set auto_connect to false. Otherwise the connection will be recovered. * * ```javascript * var obniz = new Obniz('1234-5678', { * auto_connect: false, * reset_obniz_on_ws_disconnection: false * }); * * obniz.connect(); * obniz.onconnect = async function() { * obniz.io0.output(true); * await obniz.closeWait(); * } * ``` * */ async closeWait() { this.autoConnect = false; if (this.connectionState === 'connecting' || this.connectionState === 'connected' || this.connectionState === 'closing') { this.connectionState = 'closing'; const p = new Promise((resolve) => { this.once('_close', resolve); }); this._disconnectCloudRequest(); await p; } } /** * Send json/binary data to obniz Cloud or device. * * @param obj send data * @param options send option * @param options.local_connect If false, send data via gloval internet. */ send(obj, options) { options = options || {}; options.local_connect = options.local_connect !== false; options.connect_check = options.connect_check !== false; if (options.connect_check && this.connectionState !== 'connected') { throw new ObnizError_1.ObnizOfflineError(); } try { if (!obj || typeof obj !== 'object') { this.log('obnizjs. didnt send ', obj); return; } if (Array.isArray(obj)) { for (let i = 0; i < obj.length; i++) { this.send(obj[i]); } return; } if (this._sendPool) { this._sendPool.push(obj); return; } let sendData = JSON.stringify([obj]); if (this.debugprint) { this._print_debug('send: ' + sendData); } /* compress */ if (this.options.binary && options.local_connect) { let compressed; try { compressed = this.wsCommandManager.compress(JSON.parse(sendData)[0]); if (compressed) { sendData = compressed; if (this.debugprintBinary) { this.log('binalized(send): ' + new Uint8Array(compressed).toString()); } } } catch (e) { this.error({ alert: 'error', message: '------ errored json -------', }); this.error({ alert: 'error', message: sendData, }); throw e; } } /* queue sending */ if (typeof sendData === 'string') { this._drainQueued(); this._sendRouted(sendData); } else { if (this._sendQueue) { this._sendQueue.push(sendData); } else { this._sendQueue = [sendData]; this._sendQueueTimer = setTimeout(this._drainQueued.bind(this), 0); } } } catch (e) { this.log(e); } } /** * @ignore * @param msg */ warning(msg) { this.log('warning:' + msg); } /** * @ignore * @param msg */ error(msg) { console.error(`[obniz ${this.id}] error:${msg.message}`); } /** * @ignore */ log(...args) { console.log(new Date(), `[obniz ${this.id}]`, ...args); } /** * @ignore * @private */ _runUserCreatedFunction(func, ...args) { if (!func) { return; } if (typeof func !== 'function') { return; } let promise; try { promise = func(...args); if (promise instanceof Promise) { promise.catch((err) => { setTimeout(() => { throw err; }); }); } } catch (err) { console.error(`obniz.js handled Exception inside of ${func}`); setTimeout(() => { throw err; }); } return promise; } /** * Sets the execution interval of onLoop function. * Changes will be reflected after the next onloop is executed. * * @param interval interval of execution in milliseconds. */ setLoopInterval(interval) { this._repeatInterval = interval; } /** * Set onloop function. Use onloop property instead. This is deprecated function. * * @param callback * @param interval default 100. It mean 100ms interval loop. * @deprecated */ repeat(callback, interval) { if (this.onloop) { this.onloop = callback; this._repeatInterval = interval || this._repeatInterval || 100; return; } this.onloop = callback; this._repeatInterval = interval || 100; } _close() { this._stopLoopInBackground(); this._drainQueued(); this._disconnectLocal(); this._disconnectCloud(); this._onConnectCalled = false; } wsOnOpen() { this._print_debug('ws connected'); this._connectionRetryCount = 0; // wait for {ws:{ready:true}} object if (typeof this.onopen === 'function') { this.onopen(this); } } wsOnMessage(data) { if (Array.isArray(data)) { for (const b of data) { this.wsOnMessage(data); } return; } this._lastDataReceivedAt = new Date().getTime(); if (this._measureTraffic) { const trafficSize = this._calcTrafficSize(data, this._measureTraffic.ceilByte); this._measureTraffic.readByte += trafficSize; this._measureTraffic.readCount++; } try { let json; if (typeof data === 'string') { json = JSON.parse(data); } else { const binary = new Uint8Array(data); // binary if (this.debugprintBinary) { this.log('binalized: ' + binary.toString()); } json = this.wsCommandManager.binary2Json(binary); } if (Array.isArray(json)) { for (const i in json) { this._notifyToModule(json[i]); } } else { // invalid json } } catch (e) { console.error(e); this.error(e); } } wsOnClose(event) { this._print_debug(`closed from remote event=${event}`); this.connectionState = 'closing'; const beforeOnConnectCalled = this._onConnectCalled; this._close(); this.connectionState = 'closed'; this.emit('_close', this); if (beforeOnConnectCalled === true) { this.emit('close', this); this._runUserCreatedFunction(this.onclose, this); } if (this.autoConnect) { this._startAutoConnectLoopInBackground(); } this._stopPingLoopInBackground(); } wsOnError(event) { this._print_debug(`ws onerror event=${event}`); } wsOnUnexpectedResponse(req, res) { if (res && res.statusCode === 404) { this._print_debug('obniz not online'); } else { this._print_debug('invalid server response ' + res ? res.statusCode : ''); } this._disconnectCloudRequest(); } async tryWsConnectOnceWait(desired_server) { this.connectionState = 'connecting'; await this._connectCloudWait(desired_server); try { let localConnectTimeoutRef; const localConnectTimeout = new Promise((resolve, reject) => { const localConnectTimeoutError = new Error('Cannot use local_connect because the connection was timeouted'); localConnectTimeoutRef = setTimeout(() => { reject(localConnectTimeoutError); }, 3000); }); await Promise.race([localConnectTimeout, this._connectLocalWait()]); if (localConnectTimeoutRef) { clearTimeout(localConnectTimeoutRef); } } catch (e) { // cannot connect local this.error(e); this._disconnectLocal(); } this._callOnConnect(); } _connectCloudWait(desired_server) { let server = this.options.obniz_server; if (desired_server) { server = '' + desired_server; } if (this.socket && this.socket.readyState <= 1) { // if already connected or connecting, reset it. this._close(); } let url = server + '/obniz/' + this.id + '/ws/1'; if (this.constructor.isIpAddress(this.id)) { url = `ws://${this.id}/`; } const query = []; if (this.constructor.version) { query.push('obnizjs=' + this.constructor.version); } if (this.options.access_token) { query.push('access_token=' + this.options.access_token); } if (this.options.binary) { query.push('accept_binary=true'); } if (query.length > 0) { url += '?' + query.join('&'); } this._print_debug('connecting to ' + url); return new Promise((resolve, reject) => { const release = () => { if (redirect) { this.off('_cloudConnectRedirect', redirect); redirect = null; } if (ready) { this.off('_cloudConnectReady', ready); ready = null; } if (closed) { this.off('_cloudConnectClose', closed); closed = null; } }; let redirect = (host) => { release(); this._connectCloudWait(host).then(resolve).catch(reject); }; this.once('_cloudConnectRedirect', redirect); let ready = () => { release(); resolve(); }; this.once('_cloudConnectReady', ready); let closed = () => { release(); reject(new Error('Connection closed')); }; this.once('_cloudConnectClose', closed); this.socket = this._createCloudSocket(url); }); } _createCloudSocket(url) { const socket = new ws_1.default(url); socket.on('open', () => { this.wsOnOpen(); }); socket.on('message', (msg) => { this.wsOnMessage(msg); }); socket.on('close', (event) => { this.wsOnClose(event); }); socket.on('error', (err) => { this.wsOnError(err); }); socket.on('unexpected-response', (req, res) => { this.wsOnUnexpectedResponse(req, res); }); return socket; } _connectLocalWait() { const host = this._localConnectIp; this.connectionCheckLoopInterval = null; // local connectしないのであれば OSとの通信確認は不要 if (!host || !this.options.binary || !this.options.local_connect) { return; // cannot local connect // throw new Error( // 'Cannot use local_connect because target device is on a different network' // ); } if (!this._canConnectToInsecure()) { return; // cannot local connect // throw new Error( // 'Cannot use local_connect because this page use HTTP protocol' // ); } const url = 'ws://' + host; this._print_debug('local connect to ' + url); const ws = new ws_1.default(url); ws.on('open', () => { this._print_debug('connected to ' + url); this.emit('_localConnectReady'); }); ws.on('message', (data) => { this._print_debug('recvd via local'); this.wsOnMessage(data); }); ws.on('close', (event) => { this.log('local websocket closed'); this._disconnectLocal(); }); ws.on('error', (err) => { console.error('local websocket error.', err); this._disconnectLocal(); }); ws.on('unexpected-response', (event) => { this.log('local websocket closed'); this._disconnectLocal(); }); this.socket_local = ws; return new Promise((resolve, reject) => { this.once('_localConnectReady', () => { this.connectionCheckLoopInterval = 60 * 1000; // local connectするのであればOSとの通信確認は必要 resolve(); }); this.once('_localConnectClose', () => { reject(new Error('Cannot use local_connect because the connection was rejected')); }); }); } _disconnectLocal() { if (this.socket_local) { if (this.socket_local.readyState <= 1) { this.socket_local.close(); } this._clearSocket(this.socket_local); this.socket_local = null; } this.emit('_localConnectClose'); } _disconnectCloudRequest() { if (this.socket) { if (this.socket.readyState <= 1) { // Connecting & Connected this.connectionState = 'closing'; this.socket.close(1000, 'close'); } } } _disconnectCloud(notify = true) { this._disconnectLocal(); if (this.socket) { if (this.socket.readyState <= 1) { this.socket.close(1000, 'close'); } this._clearSocket(this.socket); this.socket = null; } if (notify) { this.emit('_cloudConnectClose'); } } _clearSocket(socket) { if (!socket) { return; } /* send queue */ if (this._sendQueueTimer) { this._sendQueue = null; clearTimeout(this._sendQueueTimer); this._sendQueueTimer = null; } /* unbind */ const shouldRemoveObservers = [ 'open', 'message', 'close', 'error', 'unexpected-response', ]; for (let i = 0; i < shouldRemoveObservers.length; i++) { socket.removeAllListeners(shouldRemoveObservers[i]); } } /** * This function will be called before obniz.onconnect called; */ _beforeOnConnect() { // do nothing. } _callOnConnect() { this.connectionState = 'connected'; const currentTime = new Date().getTime(); this._lastDataReceivedAt = currentTime; // reset this._beforeOnConnect(); this.emit('connect', this); let promise; this._onConnectCalled = true; if (typeof this.onconnect === 'function') { promise = this._runUserCreatedFunction(this.onconnect, this); } this._startPingLoopInBackground(); if (promise instanceof Promise) { promise.finally(() => { this._startLoopInBackgroundWait(); }); } else { this._startLoopInBackgroundWait(); } } _print_debug(str) { if (this.debugprint) { this.log(str); } } _sendRouted(data) { if (this._measureTraffic) { const trafficSize = this._calcTrafficSize(data, this._measureTraffic.ceilByte); this._measureTraffic.sendByte += trafficSize; this._measureTraffic.sendCount++; } if (this.socket_local && this.socket_local.readyState === 1 && typeof data !== 'string') { this._print_debug('send via local'); this.socket_local.send(data); if (this.socket_local.bufferedAmount > this.bufferdAmoundWarnBytes) { this.warning('over ' + this.socket_local.bufferedAmount + ' bytes queued'); } return; } if (this.socket && this.socket.readyState === 1) { this.socket.send(data); if (this.socket.bufferedAmount > this.bufferdAmoundWarnBytes) { this.warning('over ' + this.socket.bufferedAmount + ' bytes queued'); } return; } } _drainQueued() { if (!this._sendQueue) { return; } let expectSize = 0; for (let i = 0; i < this._sendQueue.length; i++) { expectSize += this._sendQueue[i].length; } let filled = 0; const sendData = new Uint8Array(expectSize); for (let i = 0; i < this._sendQueue.length; i++) { sendData.set(this._sendQueue[i], filled); filled += this._sendQueue[i].length; } this._sendRouted(sendData); this._sendQueue = null; if (this._sendQueueTimer) { clearTimeout(this._sendQueueTimer); this._sendQueueTimer = null; } } _notifyToModule(obj) { if (this.debugprint) { this._print_debug(JSON.stringify(obj)); } if (obj.ws) { this._handleWSCommand(obj.ws); return; } if (obj.system) { this._handleSystemCommand(obj.system); return; } } _canConnectToInsecure() { if (this.isNode) { return true; } else { return location.protocol !== 'https:'; } } _handleWSCommand(wsObj) { if (wsObj.ready) { const wsObniz = wsObj.obniz; this.firmware_ver = wsObniz.firmware; this.plugin_name = wsObniz.plugin_name; this.boot_reason = wsObniz.boot_reason; this.hw = wsObniz.hw; if (!this.hw) { this.hw = 'obnizb1'; } this.wsCommandManager.setHw({ hw: this.hw, firmware: this.firmware_ver, }); if (this.options.reset_obniz_on_ws_disconnection) { this.resetOnDisconnect(true); } if (wsObniz.metadata) { try { this.metadata = JSON.parse(wsObj.obniz.metadata); } catch (e) { // ignore parsing error. } } if (wsObniz.connected_network) { this.connected_network = wsObniz.connected_network; } if (wsObj.local_connect && wsObj.local_connect.ip) { this._localConnectIp = wsObj.local_connect.ip; } this.emit('_cloudConnectReady'); } if (wsObj.redirect) { const urlString = wsObj.redirect; this._print_debug('WS connection changed to ' + urlString); const url = new URL(urlString); const host = url.origin; const paths = url.pathname; if (paths && paths.split('/').length === 5) { // migrate obnizID this.id = paths.split('/')[2]; } /* close current ws immediately */ this._disconnectCloud(false); this.emit('_cloudConnectRedirect', host); } } _handleSystemCommand(wsObj) { // do nothing. } async _startLoopInBackgroundWait() { this._stopLoopInBackground(); if (this._isLoopProcessing || this.connectionState !== 'connected') { return; } this._isLoopProcessing = true; try { if (typeof this.onloop === 'function') { await this.onloop(this); } } catch (e) { console.error(`obniz.js handled Exception inside of obniz.onloop function`); console.error(e); } this._isLoopProcessing = false; if (this._nextLoopTimeout || this.connectionState !== 'connected') { return; } const interval = typeof this.onloop === 'function' ? this._repeatInterval : 100; this._nextLoopTimeout = setTimeout(this._startLoopInBackgroundWait.bind(this), interval); } _stopLoopInBackground() { if (this._nextLoopTimeout) { clearTimeout(this._nextLoopTimeout); this._nextLoopTimeout = null; } } _startAutoConnectLoopInBackground() { if (!this.autoConnect) { return; } this.connectionState = 'connecting'; this._connectionRetryCount++; let tryAfter = this._connectionRetryCount === 1 ? 0 : 1000; if (this._connectionRetryCount > 15) { tryAfter = (this._connectionRetryCount - 15) * 1000; const Limit = this.isNode ? 60 * 1000 : 10 * 1000; if (tryAfter > Limit) { tryAfter = Limit; } } this._stopAutoConnectLoopInBackground(); this._nextAutoConnectLoopTimeout = setTimeout(async () => { if (this._nextAutoConnectLoopTimeout) { clearTimeout(this._nextAutoConnectLoopTimeout); } this._nextAutoConnectLoopTimeout = null; if (!this.autoConnect) { return; } try { await this.tryWsConnectOnceWait(); } catch (e) { // cannot connect this._startAutoConnectLoopInBackground(); } }, tryAfter); } _stopAutoConnectLoopInBackground() { if (this._nextAutoConnectLoopTimeout) { clearTimeout(this._nextAutoConnectLoopTimeout); this._nextAutoConnectLoopTimeout = null; } } _startPingLoopInBackground() { if (this._nextPingTimeout) { clearTimeout(this._nextPingTimeout); } this._nextPingTimeout = setTimeout(async () => { var _a; if (this._nextPingTimeout) { clearTimeout(this._nextPingTimeout); } this._nextPingTimeout = null; const enablePingPong = this.connectionCheckLoopInterval !== null; const loopInterval = (_a = this.connectionCheckLoopInterval) !== null && _a !== void 0 ? _a : 60 * 1000; const loopTimeout = 30 * 1000; // 30 sec if (!enablePingPong) { return; } if (this.connectionState !== 'connected') { return; } const currentTime = new Date().getTime(); // after 15 sec from last data received if (this._lastDataReceivedAt + loopTimeout < currentTime) { const time = this._lastDataReceivedAt; try { const p = this.pingWait(); const wait = new Promise((resolve, reject) => { setTimeout(reject, loopTimeout); }); await Promise.race([p, wait]); // this.log("ping/pong success"); } catch (e) { if (this.connectionState !== 'connected') { // already closed } else if (time !== this._lastDataReceivedAt) { // this will be disconnect -> reconnect while pingWait } else { // ping error or timeout // this.error("ping/pong response timeout error"); this.wsOnClose('ping/pong response timeout error'); return; } } } else { // this.log("ping/pong not need"); } if (this.connectionState === 'connected') { if (!this._nextPingTimeout) { this._nextPingTimeout = setTimeout(this._startPingLoopInBackground.bind(this), loopInterval); } } }, 0); } _stopPingLoopInBackground() { if (this._nextPingTimeout) { clearTimeout(this._nextPingTimeout); this._nextPingTimeout = null; } } throwErrorIfOffline() { if (this.connectionState !== 'connected') { throw new ObnizError_1.ObnizOfflineError(); } } startTrafficMeasurement(ceil = 1) { if (!this.socket_local) { throw new Error('Cannot measure traffic data outside of local connect'); } if (!this._measureTraffic) { this._measureTraffic = { ceilByte: ceil, readByte: 0, readCount: 0, sendByte: 0, sendCount: 0, }; } } getTrafficData() { if (!this._measureTraffic) { return { readByte: 0, readCount: 0, sendByte: 0, sendCount: 0, ceilByte: 1, }; } return { readByte: this._measureTraffic.readByte, readCount: this._measureTraffic.readCount, sendByte: this._measureTraffic.sendByte, sendCount: this._measureTraffic.sendCount, ceilByte: this._measureTraffic.ceilByte, }; } resetTrafficMeasurement() { if (this._measureTraffic) { const data = this.getTrafficData(); this._measureTraffic = { ceilByte: this._measureTraffic.ceilByte, readByte: 0, readCount: 0, sendByte: 0, sendCount: 0, }; return data; } return null; } endTrafficMeasurement() { const data = this.getTrafficData(); this._measureTraffic = null; return data; } _calcTrafficSize(data, ceil) { let trafficSize; if (data instanceof Buffer) { trafficSize = data.length; } else if (data instanceof ArrayBuffer) { trafficSize = data.byteLength; } else { trafficSize = data.length * 8; } const ceiledTrafficSize = Math.round(Math.ceil(trafficSize / ceil) * ceil); return ceiledTrafficSize; } } exports.ObnizConnection = ObnizConnection;