irusb-adapter/index.js
2021-08-11 21:42:20 +02:00

384 lines
9.9 KiB
JavaScript

"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);
};