From 9e2e274addd77a49497caf7081a86b4343f044c7 Mon Sep 17 00:00:00 2001 From: jesse Date: Sat, 18 Apr 2020 21:48:47 -0400 Subject: [PATCH] rewrite, discover returns array --- src/{IDevice.ts => Device.ts} | 6 +- src/ServerInfo.ts | 21 +++ src/discovery.ts | 55 +++++--- src/index.ts | 246 +++++++++++++++++++--------------- 4 files changed, 195 insertions(+), 133 deletions(-) rename src/{IDevice.ts => Device.ts} (84%) create mode 100644 src/ServerInfo.ts diff --git a/src/IDevice.ts b/src/Device.ts similarity index 84% rename from src/IDevice.ts rename to src/Device.ts index 671c973..769ebb4 100644 --- a/src/IDevice.ts +++ b/src/Device.ts @@ -1,5 +1,4 @@ -export default interface IDevice { - ip: string; +export default interface Device { board: string; private_ip: string; server_id: string; @@ -9,7 +8,7 @@ export default interface IDevice { dev_type: string; host?: string; public_ip?: string; - slip?: number; // a port? + slip?: number; // a port http?: number; // a port ssl?: number; // a port or bool inserted?: string; // this is a date @@ -18,5 +17,4 @@ export default interface IDevice { server_version?: string; name?: string; roku?: number; // probably a bool - } \ No newline at end of file diff --git a/src/ServerInfo.ts b/src/ServerInfo.ts new file mode 100644 index 0000000..2318c98 --- /dev/null +++ b/src/ServerInfo.ts @@ -0,0 +1,21 @@ +export default interface ServerInfo { + server_id: string; + name: string; + timezone: string; + version: string; + local_address: string; + setup_completed: boolean; + build_number: number; + model: Model; + availability: string; + cache_key: string; + checked: string; +} + +export interface Model { + wifi: boolean; + tuners: number; + type: string; + name: string; + device: string; +} diff --git a/src/discovery.ts b/src/discovery.ts index 2f47c11..f0d85cb 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -7,15 +7,18 @@ import dgram = require('dgram'); const debug = Debug('discovery'); -import IDevice from "./IDevice"; +import Device from "./Device"; -class Discover { +export default class Discover { private readonly sendPort: number = 8881; private readonly recvPort: number = 8882; private readonly discoveryUrl: string = 'https://api.tablotv.com/assocserver/getipinfo/'; private watcher: Timeout; - public async broadcast(): Promise<[IDevice]> { + /** + * Attempt discovery via UDP broadcast. Will only return a single device. + */ + public async broadcast(): Promise<[Device]> { const server = dgram.createSocket('udp4'); server.on('error', (error) => { @@ -43,7 +46,7 @@ class Discover { }); }); - let outerDevice; + let outerDevice:Device; server.on('message', (msg, info) => { if (msg.length !== 140) { @@ -55,14 +58,15 @@ class Discover { // this is the proper format string let s = struct('>4s64s32s20s10s10s') // s = struct.format('b').unpack() - const trunc = (txt) => txt.split('\0', 1)[0]; - const device = { + const trunc = (txt: string) => txt.split('\0', 1)[0]; + const device:Device = { host: trunc(bytes.unpackString(msg, 4, 68)), private_ip: trunc(bytes.unpackString(msg, 68, 100)), - resp_code: trunc(bytes.unpackString(msg, 0, 4)), + // resp_code: trunc(bytes.unpackString(msg, 0, 4)), server_id: trunc(bytes.unpackString(msg, 100, 120)), dev_type: trunc(bytes.unpackString(msg, 120, 130)), board: trunc(bytes.unpackString(msg, 130, 140)), + via: 'broadcast' }; clearTimeout(this.watcher); server.close(); @@ -91,22 +95,29 @@ class Discover { }); } - public async http(): Promise<[IDevice]> { - let data; - try { - const response = await axios.get(this.discoveryUrl); - data = response.data.cpes; - } catch (error) { - debug('Http Error:', error); - } - return new Promise((resolve) => { - resolve(data); + /** + * Attempt discovery via HTTP broadcast using Tablo discovery service + */ + public async http(): Promise{ + return new Promise(async (resolve, reject) => { + + let data: Device[]; + try { + type Response = { data: { cpes: Device[]}}; + const response:Response = await axios.get(this.discoveryUrl); + data = response.data.cpes; + data.forEach((part, index, arr) => { + if (arr[index]) { + arr[index].via = 'http'; + } + }, data); // use arr as this + } catch (error) { + debug('Http Error:', error); + reject( new Error(`Http Error: ${error}`) ); + } + resolve(data); }); - } } -export const discovery = new Discover(); - -exports.default = discovery; -exports.discovery = discovery; +export const discovery = new Discover(); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 72a987d..c2839d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,160 +2,192 @@ import axios from 'axios'; import * as Debug from 'debug'; import { discovery } from './discovery'; -import IDevice from "./IDevice"; +import Device from "./Device"; +import ServerInfo from "./ServerInfo"; const debug = Debug('index'); const Axios = axios.create(); -class Tablo { - private devices: IDevice[]; - private airings: []; - private device: IDevice; +class TabloApi { + private devices: Device[]; + private airingsCache: []; + private device: Device; - /** This should really look for and return multiple devices if they exist, but I can't test that :/ - * ditto for the Discover methods this calls + /** + * Utilizes HTTP discovery with UDP broadcast fallback to find local Tablo devices */ - public async discover() { - let discoverData = await discovery.broadcast(); - debug('discover.broadcast:'); - debug(discoverData); + async discover() { - let via = null; - if (Object.keys(discoverData).length > 0) { - debug("Broadcast discovery succeeded."); - via = 'broadcast'; - } else { - debug('Broadcast discovery failed, trying HTTP fallback.'); + let discoverData: Device[]; + discoverData = await discovery.http(); - discoverData = await discovery.http(); - if (discoverData && Object.keys(discoverData).length > 0) { - via = 'http'; - } + debug('discover.http:'); + debug(discoverData); + + if (Object.keys(discoverData).length === 0) { + discoverData = await discovery.broadcast(); + debug('discover.broadcast:'); + debug(discoverData); } - if (!via){ + + if (Object.keys(discoverData).length === 0) { return []; } - discoverData.forEach(function (part, index) { - debug(`part: ${part}`); - debug(`index: ${index}`); - debug(`this: ${this[index]}`); - - if (this[index]){ - this[index].via = via; - } - - }, discoverData); // use arr as this + // TODO: a nicety when testing, should probably remove this.devices = discoverData; this.device = this.devices[0]; + return discoverData; } - public async getServerInfo() { - if (typeof this.device === 'undefined') { - console.log('TabloAPI - getServerInfo - No device selected, returning null.'); - return null; + /** + * Pre-flight check + * @throws Error when no device has been selected + */ + private isReady() { + if (typeof this.device === 'undefined' || !this.device || !this.device.private_ip) { + const msg = 'TabloAPI - No device selected.' + throw new Error(msg); } + } + + /** + * Returns server info reported by the Tablo + */ + async getServerInfo() { + this.isReady(); try { - const info = await this.get('/server/info'); - if (info) { info.checked = new Date(); } + const info: ServerInfo = await this.get('/server/info'); return info; - } catch (e) { - console.error(e); - return {}; + } catch (err) { + throw err; } } - public async getRecordings({ countOnly = false, force = false, callback = null }) { + /** + * Returns a count of the Recordings on the Tablo + * @param force whether or not to force reloading from the device or use cached airings + */ + async getRecordingsCount(force = false) { + this.isReady(); try { - if (!this.airings || force) { - this.airings = await this.get('/recordings/airings'); + if (!this.airingsCache || force) { + this.airingsCache = await this.get('/recordings/airings'); } - if (!this.airings){ - return null; + if (!this.airingsCache) { + return 0; } + return this.airingsCache.length; - if (countOnly) { return this.airings.length; } + } catch (err) { + throw err; + } + } - return await this.batch(this.airings, callback); + /** + * Retrieves all Recordings from the Tablo + * @param force whether or not to force reloading from the device or use cached airings + * @param progressCallback function to receive a count of records processed + */ + async getRecordings(force = false, progressCallback: (num: number) => void ) { + this.isReady(); + try { + if (!this.airingsCache || force) { + this.airingsCache = await this.get('/recordings/airings'); + } + if (!this.airingsCache){ + return null; + } + + return this.batch(this.airingsCache, progressCallback); } catch (error) { - console.error('getRecordings error, returning null:', error); - return null; + throw error; } } - public async delete(path) { + /** + * Deletes a + * @param path + */ + async delete(path: string) { + this.isReady(); const url = this.getUrl(path); return Axios.delete(url); } - public async get(path) { - if (typeof this.device === 'undefined') { - console.log('TabloAPI - get - No device selected, returning null.'); - return null; - } - try { - const url = this.getUrl(path); - const response = await Axios.get(url); - return response.data; - } catch (error) { - console.error(error); - return {}; - } + /** + * Try to receive data from a specified path + * @param path + */ + async get(path: string): Promise { + this.isReady(); + return new Promise( async (resolve, reject) => { + try { + const url = this.getUrl(path); + const response: {data: T } = await Axios.get(url); + resolve(response.data); + } catch (error) { + reject(error); + } + }); } - public getUrl(path) { + private getUrl(path: string) { const newPath = path.replace(/^\/+/, ''); return `http://${this.device.private_ip}:8885/${newPath}`; } - public async batch(data, callback = null) { - if (typeof this.device === 'undefined') { - const msg = 'TabloAPI - batch - No device selected, returning null.'; - console.error(msg); - throw(msg); - } - - let chunk = []; - let idx = 0; - const size = 50; - let recs = []; - while (idx < data.length) { - chunk = data.slice(idx, size + idx); - idx += size; - // eslint-disable-next-line no-await-in-loop - const returned = await this.post({ path: 'batch', data: chunk }); - // FIX: maybe? no idea if this works instead of Object.values() - const values = Object.keys(returned).map( (el) =>{ - return returned[el] - }); - - recs = recs.concat(values); - if (typeof callback === 'function') { - callback(recs.length); + private async batch(data:string[], progressCallback: (arg0: number) => void):Promise { + this.isReady(); + + return new Promise( async (resolve, reject) => { + let chunk = []; + let idx = 0; + const size = 50; + let recs: T[] = []; + while (idx < data.length) { + chunk = data.slice(idx, size + idx); + idx += size; + + let returned: T[]; + try { + returned = await this.post( 'batch', chunk ); + } catch (err) { + reject(err); + } + + const values = Object.keys(returned).map( (el) =>{ + return returned[el] + }); + + recs = recs.concat(values); + + if (typeof progressCallback === 'function') { + progressCallback(recs.length); + } } - } - return recs; - } + resolve(recs); + }); - public async post({ path = 'batch', data = null }) { - if (typeof this.device === 'undefined') { - console.error('TabloAPI - post - No device selected, returning null.'); - return null; - } + } - try { - const url = this.getUrl(path); - const returned = await Axios.post(url, data); - return returned.data; - } catch (error) { - console.error(error); - return {}; - } + async post(path = 'batch', strArray?:string[] ):Promise { + this.isReady(); + const toPost = strArray ? strArray : null; + return new Promise( async (resolve, reject) => { + try { + const url = this.getUrl(path); + const returned:{ data: T[]} = await Axios.post(url, toPost); + const { data } = returned; + resolve(data); + } catch (error) { + reject(error); + } + }); } } -exports.default = Tablo; -exports.Tablo = Tablo; +export { TabloApi as Tablo } \ No newline at end of file