"use strict"; const { Adapter, Device, Property, Action } = require('gateway-addon'); const manifest = require('./manifest.json'); const dgram = require('dgram'); const net = require('net'); // https://source.android.com/devices/input/keyboard-devices#hid-keyboard-and-keypad-page-0x07 const HID_MAP = { PLAY: { code: '176', type: 'consumer', modifier: '000', // this is actually the upper byte and not the modifiers }, PAUSE: { code: '177', type: 'consumer', modifier: '000', }, HOME: { code: '035', type: 'consumer', modifier: '002' }, ENTER: { code: '040', type: 'keyboard', modifier: '000' }, RIGHT: { code: '079', type: 'keyboard', modifier: '000' }, LEFT: { code: '080', type: 'keyboard', modifier: '000' }, DOWN: { code: '081', type: 'keyboard', modifier: '000' }, UP: { code: '082', type: 'keyboard', modifier: '000' }, BACK: { code: '036', type: 'consumer', modifier: '002' }, MUTE: { code: '226', type: 'consumer', modifier: '000' }, VOLUME_UP: { code: '233', type: 'consumer', modifier: '000' }, VOLUME_DOWN: { code: '234', type: 'consumer', modifier: '000' }, NEXT: { code: '181', type: 'consumer', modifier: '000' }, PREVIOUS: { code: '182', type: 'consumer', modifier: '000' }, STOP: { code: '183', type: 'consumer', modifier: '00*' }, SLEEP: { code: '050', type: 'consumer', modifier: '000' }, REWIND: { code: '180', type: 'consumer', modifier: '000' }, FASTFORWARD: { code: '179', type: 'consumer', modifier: '000' }, RED: { code: '105', type: 'consumer', modifier: '000' }, GREEN: { code: '106', type: 'consumer', modifier: '000' }, BLUE: { code: '107', type: 'consumer', modifier: '000' }, YELLOW: { code: '108', type: 'consumer', modifier: '000' }, CLOSED_CAPTIONS: { code: '097', type: 'consumer', modifier: '000' }, CHANNEL_UP: { code: '156', type: 'consumer', modifier: '000' }, CHANNEL_DOWN: { code: '157', type: 'consumer', modifier: '000' } //TODO guide? }; const CONTROL_CODE = { consumer: { short: '2', long: '6' }, keyboard: { short: '1', long: '5' } }; class IRUSBProperty extends Property { async setValue(value) { if(typeof value !== 'boolean') { throw new Error(`Invalid value type ${typeof value}. Expecting boolean.`); } if(this.name === 'power') { if(value) { return this.device.sendCommand('WAKE'); } return this.device.sendKey('SLEEP'); } else if(this.name === 'playing') { if(value) { return this.device.sendKey('PLAY'); } return this.device.sendKey('PAUSE'); } this.setCachedValueAndNotify(value); return value; } } class IRUSBDevice extends Device { constructor(adapter, uuid, ip, port) { super(adapter, uuid); this.name = uuid; this['@type'] = [ 'OnOffSwitch' ]; this.socket = new net.Socket(); this.socket.setEncoding('ascii'); this.addProperty(new IRUSBProperty(this, 'power', { title: 'Power', '@type': 'OnOffProperty', type: 'boolean' })); this.addAction('launch', { title: 'Launch App', input: { type: 'string' } }); this.addAction('remoteShort', { title: 'Short Press', input: { type: 'string', enum: [ 'PLAY', 'PAUSE', 'ENTER', 'HOME', 'BACK', 'UP', 'DOWN', 'LEFT', 'RIGHT', 'NEXT', 'PREVIOUS', 'REWIND', 'FASTFORWARD', 'MUTE', 'VOLUME_UP', 'VOLUME_DOWN', 'RED', 'GREEN', 'BLUE', 'YELLOW' ] } }); this.addAction('remoteLong', { title: 'Long Press', input: { type: 'string', enum: [ 'ENTER', 'FASTFORWARD', 'REWIND', 'UP', 'DOWN', 'LEFT', 'RIGHT' ] } }); this.addAction('cancelKeys', { title: 'Cancel Presses' }); this.addProperty(new IRUSBProperty(this, 'playing', { title: 'Playing', type: 'boolean' })); this.addProperty(new Property(this, 'app', { title: 'Application', type: 'string', readOnly: true })); this.responseQueue = []; this.socket.connect(port, ip, () => { this.connectedNotify(true); this.interval = setInterval(() => this.updateState(), 5000); this.adapter.handleDeviceAdded(this); }); this.socket.on('close', () => { clearInterval(this.interval); this.interval = undefined; this.connectedNotify(false); }); this.socket.on('data', (data) => { for(const waitingOn of this.responseQueue) { if(data.startsWith(waitingOn.code)) { waitingOn.callback(data.slice(waitingOn.code.length).trim()); this.responseQueue.splice(this.responseQueue.indexOf(waitingOn), 1); return; } } //TODO event for incoming IR console.log('Unexpected packet', data); }); this.updateState(); } async updateState() { const playing = await this.sendCommand('GETPLAY'); const app = await this.sendCommand('GETFG'); const isPlaying = playing === '1'; this.findProperty('playing').setCachedValueAndNotify(isPlaying); this.findProperty('app').setCachedValueAndNotify(app); // app !== screensaver this.findProperty('power').setCachedValueAndNotify(isPlaying || app); } destroy() { clearInterval(this.interval); this.interval = undefined; this.socket.destroy(); } async sendKey(keyName, long = false) { let pressType = long ? 'long' : 'short'; const keyInfo = HID_MAP[keyName]; const startCode = CONTROL_CODE[keyInfo.type][pressType]; return this.sendCommand(`HIDCODE${startCode}${keyInfo.modifier}${keyInfo.code}`); } sendCommand(command) { return new Promise((resolve) => { const fullCommand = `Q${command}\r`; this.responseQueue.push({ code: fullCommand, callback(response) { resolve(response.split('\r').filter((part) => part.length && part !== 'OK').join('\r')); } }); this.socket.write(fullCommand); }); } reconnect(ip, port) { if(this.socket.readyState !== 'open') { if(!this.interval) { this.interval = setInterval(() => this.updateState(), 5000); } this.socket.connect(port, ip, () => { this.connectedNotify(true); }); } } /** * * @param {Action} action */ async performAction(action) { switch(action.name) { case 'launch': return this.sendCommand(`LAUNCH ${action.input}`); case 'remoteShort': return this.sendKey(action.input, false); case 'remoteLong': return this.sendKey(action.input, true); case 'cancelKeys': return this.sendCommand('HIDCODE0000000'); } } } class IRUSBAdapter extends Adapter { constructor(addonManager) { super(addonManager, manifest.id, manifest.id); addonManager.addAdapter(this); this.startDiscovering(); } async startDiscovering() { //TODO close socket on unload! this.socket = dgram.createSocket({ type: 'udp4' }); await new Promise((resolve) => this.socket.bind(1904, resolve)); this.socket.setBroadcast(true); this.socket.addMembership('239.255.255.250'); this.socket.on('message', (message, info) => { const string = message.toString('ascii'); if(string.startsWith('NOTIFY \n')) { const [ header, uuid, ip, port ] = string.split('\n'); this.onDevice(uuid.trim(), ip.trim(), port.trim()); } }); } onDevice(uuid, ip, port) { if(!this.devices[uuid]) { new IRUSBDevice(this, uuid, ip, port); } else { this.devices[uuid].reconnect(ip, port); } } handleDeviceRemoved(device) { device.destroy(); super.handleDeviceRemoved(device); } async unload() { await new Promise((resolve) => { this.socket.close(resolve); }); return super.unload(); } } module.exports = (addonManager) => { new IRUSBAdapter(addonManager); };