UNPKG

@seydx/bravia

Version:

Node.js module for discovering and controlling Sony BRAVIA Android TVs

487 lines (364 loc) 13.1 kB
'use strict'; const axios = require('axios'); const base64 = require('base-64'); const parseString = require('xml2js').parseString; const { prompt } = require('enquirer'); const SsdpClient = require('node-ssdp').Client; const URL = require('url'); const wol = require('wake_on_lan'); const debug = require('debug'); const braviaDebug = debug('bravia'); const requestDebug = debug('bravia:request'); const discoveryDebug = debug('bravia:discovery'); const ServiceProtocol = require('./service-protocol'); const SSDP_SERVICE_TYPE = 'urn:schemas-sony-com:service:IRCC:1'; //urn:schemas-sony-com:service:ScalarWebAPI:1 const SERVICE_PROTOCOLS = [ 'accessControl', 'appControl', 'audio', 'avContent', 'browser', 'cec', 'encryption', 'guide', 'recording', 'system', 'videoScreen' ]; const DEFAULT_TIME_BETWEEN_COMMANDS = 350; const TIMEOUT = (ms) => new Promise((res) => setTimeout(res, ms)); class Bravia { constructor(options) { this.host = options.host; this.mac = options.mac; this.port = options.port || 80; this.timeout = options.timeout && options.timeout < 1000 ? options.timeout * 1000 : 5000; this.psk = options.psk; this.authWithPIN = options.pin; if(!this.psk && !this.authWithPIN) this.authWithPIN = true; this.credentials = { name: options.name, uuid: options.uuid, token: options.token, expires: false }; this.protocols = SERVICE_PROTOCOLS; this.delay = DEFAULT_TIME_BETWEEN_COMMANDS; for (let key in this.protocols) { braviaDebug('Creating service /' + this.protocols[key]); let protocol = this.protocols[key]; this[protocol] = new ServiceProtocol(this, protocol); } this._url = `http://${this.host}:${this.port}/sony`; braviaDebug('Using url %s', this._url); if(this.psk) braviaDebug('Using PSK (%s) for authentication.', this.psk); if(this.authWithPIN) braviaDebug('Using PIN for authentication %O', this.credentials); this._codes = []; this._methods = []; } async discover(timeout) { let ssdp = new SsdpClient(); let discovered = []; ssdp.on('response', async (headers, statusCode, data) => { if (statusCode === 200) { try { const response = await axios(headers.LOCATION); if(response.status === 200){ parseString(response.data, (err, result) => { if (!err) { try { let device = result.root.device[0]; if (device.serviceList) { // Not all devices return a serviceList (e.g. Philips Hue gateway responds without serviceList) let service = device.serviceList[0].service .find(service => service.serviceType[0] === SSDP_SERVICE_TYPE); let api = URL.parse(service.controlURL[0]); discovered.push({ host: api.host, port: (api.port || 80), friendlyName: device.friendlyName[0], manufacturer: device.manufacturer[0], manufacturerURL: device.manufacturerURL[0], modelName: device.modelName[0], UDN: device.UDN[0], services: device.serviceList[0].service, scalar: device['av:X_ScalarWebAPI_DeviceInfo'], ircc: device['av:X_IRCC_DeviceInfo'], codes: device['av:X_IRCCCodeList'] }); } } catch(e) { failed(new Error(`Unexpected or malformed discovery response: ${result}.`)); } } else { failed(new Error(`Failed to parse the discovery response: ${response.data}.`)); } }); } else { failed(new Error(`Error retrieving the description metadata for device ${data.address}.`)); } } catch(error){ failed(new Error(`Error retrieving the description metadata for device ${data.address}.`)); } } }); discoveryDebug('Starting discovery.'); ssdp.search(SSDP_SERVICE_TYPE); let failed = error => { ssdp.stop(); throw error; }; await TIMEOUT((timeout*1000||5000)); discoveryDebug('Timeout (%ds) reached. Stopping discovery.', timeout); discoveryDebug('Found %d devices', discovered.length); ssdp.stop(); return discovered; } async getIRCCCodes() { if (this._codes.length > 0) { return this._codes; } this._codes = await this.system.invoke('getRemoteControllerInfo'); return this._codes; } async getAllMethodTypes(){ if (this._methods.length > 0) { return this._methods; } for(const key of this.protocols){ try { let data = await this[key].getMethodTypes(); this._methods.push(data); } catch(err) { this._methods.push([{ endpoint: key, version: false, methods: [] }]); } } return this._methods; } async send(codes, delay) { delay = delay ? delay : this.delay; if (typeof codes === 'string') { codes = [codes]; } let sendCmd = async code => { let body = `<?xml version="1.0"?> <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"> <s:Body> <u:X_SendIRCC xmlns:u="urn:schemas-sony-com:service:IRCC:1"> <IRCCCode>${code}</IRCCCode> </u:X_SendIRCC> </s:Body> </s:Envelope>`; await this._request({ path: '/IRCC', body: body }); return; }; for(const code of codes){ if (/^[A]{5}[a-zA-Z0-9]{13}[\=]{2}$/.test(code)) { await sendCmd(code); } else { const response = await this.getIRCCCodes(); let ircc = response.find(ircc => ircc.name === code); if (!ircc) { return new Error(`Unknown IRCC code ${code}.`); } requestDebug('Send ircc command %s (%s)', ircc.name, ircc.value) await sendCmd(ircc.value); } if(codes.length > 1) await TIMEOUT(delay); } return; } wake(address, options, overWOL){ return new Promise((resolve, reject) => { if(!overWOL){ this.system .invoke('setPowerStatus', '1.0', { 'status': true }) .then(() => { resolve(); }) .catch(err => { reject(err); }); } else { address = address || this.mac; options = options ? options : {}; if(/^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})$/.test(address)){ options = { address: options.address || '255.255.255.255', num_packets: options.num_packets || 10, interval: options.interval || 100 }; requestDebug('Sending magic packets to %s', address); requestDebug('WOL Options %O', options); wol.wake(address, options, function(error) { if(error) return reject(error); resolve('Magic packets send to ' + address); }); } else { reject(new Error('No valid MAC addresss')); } } }); } sleep(){ return new Promise((resolve, reject) => { this.system .invoke('setPowerStatus', '1.0', { 'status': false }) .then(() => { resolve(); }) .catch(err => { reject(err); }); }); } async pair(user, pin, refresh){ try { user = user ? user : {}; user.name = user.name || 'Bravia'; user.uuid = user.uuid || this.genUUID(); requestDebug('Credentials %O', user); const headers = {}; if(!refresh){ if(pin){ requestDebug('Using PIN (%s) for authentication', pin); headers.Authorization = 'Basic ' + base64.encode(':' + pin); } else if(this.token) { requestDebug('Using Token (%s) for authentication', this.token); headers.Cookie = this.token; } } else { requestDebug('Refreshing token with given credentials %O', this.credentials); } const post_data = `{ "id": 8, "method": "actRegister", "version": "1.0", "params": [ { "clientid":"${user.name}:${user.uuid}", "nickname":"${user.name}" }, [ { "clientid":"${user.name}:${user.uuid}", "value":"yes", "nickname":"${user.name}", "function":"WOL" } ] ] }`; const response = await axios.post(this._url + '/accessControl', post_data, { headers: headers }); if(response.headers['set-cookie']){ let credentials = { name: user.name, uuid: user.uuid, token: response.headers['set-cookie'][0].split(';')[0].split('auth=')[1], expires: response.headers['set-cookie'][0].split(';')[3].split('Expires=')[1] }; requestDebug('New token %s', credentials.token); return credentials; } else if(response.data && response.data.error && (response.data.error.includes(40005) || response.data.error.includes('Display Is Turned off') || response.data.error.includes('not power-on'))){ throw new Error('Please turn on TV to handle authentication process through PIN.'); } else { throw response.data; } } catch(error) { if(error.response && error.response.status === 401){ const response = await prompt({ type: 'input', name: 'pin', message: 'Please enter the four-digit PIN.' }); return this.pair(user, response.pin); } else { throw error; } } } async _request(opts) { const options = { //timeout: this.timeout, url: this._url + opts.path, method: 'post', data: opts.json || opts.body, headers: { 'Content-Type': 'text/xml; charset=UTF-8', 'SOAPACTION': '"urn:schemas-sony-com:service:IRCC:1#X_SendIRCC"' } }; requestDebug('Initializing request for %s', options.url); requestDebug('Request data %O', options.data); try { if(this.authWithPIN){ if(!this.credentials.name && !this.credentials.uuid){ this.credentials = await this.pair(); } else { this.credentials = await this.pair(this.credentials, false, true); } options.headers.Cookie = 'auth=' + this.credentials.token; } else { options.headers['X-Auth-PSK'] = this.psk; } requestDebug('Request options %O', options.headers); if(opts.turnOn){ requestDebug('Additional request parameter: turnOnTv for request (true)'); await this.wake(); await TIMEOUT(2000); } let response = await axios(options); if(response.status !== 200){ if(response.data.error){ throw new Error(response.data.error[1]); } else { parseString(response.data, (err, result) => { if (!err) { try { throw new Error(result['s:Envelope']['s:Body'][0]['s:Fault'][0]['detail'][0]['UPnPError'][0]['errorDescription'][0]); } catch (e) { throw new Error(`Unexpected or malformed error response: ${result}.`); } } else { throw new Error(`Failed to parse the error response: ${response.data}.`); } }); } } if(response.data.error) { if(response.data.error.includes(7) && response.data.error.includes('Illegal State')){ response.data.result = [{ source: 'application', title: 'App', uri: false }]; } else { throw response.data.error; } } return response; } catch(error) { if (error.response) { throw (`Response error, status code: ${error.response.status}.`); } else { throw error; } } } genUUID () { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16 | 0; var v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } } module.exports = Bravia;