You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

435 lines
12 KiB
JavaScript

class DeviceEvent extends Event {
constructor(name, device) {
super(name);
this.device = device;
}
}
class DeviceCommandEvent extends DeviceEvent {
constructor(name, device, cmd, data) {
super(name, device);
this.cmd = cmd;
this.data = data;
}
}
class DeviceUpdateEvent extends DeviceEvent {
constructor(device, name, data) {
super("update", device);
this.name = name;
this.data = data;
}
}
class DeviceErrorEvent extends DeviceEvent {
constructor(device, error) {
super("error", device);
this.error = error;
}
}
class DeviceConnectionStatusEvent extends DeviceEvent {
constructor(device, status) {
super("connectionstatus", device);
this.status = status;
}
}
class Device extends EventEmitter {
/** @type {Connection[]} */
active_connections = [];
/** @type {object} */
info;
#input_queue;
#pingTimer;
// device
prev_set = {};
skip_prd = 1000; // skip updates
tout_prd = 2500; // connection timeout
// external
granted = false;
cfg_flag = false;
/**
* @param {GyverHub} hub
* @param {string} id
*/
constructor(hub, id) {
super();
this._hub = hub;
this.info = hub.config.getDevice(id);
this.#input_queue = new InputQueue(1000, 1000); // TODO config
this.#pingTimer = new AsyncTimer(3000, async () => {
try {
await this.#postAndWait('ping', ['OK']);
this.dispatchEvent(new DeviceConnectionStatusEvent(this, true));
} catch (e) {
console.log('[PING]', e);
this.dispatchEvent(new DeviceConnectionStatusEvent(this, false));
}
this.#pingTimer.restart();
})
}
/**
* Check if module is enabled
* @param {Module} mod
* @returns {boolean}
*/
isModuleEnabled(mod) {
return !(this.info.modules & mod);
}
//#region Communication
/**
* Send command to device
* @param {string} cmd
* @param {string} name
* @param {string} value
*/
async #post(cmd, name = '', value = '') {
if (cmd == 'set' && name && this.isModuleEnabled(Modules.SET)) {
if (this.prev_set[name]) clearTimeout(this.prev_set[name]);
this.prev_set[name] = setTimeout(() => delete this.prev_set[name], this.skip_prd);
}
console.log('[OUT]', this.info.id, cmd, name, value);
await this.getConnection().post(this, cmd, name, value);
}
/**
* Send command to device and wait for response
* @param {string} cmd Command
* @param {string[]} types List of possible responses
* @param {string} name
* @param {string} value
* @returns {Promise<[string, object]>}
*/
async #postAndWait(cmd, types, name = '', value = '') {
this.dispatchEvent(new DeviceEvent("transferstart", this));
let res;
try {
await this.#post(cmd, name, value);
res = await this.#input_queue.get(types);
} catch (e) {
this.dispatchEvent(new DeviceEvent("transfererror", this));
this.dispatchEvent(new DeviceEvent("transferend", this));
throw e;
}
this.dispatchEvent(new DeviceEvent("transfersuccess", this));
this.dispatchEvent(new DeviceEvent("transferend", this));
return res;
}
async _parse(type, data) {
this.#input_queue.put(type, data);
this.dispatchEvent(new DeviceCommandEvent("command", this, type, data));
this.dispatchEvent(new DeviceCommandEvent("command." + type, this, type, data));
switch (type) {
case 'ui':
await this.#postAndWait('unix', ['OK'], Math.floor(new Date().getTime() / 1000));
break;
case 'refresh':
await this.updateUi();
break;
case 'update':
this._checkUpdates(data.updates);
break;
case 'error':
this.dispatchEvent(new DeviceErrorEvent(this, new DeviceError(data.code)));
}
}
//#endregion
//#region Connection
/**
* Check if device is accessable by http.
* @returns {boolean}
*/
isHttpAccessable() {
return this.active_connections.some(conn => conn instanceof HTTPConnection);
}
/**
* Get primary connection
* @returns {Connection | null}
*/
getConnection() {
return this.active_connections.length ? Array_maxBy(this.active_connections, conn => conn.priority) : null;
}
/**
* Check if device is connected to hub.
* @returns {boolean}
*/
isConnected() {
return this.active_connections.length !== 0;
}
/**
* Add new active connection to device.
* @param {Connection} conn
*/
addConnection(conn) {
if (this.active_connections.includes(conn)) return;
this.active_connections.push(conn);
this.dispatchEvent(new DeviceEvent('connectionchanged', this));
conn.addEventListener('statechange', e => {
switch (e.state) {
case ConnectionState.DISCONNECTED:
Array_remove(this.active_connections, conn);
this.dispatchEvent(new DeviceEvent('connectionchanged', this));
break;
}
})
}
//#endregion
//#region Interraction > Common
async getInfo() {
const [type, data] = await this.#postAndWait('info', ['info', 'OK']);
if (type === 'info') return data.info;
return undefined;
}
async reboot() {
await this.#postAndWait('reboot', ['OK']);
}
async sendCli(command) {
await this.#postAndWait('cli', ['OK'], 'cli', command);
}
//#endregion
//#region Interraction > UI
async updateUi(){
return (await this.#postAndWait('ui', ['ui']))[1];
}
async set(name, value){
const [cmd, data] = await this.#postAndWait('set', ['ui', 'ack'], name, value);
if (cmd === 'ui') return data;
if (data.name !== name) throw new HubError("set / ack check failed!");
return undefined;
}
_checkUpdates(updates) {
if (typeof updates !== 'object')
return;
for (const [keys, data] of Object.entries(updates)) {
if (typeof data !== 'object')
continue;
if ('value' in data && this.prev_set[keys])
delete data.value;
if (!Object.keys(data).length)
continue;
const names = keys.includes(';') ? keys.split(';') : [keys];
for (const name of names)
this.dispatchEvent(new DeviceUpdateEvent(this, name, data));
}
}
async focus() {
if (this.info.ws_port && this._hub._ws && this.active_connections.some(conn => conn instanceof HTTPConnection)) {
const ws = this._hub._ws;
await ws.disconnect();
ws.options.ip = this.info.ip;
ws.options.port = this.info.ws_port;
ws.options.enabled = true;
await ws.connect();
if (!ws.isConnected()) {
await ws.disconnect();
} else {
this.addConnection(ws)
}
}
this.#pingTimer.start();
return await this.updateUi();
}
async unfocus() {
this.#pingTimer.cancel();
await this.#post('unfocus');
if (this._hub._ws) await this._hub._ws.disconnect();
}
//#endregion
//#region Interraction > FS
async deleteFile(path) {
return (await this.#postAndWait('delete', ['files'], path))[1];
}
async createFile(path) {
return (await this.#postAndWait('mkfile', ['files'], path))[1];
}
async renameFile(path, new_name) {
return (await this.#postAndWait('rename', ['files'], path, new_name))[1];
}
async formatFS() {
return (await this.#postAndWait('format', ['files']))[1];
}
async updateFileList() {
return (await this.#postAndWait('files', ['files']))[1];
}
async fsStop() {
if (this.isModuleEnabled(Modules.FETCH) || this.isModuleEnabled(Modules.UPLOAD) || this.isModuleEnabled(Modules.OTA))
await this.#post('fs_abort', 'all');
}
async upload(file, path, progress = undefined) {
if (!this.isModuleEnabled(Modules.UPLOAD))
throw new DeviceError(HubErrors.Disabled);
const data = await readFileAsArrayBuffer(file);
const buffer = new Uint8Array(data);
const crc = crc32(buffer);
if (this.isHttpAccessable() && this.info.http_t) {
let formData = new FormData();
formData.append('upload', file, "upload");
await http_post(`http://${this.info.ip}:${this.info.http_port}/hub/upload?path=${path}&crc32=${crc}&client_id=${this._hub.clientId}&size=${buffer.length}`, formData)
} else {
if (!progress) progress = () => {};
const upl_bytes = Array.from(buffer);
const upl_size = upl_bytes.length;
const max_enc_len = this.info.max_upl * 3 / 4 - 60;
let [cmd, data] = await this.#postAndWait('upload', ['upload_next', 'upload_err'], path, upl_size);
if (cmd === 'upload_next')
[cmd, data] = await this.#postAndWait('upload_chunk', ['upload_next', 'upload_err'], 'crc', crc);
while (cmd === 'upload_next') {
const data2 = String.fromCharCode.apply(null, upl_bytes.splice(0, max_enc_len));
progress(Math.round((upl_size - upl_bytes.length) / upl_size * 100));
if (upl_bytes.length)
[cmd, data] = await this.#postAndWait('upload_chunk', ['upload_next', 'upload_err'], 'next', window.btoa(data2));
else
[cmd, data] = await this.#postAndWait('upload_chunk', ['upload_done', 'upload_err'], 'last', window.btoa(data2));
}
if (cmd === 'upload_err')
throw new DeviceError(data.code);
}
await this.updateFileList();
}
async fetch(path, type, progress = undefined) {
if (!this.isModuleEnabled(Modules.FETCH))
throw new DeviceError(HubErrors.Disabled);
if (!progress) progress = () => {};
if (this.isHttpAccessable() && this.info.http_t) {
return await http_fetch_blob(`http://${this.info.ip}:${this.info.http_port}/hub/fetch?path=${path}&client_id=${this._hub.clientId}`,
type, progress, this._hub.config.get('connections', 'HTTP', 'request_timeout'));
} else {
let [cmd, data] = await this.#postAndWait('fetch', ['fetch_start', 'fetch_err'], path);
let fet_len, fet_buf;
if (cmd === 'fetch_start') {
fet_len = data.len;
fet_buf = '';
[cmd, data] = await this.#postAndWait('fetch_next', ['fetch_chunk', 'fetch_err']);
progress(0);
}
while (cmd === 'fetch_chunk') {
fet_buf += atob(data.data);
if (data.last) {
if (fet_buf.length != fet_len)
throw new DeviceError(HubErrors.SizeMiss);
const crc = crc32(fet_buf);
if (crc != data.crc32)
throw new DeviceError(HubErrors.CrcMiss);
if (type === 'url')
return `data:${getMime(path)};base64,${btoa(fet_buf)}`;
else if (type === 'text')
return new TextDecoder().decode(Uint8Array.from(fet_buf, (m) => m.codePointAt(0)));
}
// not last chunk
progress(Math.round(fet_buf.length / fet_len * 100));
[cmd, data] = await this.#postAndWait('fetch_next', ['fetch_chunk', 'fetch_err']);
}
if (cmd === 'fetch_err')
throw new DeviceError(data.code);
}
}
//#endregion
//#region Interraction > OTA
async otaUrl(type, url) {
const [t, data] = await this.#postAndWait('ota_url', ['ota_url_ok', 'ota_url_err'], type, url);
if (t === 'ota_url_err')
throw new DeviceError(data.code);
}
async uploadOta(file, type, progress = undefined) {
if (!this.isModuleEnabled(Modules.OTA))
throw new DeviceError(HubErrors.Disabled);
if (this.isHttpAccessable() && this.info.http_t) {
let formData = new FormData();
formData.append(type, file, "ota");
await http_post(`http://${this.info.ip}:${this.info.http_port}/hub/ota?type=${type}&client_id=${this._hub.clientId}`, formData)
} else {
if (!progress) progress = () => {};
const fdata = await readFileAsArrayBuffer(file);
const buffer = new Uint8Array(fdata);
const ota_bytes = Array.from(buffer);
const ota_size = ota_bytes.length;
const max_enc_len = this.info.max_upl * 3 / 4 - 60;
let [cmd, data] = await this.#postAndWait('ota', ['ota_next', 'ota_done', 'ota_err'], type);
while (cmd === 'ota_next') {
const data2 = String.fromCharCode.apply(null, ota_bytes.splice(0, max_enc_len));
progress(Math.round((ota_size - ota_bytes.length) / ota_size * 100));
[cmd, data] = await this.#postAndWait('ota_chunk', ['ota_next', 'ota_done', 'ota_err'], (ota_bytes.length) ? 'next' : 'last', window.btoa(data2));
}
if (cmd === 'ota_err')
throw new DeviceError(data.code);
}
}
//#endregion
};