Created
April 18, 2020 20:01
-
-
Save rafaelncarvalho/44d21b718825f4327bbd7c0217b17a84 to your computer and use it in GitHub Desktop.
broadlinkjs-rm
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const EventEmitter = require('events'); | |
const dgram = require('dgram'); | |
const os = require('os'); | |
const crypto = require('crypto'); | |
const assert = require('assert'); | |
// RM Devices (without RF support) | |
const rmDeviceTypes = {}; | |
rmDeviceTypes[parseInt(0x2737, 16)] = "Broadlink RM Mini"; | |
rmDeviceTypes[parseInt(0x27c7, 16)] = 'Broadlink RM Mini 3 A'; | |
rmDeviceTypes[parseInt(0x27c2, 16)] = "Broadlink RM Mini 3 B"; | |
rmDeviceTypes[parseInt(0x27de, 16)] = "Broadlink RM Mini 3 C"; | |
rmDeviceTypes[parseInt(0x5f36, 16)] = "Broadlink RM Mini 3 D"; | |
rmDeviceTypes[parseInt(0x273d, 16)] = 'Broadlink RM Pro Phicomm'; | |
rmDeviceTypes[parseInt(0x2712, 16)] = 'Broadlink RM2'; | |
rmDeviceTypes[parseInt(0x2783, 16)] = 'Broadlink RM2 Home Plus'; | |
rmDeviceTypes[parseInt(0x277c, 16)] = 'Broadlink RM2 Home Plus GDT'; | |
rmDeviceTypes[parseInt(0x278f, 16)] = 'Broadlink RM Mini Shate'; | |
// RM Devices (with RF support) | |
const rmPlusDeviceTypes = {}; | |
rmPlusDeviceTypes[parseInt(0x272a, 16)] = 'Broadlink RM2 Pro Plus'; | |
rmPlusDeviceTypes[parseInt(0x2787, 16)] = 'Broadlink RM2 Pro Plus v2'; | |
rmPlusDeviceTypes[parseInt(0x278b, 16)] = 'Broadlink RM2 Pro Plus BL'; | |
rmPlusDeviceTypes[parseInt(0x2797, 16)] = 'Broadlink RM2 Pro Plus HYC'; | |
rmPlusDeviceTypes[parseInt(0x27a1, 16)] = 'Broadlink RM2 Pro Plus R1'; | |
rmPlusDeviceTypes[parseInt(0x27a6, 16)] = 'Broadlink RM2 Pro PP'; | |
rmPlusDeviceTypes[parseInt(0x279d, 16)] = 'Broadlink RM3 Pro Plus'; | |
rmPlusDeviceTypes[parseInt(0x27a9, 16)] = 'Broadlink RM3 Pro Plus v2'; // (model RM 3422) | |
rmPlusDeviceTypes[parseInt(0x27c3, 16)] = 'Broadlink RM3 Pro'; | |
// Known Unsupported Devices | |
const unsupportedDeviceTypes = {}; | |
unsupportedDeviceTypes[parseInt(0, 16)] = 'Broadlink SP1'; | |
unsupportedDeviceTypes[parseInt(0x2711, 16)] = 'Broadlink SP2'; | |
unsupportedDeviceTypes[parseInt(0x2719, 16)] = 'Honeywell SP2'; | |
unsupportedDeviceTypes[parseInt(0x7919, 16)] = 'Honeywell SP2'; | |
unsupportedDeviceTypes[parseInt(0x271a, 16)] = 'Honeywell SP2'; | |
unsupportedDeviceTypes[parseInt(0x791a, 16)] = 'Honeywell SP2'; | |
unsupportedDeviceTypes[parseInt(0x2733, 16)] = 'OEM Branded SP Mini'; | |
unsupportedDeviceTypes[parseInt(0x273e, 16)] = 'OEM Branded SP Mini'; | |
unsupportedDeviceTypes[parseInt(0x2720, 16)] = 'Broadlink SP Mini'; | |
unsupportedDeviceTypes[parseInt(0x7d07, 16)] = 'Broadlink SP Mini'; | |
unsupportedDeviceTypes[parseInt(0x753e, 16)] = 'Broadlink SP 3'; | |
unsupportedDeviceTypes[parseInt(0x2728, 16)] = 'Broadlink SPMini 2'; | |
unsupportedDeviceTypes[parseInt(0x2736, 16)] = 'Broadlink SPMini Plus'; | |
unsupportedDeviceTypes[parseInt(0x2714, 16)] = 'Broadlink A1'; | |
unsupportedDeviceTypes[parseInt(0x4EB5, 16)] = 'Broadlink MP1'; | |
unsupportedDeviceTypes[parseInt(0x2722, 16)] = 'Broadlink S1 (SmartOne Alarm Kit)'; | |
unsupportedDeviceTypes[parseInt(0x4E4D, 16)] = 'Dooya DT360E (DOOYA_CURTAIN_V2) or Hysen Heating Controller'; | |
unsupportedDeviceTypes[parseInt(0x4ead, 16)] = 'Dooya DT360E (DOOYA_CURTAIN_V2) or Hysen Heating Controller'; | |
unsupportedDeviceTypes[parseInt(0x947a, 16)] = 'BroadLink Outlet'; | |
class Broadlink extends EventEmitter { | |
constructor() { | |
super(); | |
this.devices = {}; | |
this.sockets = []; | |
} | |
discover() { | |
// Close existing sockets | |
this.sockets.forEach((socket) => { | |
socket.close(); | |
}) | |
this.sockets = []; | |
// Open a UDP socket on each network interface/IP address | |
const ipAddresses = this.getIPAddresses(); | |
ipAddresses.forEach((ipAddress) => { | |
const socket = dgram.createSocket({ type:'udp4', reuseAddr:true }); | |
this.sockets.push(socket) | |
socket.on('listening', this.onListening.bind(this, socket, ipAddress)); | |
socket.on('message', this.onMessage.bind(this)); | |
socket.bind(0, ipAddress); | |
}); | |
} | |
getIPAddresses() { | |
const interfaces = os.networkInterfaces(); | |
const ipAddresses = []; | |
Object.keys(interfaces).forEach((interfaceID) => { | |
const currentInterface = interfaces[interfaceID]; | |
currentInterface.forEach((address) => { | |
if (address.family === 'IPv4' && !address.internal) { | |
ipAddresses.push(address.address); | |
} | |
}) | |
}); | |
return ipAddresses; | |
} | |
onListening (socket, ipAddress) { | |
const { debug, log } = this; | |
// Broadcase a multicast UDP message to let Broadlink devices know we're listening | |
socket.setBroadcast(true); | |
const splitIPAddress = ipAddress.split('.'); | |
const port = socket.address().port; | |
if (debug && log) log(`\x1b[35m[INFO]\x1b[0m Listening for Broadlink devices on ${ipAddress}:${port} (UDP)`); | |
const now = new Date(); | |
const starttime = now.getTime(); | |
const timezone = now.getTimezoneOffset() / -3600; | |
const packet = Buffer.alloc(0x30, 0); | |
const year = now.getYear(); | |
if (timezone < 0) { | |
packet[0x08] = 0xff + timezone - 1; | |
packet[0x09] = 0xff; | |
packet[0x0a] = 0xff; | |
packet[0x0b] = 0xff; | |
} else { | |
packet[0x08] = timezone; | |
packet[0x09] = 0; | |
packet[0x0a] = 0; | |
packet[0x0b] = 0; | |
} | |
packet[0x0c] = year & 0xff; | |
packet[0x0d] = year >> 8; | |
packet[0x0e] = now.getMinutes(); | |
packet[0x0f] = now.getHours(); | |
const subyear = year % 100; | |
packet[0x10] = subyear; | |
packet[0x11] = now.getDay(); | |
packet[0x12] = now.getDate(); | |
packet[0x13] = now.getMonth(); | |
packet[0x18] = parseInt(splitIPAddress[0]); | |
packet[0x19] = parseInt(splitIPAddress[1]); | |
packet[0x1a] = parseInt(splitIPAddress[2]); | |
packet[0x1b] = parseInt(splitIPAddress[3]); | |
packet[0x1c] = port & 0xff; | |
packet[0x1d] = port >> 8; | |
packet[0x26] = 6; | |
let checksum = 0xbeaf; | |
for (let i = 0; i < packet.length; i++) { | |
checksum += packet[i]; | |
} | |
checksum = checksum & 0xffff; | |
packet[0x20] = checksum & 0xff; | |
packet[0x21] = checksum >> 8; | |
socket.sendto(packet, 0, packet.length, 80, '255.255.255.255'); | |
} | |
onMessage (message, host) { | |
// Broadlink device has responded | |
const macAddress = Buffer.alloc(6, 0); | |
message.copy(macAddress, 0x00, 0x3D); | |
message.copy(macAddress, 0x01, 0x3E); | |
message.copy(macAddress, 0x02, 0x3F); | |
message.copy(macAddress, 0x03, 0x3C); | |
message.copy(macAddress, 0x04, 0x3B); | |
message.copy(macAddress, 0x05, 0x3A); | |
// Ignore if we already know about this device | |
const key = macAddress.toString('hex'); | |
if (this.devices[key]) return; | |
const deviceType = message[0x34] | (message[0x35] << 8); | |
// Create a Device instance | |
this.addDevice(host, macAddress, deviceType); | |
} | |
addDevice (host, macAddress, deviceType) { | |
const { log, debug } = this; | |
if (this.devices[macAddress]) return; | |
const isHostObjectValid = ( | |
typeof host === 'object' && | |
(host.port || host.port === 0) && | |
host.address | |
); | |
assert(isHostObjectValid, `createDevice: host should be an object e.g. { address: '192.168.1.32', port: 80 }`); | |
assert(macAddress, `createDevice: A unique macAddress should be provided`); | |
assert(deviceType, `createDevice: A deviceType from the rmDeviceTypes or rmPlusDeviceTypes list should be provided`); | |
// Mark is at not supported by default so we don't try to | |
// create this device again. | |
this.devices[macAddress] = 'Not Supported'; | |
// Ignore devices that don't support infrared or RF. | |
if (unsupportedDeviceTypes[parseInt(deviceType, 16)]) return null; | |
if (deviceType >= 0x7530 && deviceType <= 0x7918) return null; // OEM branded SPMini2 | |
// If we don't know anything about the device we ask the user to provide details so that | |
// we can handle it correctly. | |
const isKnownDevice = (rmDeviceTypes[parseInt(deviceType, 16)] || rmPlusDeviceTypes[parseInt(deviceType, 16)]) | |
if (!isKnownDevice) { | |
log(`\n\x1b[35m[Info]\x1b[0m We've discovered an unknown Broadlink device. This likely won't cause any issues.\n\nPlease raise an issue in the GitHub repository (https://github.com/lprhodes/homebridge-broadlink-rm/issues) with details of the type of device and its device type code: "${deviceType.toString(16)}". The device is connected to your network with the IP address "${host.address}".\n`); | |
return null; | |
} | |
// The Broadlink device is something we can use. | |
const device = new Device(host, macAddress, deviceType) | |
device.log = log; | |
device.debug = debug; | |
this.devices[macAddress] = device; | |
// Authenticate the device and let others know when it's ready. | |
device.on('deviceReady', () => { | |
this.emit('deviceReady', device); | |
}); | |
device.authenticate(); | |
} | |
} | |
class Device { | |
constructor (host, macAddress, deviceType, port) { | |
this.host = host; | |
this.mac = macAddress; | |
this.emitter = new EventEmitter(); | |
this.log = console.log; | |
this.type = deviceType; | |
this.model = rmDeviceTypes[parseInt(deviceType, 16)] || rmPlusDeviceTypes[parseInt(deviceType, 16)]; | |
this.request_header = parseInt(deviceType, 16) === parseInt(0x5f36, 16) ? new Buffer([0x04, 0x00]) : new Buffer([]); | |
this.code_sending_header = parseInt(deviceType, 16) === parseInt(0x5f36, 16) ? new Buffer([0xd0, 0x00]) : new Buffer([]); | |
this.on = this.emitter.on; | |
this.emit = this.emitter.emit; | |
this.removeListener = this.emitter.removeListener; | |
this.count = Math.random() & 0xffff; | |
this.key = new Buffer([0x09, 0x76, 0x28, 0x34, 0x3f, 0xe9, 0x9e, 0x23, 0x76, 0x5c, 0x15, 0x13, 0xac, 0xcf, 0x8b, 0x02]); | |
this.iv = new Buffer([0x56, 0x2e, 0x17, 0x99, 0x6d, 0x09, 0x3d, 0x28, 0xdd, 0xb3, 0xba, 0x69, 0x5a, 0x2e, 0x6f, 0x58]); | |
this.id = new Buffer([0, 0, 0, 0]); | |
this.setupSocket(); | |
// Dynamically add relevant RF methods if the device supports it | |
const isRFSupported = rmPlusDeviceTypes[parseInt(deviceType, 16)]; | |
if (isRFSupported) this.addRFSupport(); | |
} | |
// Create a UDP socket to receive messages from the broadlink device. | |
setupSocket() { | |
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); | |
this.socket = socket; | |
socket.on('message', (response) => { | |
const encryptedPayload = Buffer.alloc(response.length - 0x38, 0); | |
response.copy(encryptedPayload, 0, 0x38); | |
const err = response[0x22] | (response[0x23] << 8); | |
if (err != 0) return; | |
const decipher = crypto.createDecipheriv('aes-128-cbc', this.key, this.iv); | |
decipher.setAutoPadding(false); | |
let payload = decipher.update(encryptedPayload); | |
const p2 = decipher.final(); | |
if (p2) payload = Buffer.concat([payload, p2]); | |
if (!payload) return false; | |
const command = response[0x26]; | |
if (command == 0xe9) { | |
this.key = Buffer.alloc(0x10, 0); | |
payload.copy(this.key, 0, 0x04, 0x14); | |
this.id = Buffer.alloc(0x04, 0); | |
payload.copy(this.id, 0, 0x00, 0x04); | |
this.emit('deviceReady'); | |
} else if (command == 0xee || command == 0xef) { | |
const payloadHex = payload.toString('hex'); | |
const requestHeaderHex = this.request_header.toString('hex'); | |
const indexOfHeader = payloadHex.indexOf(requestHeaderHex); | |
if (indexOfHeader > -1) { | |
payload = payload.slice(indexOfHeader + this.request_header.length, payload.length); | |
} | |
this.onPayloadReceived(err, payload); | |
} else { | |
console.log('Unhandled Command: ', command); | |
} | |
}); | |
socket.bind(); | |
} | |
authenticate() { | |
const payload = Buffer.alloc(0x50, 0); | |
payload[0x04] = 0x31; | |
payload[0x05] = 0x31; | |
payload[0x06] = 0x31; | |
payload[0x07] = 0x31; | |
payload[0x08] = 0x31; | |
payload[0x09] = 0x31; | |
payload[0x0a] = 0x31; | |
payload[0x0b] = 0x31; | |
payload[0x0c] = 0x31; | |
payload[0x0d] = 0x31; | |
payload[0x0e] = 0x31; | |
payload[0x0f] = 0x31; | |
payload[0x10] = 0x31; | |
payload[0x11] = 0x31; | |
payload[0x12] = 0x31; | |
payload[0x1e] = 0x01; | |
payload[0x2d] = 0x01; | |
payload[0x30] = 'T'.charCodeAt(0); | |
payload[0x31] = 'e'.charCodeAt(0); | |
payload[0x32] = 's'.charCodeAt(0); | |
payload[0x33] = 't'.charCodeAt(0); | |
payload[0x34] = ' '.charCodeAt(0); | |
payload[0x35] = ' '.charCodeAt(0); | |
payload[0x36] = '1'.charCodeAt(0); | |
this.sendPacket(0x65, payload); | |
} | |
sendPacket (command, payload, debug = false) { | |
const { log, socket } = this; | |
this.count = (this.count + 1) & 0xffff; | |
let packet = Buffer.alloc(0x38, 0); | |
packet[0x00] = 0x5a; | |
packet[0x01] = 0xa5; | |
packet[0x02] = 0xaa; | |
packet[0x03] = 0x55; | |
packet[0x04] = 0x5a; | |
packet[0x05] = 0xa5; | |
packet[0x06] = 0xaa; | |
packet[0x07] = 0x55; | |
packet[0x24] = this.type & 0xff | |
packet[0x25] = this.type >> 8 | |
packet[0x26] = command; | |
packet[0x28] = this.count & 0xff; | |
packet[0x29] = this.count >> 8; | |
packet[0x2a] = this.mac[2] | |
packet[0x2b] = this.mac[1] | |
packet[0x2c] = this.mac[0] | |
packet[0x2d] = this.mac[3] | |
packet[0x2e] = this.mac[4] | |
packet[0x2f] = this.mac[5] | |
packet[0x30] = this.id[0]; | |
packet[0x31] = this.id[1]; | |
packet[0x32] = this.id[2]; | |
packet[0x33] = this.id[3]; | |
if (payload){ | |
const padPayload = Buffer.alloc(16 - payload.length % 16, 0) | |
payload = Buffer.concat([payload, padPayload]); | |
} | |
let checksum = 0xbeaf; | |
for (let i = 0; i < payload.length; i++) { | |
checksum += payload[i]; | |
} | |
checksum = checksum & 0xffff; | |
packet[0x34] = checksum & 0xff; | |
packet[0x35] = checksum >> 8; | |
const cipher = crypto.createCipheriv('aes-128-cbc', this.key, this.iv); | |
payload = cipher.update(payload); | |
packet = Buffer.concat([packet, payload]); | |
checksum = 0xbeaf; | |
for (let i = 0; i < packet.length; i++) { | |
checksum += packet[i]; | |
} | |
checksum = checksum & 0xffff; | |
packet[0x20] = checksum & 0xff; | |
packet[0x21] = checksum >> 8; | |
if (debug) log('\x1b[33m[DEBUG]\x1b[0m packet', packet.toString('hex')) | |
socket.send(packet, 0, packet.length, this.host.port, this.host.address, (err, bytes) => { | |
if (debug && err) log('\x1b[33m[DEBUG]\x1b[0m send packet error', err) | |
if (debug) log('\x1b[33m[DEBUG]\x1b[0m successfuly sent packet - bytes: ', bytes) | |
}); | |
} | |
onPayloadReceived (err, payload) { | |
const param = payload[0]; | |
const data = Buffer.alloc(payload.length - 4, 0); | |
payload.copy(data, 0, 4); | |
switch (param) { | |
case 1: { | |
const temp = (payload[0x4] * 10 + payload[0x5]) / 10.0; | |
this.emit('temperature', temp); | |
break; | |
} | |
case 4: { //get from check_data | |
const data = Buffer.alloc(payload.length - 4, 0); | |
payload.copy(data, 0, 4); | |
this.emit('rawData', data); | |
break; | |
} | |
case 38: { //get from check_data | |
this.emit('rawData', payload); | |
break; | |
} | |
case 26: { //get from check_data | |
const data = Buffer.alloc(1, 0); | |
payload.copy(data, 0, 0x4); | |
if (data[0] !== 0x1) break; | |
this.emit('rawRFData', data); | |
break; | |
} | |
case 27: { //get from check_data | |
const data = Buffer.alloc(1, 0); | |
payload.copy(data, 0, 0x4); | |
if (data[0] !== 0x1) break; | |
this.emit('rawRFData2', data); | |
break; | |
} | |
} | |
} | |
// Externally Accessed Methods | |
checkData() { | |
let packet = new Buffer([0x04]); | |
packet = Buffer.concat([this.request_header, packet]); | |
this.sendPacket(0x6a, packet); | |
} | |
sendData (data, debug = false) { | |
let packet = new Buffer([0x02, 0x00, 0x00, 0x00]); | |
packet = Buffer.concat([this.code_sending_header, packet, data]); | |
this.sendPacket(0x6a, packet, debug); | |
} | |
enterLearning() { | |
let packet = new Buffer([0x03]); | |
packet = Buffer.concat([this.request_header, packet]); | |
this.sendPacket(0x6a, packet); | |
} | |
checkTemperature() { | |
let packet = Buffer.alloc(16, 0); | |
packet[0] = 1; | |
this.sendPacket(0x6a, packet); | |
} | |
cancelLearn() { | |
const packet = Buffer.alloc(16, 0); | |
packet[0] = 0x1e; | |
this.sendPacket(0x6a, packet); | |
} | |
addRFSupport() { | |
this.enterRFSweep = () => { | |
const packet = Buffer.alloc(16, 0); | |
packet[0] = 0x19; | |
this.sendPacket(0x6a, packet); | |
} | |
this.checkRFData = () => { | |
const packet = Buffer.alloc(16, 0); | |
packet[0] = 0x1a; | |
this.sendPacket(0x6a, packet); | |
} | |
this.checkRFData2 = () => { | |
const packet = Buffer.alloc(16, 0); | |
packet[0] = 0x1b; | |
this.sendPacket(0x6a, packet); | |
} | |
} | |
} | |
module.exports = Broadlink; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment