diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e087fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +node_modules diff --git a/Example.js b/Example.js new file mode 100644 index 0000000..b617de9 --- /dev/null +++ b/Example.js @@ -0,0 +1,64 @@ +const Orvibo = require('./Orvibo'); +const http = require('http'); +const url = require('url'); + +const httpPort = 3000; + +let orvbio = new Orvibo(); + +// When a socket first connects and initiates the handshake it will emit the connected event with the uid of the socket; +orvbio.on('plugConnected', ({uid, name}) => { + console.log(`Connected ${uid} name = ${name}`); +}); + +// If the socket state is updated this event will fire +orvbio.on('plugStateUpdated', ({uid, state , name}) => { + console.log(`Plug ${name} ${uid} updated state ${state}`); +}); + +// The plug sends a hearbeat to let the server know it's still alive +orvbio.on('gotHeartbeat', ({uid, state , name}) => { + console.log(`Plug ${name} ${uid} sent heartbeat`); +}); + +// Called when the plug disconnects +orvbio.on('plugDisconnected', ({uid, name }) => { + console.log(`Plug ${uid} - ${name} disconnected`); +}); + +// Called when the plug disconnects with an error ie it's been unplugged +orvbio.on('plugDisconnectedWithError', ({uid, name }) => { + console.log(`Plug ${uid} - ${name} disconnected with error`); +}); + + + +// Start the Orvibo socket server +orvbio.startServer(); + +// Create a basic example HTTP server +// If there are no parameters it will return the uid, state, modelId and name of the socket +// You can then use the uid to toggle the state of the switch like +// http://localhost:3000?uid=5dcf7ff76e7a + +const requestHandler = (request, response) => { + response.writeHead(200, {'Content-Type': 'application/json'}); + let q = url.parse(request.url, true).query; + if (q.uid != null) { + orvbio.toggleSocket(q.uid); + } + + // Get all currently connected sockets, their names and states + let sockets = orvbio.getConnectedSocket(); + + response.end(JSON.stringify(sockets)); +}; + +const httpServer = http.createServer(requestHandler) + +httpServer.listen(httpPort, (err) => { + if (err) { + return console.log('something bad happened', err) + } + console.log(`http server is listening on ${httpPort}`) +}); \ No newline at end of file diff --git a/Orvibo.js b/Orvibo.js new file mode 100644 index 0000000..c8d1287 --- /dev/null +++ b/Orvibo.js @@ -0,0 +1,226 @@ +const net = require('net'); +const util = require('util'); +const Packet = require('./OrviboPacket'); +const PacketBuilder = require('./PacketBuilder'); +const Utils = require('./Utils'); +const Settings = require('./OrviboSettings'); +const EventEmitter = require('events').EventEmitter; + +const ORVIBO_KEY = Settings.ORVIBO_KEY; +const LOG_PACKET = Settings.LOG_PACKET; + +if (ORVIBO_KEY === '') { + console.log('Please add Orvibo PK key to OrviboSettings.js file. See Readme'); + process.exit(1); +} + +const Orvibo = function() {}; + +Object.assign(Orvibo.prototype, EventEmitter.prototype); + +let HEARTBEAT = 32; +let HELLO = 0; +let HANDSHAKE = 6; +let STATE_UPDATE = 42; +let STATE_UPDATE_CONFIRM = 15; +let UNKNOWN_CMD = 'UNKNOWN_CMD'; + +let port = 10001; +let bindHost = '0.0.0.0'; + +console.log(`Starting server Orvibo socket server on port ${port}`); + +let plugConnections = []; +let packetData = {}; + +// Put your smart socket data here and it will be matched to the uid +let plugInfo = Settings.plugInfo; + +let getNameForUid = (uid) => { + let item = plugInfo.find(item => item.uid === uid); + return item != null ? item.name : 'unknown'; +}; + +let getData = (id) => { + return packetData[id]; +}; + +let setData = (id, data) => { + packetData[id] = data; +}; + +let respondAndSetData = (data, socket, packetFunction) => { + setData(socket.id, data); + socket.write(packetFunction(data)); +}; + +let helloHandler = (plugPacket, socket) => { + let pkData = { + serial: plugPacket.getSerial(), + encryptionKey: Utils.generateRandomTextValue(16), + id: Utils.generateRandomHexValue(32), + modelId: plugPacket.getModelId(), + }; + respondAndSetData(pkData, socket, PacketBuilder.helloPacket); +}; + +let handshakeHandler = function(plugPacket, socket, socketData) { + let uid = plugPacket.getUid(); + let pkData = Object.assign({}, socketData, { + serial: plugPacket.getSerial(), + uid, + name: getNameForUid(uid) + }); + respondAndSetData(pkData, socket, PacketBuilder.handshakePacket); + this.emit('plugConnected', {uid:pkData.uid, name: pkData.name}); +}; + +let heartbeatHandler = function(plugPacket, socket, socketData) { + let pkData = Object.assign({}, socketData, { + serial: plugPacket.getSerial(), + uid: plugPacket.getUid() + }); + respondAndSetData(pkData, socket, PacketBuilder.heartbeatPacket); + this.emit('gotHeartbeat', {uid:pkData.uid, name: pkData.name}); +}; + +let stateUpdateHandler = function(plugPacket, socket, socketData) { + let pkData = Object.assign({}, socketData, { + serial: plugPacket.getSerial(), + uid: plugPacket.getUid(), + state: plugPacket.getValue1() + }); + respondAndSetData(pkData, socket, PacketBuilder.comfirmStatePacket); + this.emit('plugStateUpdated', {uid:pkData.uid, state: pkData.state, name: pkData.name}); +}; + +let stateConfirmHandler = function() { + // Do nothing at this stage +}; + +let unknownCmdHandler = function(plugPacket, socket, socketData) { + let pkData = Object.assign({}, socketData, { + serial: plugPacket.getSerial(), + uid: plugPacket.getUid(), + }); + respondAndSetData(pkData, socket, PacketBuilder.defaultPacket); +}; + +Orvibo.prototype.handlers = function() { + return { + [HELLO]: helloHandler.bind(this), + [HANDSHAKE]: handshakeHandler.bind(this), + [HEARTBEAT]: heartbeatHandler.bind(this), + [STATE_UPDATE]: stateUpdateHandler.bind(this), + [STATE_UPDATE_CONFIRM] : stateConfirmHandler, + [UNKNOWN_CMD]: unknownCmdHandler.bind(this) + }; +}; + +Orvibo.prototype.startServer = function() { + + let self = this; + + let handlers = this.handlers(); + + this.server = net.createServer(function(socket) { + + socket.id = Utils.generateRandomTextValue(16); + socket.setKeepAlive(true, 10000); + plugConnections.push(socket); + + socket.on('data', function (data) { + + let socketData = getData(socket.id); + let plugPacket = new Packet(data); + + if (!plugPacket.validCRC()) { + console.log('Got invalid CRC'); + return; + } + + if (plugPacket.packetTypeText() === 'pk') { + plugPacket.processPacket(ORVIBO_KEY); + } else { + plugPacket.processPacket(socketData.encryptionKey); + } + + LOG_PACKET && plugPacket.logPacket("Socket -> "); + + let handler = handlers[plugPacket.getCommand()]; + if (handler != null) { + handler(plugPacket, socket, socketData); + } else { + handlers[UNKNOWN_CMD](plugPacket, socket, socketData); + } + }); + + socket.on('end', function () { + let pkData = getData(socket.id); + self.emit('plugDisconnected', {uid: pkData.uid, name: pkData.name}); + delete packetData[socket.id]; + plugConnections.splice(plugConnections.indexOf(socket), 1); + }); + + socket.on('error', (err) => { + console.log(err); + console.log('error with socket ' + socket.id); + self.emit('plugDisconnectedWithError', getData(socket.id)); + delete packetData[socket.id]; + plugConnections.splice(plugConnections.indexOf(socket), 1); + + }); + + }); + + this.server.listen(port, bindHost); +}; + +Orvibo.prototype.toggleSocket = function(uid) { + + let socketId = null; + for (const key of Object.keys(packetData)) { + if (packetData[key].uid === uid) { + socketId = key; + break + } + } + if (socketId === null) { + console.log('Could not find socket ' + uid); + return; + } + let socket = plugConnections.find(s => s.id === socketId); + if (socket != null) { + let socketData = getData(socketId); + let data = Object.assign({}, socketData,{ + state: socketData.state === 1 ? 0 : 1, + serial: Utils.generateRandomNumber(8), + clientSessionId: socketData.clientSessionId ? socketData.clientSessionId : Utils.generateRandomHexValue(32), + deviceId: socketData.deviceId ? socketData.deviceId : Utils.generateRandomHexValue(32), + }); + + setData(socket.id, data); + + let packet = PacketBuilder.updatePacket(data); + socket.write(packet); + + } else { + console.log('Can not find socket '); + } +}; + +Orvibo.prototype.getConnectedSocket = function() { + let sockets = []; + for (const key of Object.keys(packetData)) { + let socketData = getData(key); + sockets.push({ + name: socketData.name, + state: socketData.state, + uid: socketData.uid, + modelId: socketData.modelId + }); + } + return sockets; +}; + +module.exports = Orvibo; \ No newline at end of file diff --git a/OrviboPacket.js b/OrviboPacket.js new file mode 100644 index 0000000..b5ac544 --- /dev/null +++ b/OrviboPacket.js @@ -0,0 +1,71 @@ +const crc32 = require('buffer-crc32'); +const crypto = require('crypto'); + +let pkt = function(packetBuffer) { + this.magic = packetBuffer.slice(0,2); + this.packetLength = packetBuffer.slice(2,4); + this.packetType = packetBuffer.slice(4,6); + this.crc32 = packetBuffer.slice(6,10); + this.packetId = packetBuffer.slice(10,42); + this.payload = packetBuffer.slice(42, packetBuffer.length); +}; + +pkt.prototype.logPacket = function(type) { + let output = { + PacketId : this.packetIdText(), + Payload : this.payloadJSON + }; + console.log(type, JSON.stringify(this.payloadJSON)); +}; + +pkt.prototype.getCommand = function() { + return this.payloadJSON.cmd; +}; + +pkt.prototype.getSerial = function() { + return this.payloadJSON.serial; +}; + +pkt.prototype.getUid = function() { + return this.payloadJSON.uid; +}; + +pkt.prototype.getValue1 = function() { + return this.payloadJSON.value1; +}; + +pkt.prototype.getModelId = function() { + return this.payloadJSON.modelId; +}; + +pkt.prototype.processPacket = function(key) { + this.payloadJSON = this.decodeJSON(key); +}; + +pkt.prototype.getKeyValue = function() { + return this.payloadJSON.key; +}; + +pkt.prototype.validCRC = function() { + return crc32(this.payload).toString('hex') === this.crc32.toString('hex'); +}; + +pkt.prototype.packetTypeText = function() { + return this.packetType.toString('ascii'); +}; + +pkt.prototype.packetIdText = function() { + return this.packetId.toString('ascii'); +}; + +pkt.prototype.decodeJSON = function(key) { + let decipher = crypto.createDecipheriv('aes-128-ecb', key, ''); + decipher.setAutoPadding(true); + let decrypted = decipher.update(this.payload.toString('hex'), 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + // Sometimes there are bad chars on the end of the JSON so check here + decrypted = decrypted.substring(0, decrypted.indexOf('}') + 1); + return JSON.parse(decrypted); +}; + +module.exports = pkt; \ No newline at end of file diff --git a/OrviboSettings.js b/OrviboSettings.js new file mode 100644 index 0000000..e0cbdd3 --- /dev/null +++ b/OrviboSettings.js @@ -0,0 +1,13 @@ +// Settings/Config store for orvibo socket server + +module.exports = { + LOG_PACKET: false, + ORVIBO_KEY: '', // put your PK key here as plain text + plugInfo : [ + // Add uid and a name so you can easily identify the connected sockets + { + uid :'53dd7fe74de7', + name: "Lamp in Kitchen" + }, + ], +} \ No newline at end of file diff --git a/PacketBuilder.js b/PacketBuilder.js new file mode 100644 index 0000000..de2d26d --- /dev/null +++ b/PacketBuilder.js @@ -0,0 +1,182 @@ +const crc32 = require('buffer-crc32'); +const crypto = require('crypto'); +const Settings = require('./orviboSettings'); + +const ORVIBO_KEY = Settings.ORVIBO_KEY; + +let Packet = { + encryptionKey : ORVIBO_KEY, + magic: new Buffer("6864", 'hex'), + build: function () { + let packetId = new Buffer(this.id, 'ascii'); + let payload = encodePayload(JSON.stringify(this.json), this.encryptionKey); + let crc = crc32(payload); + let length = getLength([this.magic, this.packetType, packetId, crc, payload], 2); // Extra 2 for the length field itself + return Buffer.concat([this.magic, length, this.packetType, crc, packetId, payload]); + } +}; + +let PKPacket = Object.assign({}, Packet, { + packetType: new Buffer('pk', 'ascii'), +}); + +let DKPacket = Object.assign({}, Packet, { + packetType: new Buffer('dk', 'ascii'), +}); + +let helloPacket = function({ serial, encryptionKey, id }) { + let json = { + cmd: 0, + status: 0, + serial: serial, + key: encryptionKey + }; + + let pkt = Object.assign(Object.create(PKPacket), { + json: json, + id: id + }); + + return pkt.build(); +}; + +let handshakePacket = function({ serial, encryptionKey, id }) { + + let json = { + cmd: 6, + status:0, + serial: serial + }; + + let pkt = Object.assign(Object.create(DKPacket), { + json: json, + id: id, + encryptionKey, encryptionKey + }); + + return pkt.build(); +}; + +let heartbeatPacket = function({serial, uid, encryptionKey, id}) { + let json = { + cmd: 32, + status:0, + serial: serial, + uid: uid, + utc: new Date().getTime() + }; + + let pkt = Object.assign(Object.create(DKPacket), { + json: json, + id: id, + encryptionKey, encryptionKey + }); + + return pkt.build(); +}; + +let comfirmStatePacket = function({serial, uid, state, encryptionKey, id}) { + + var json = { + uid: uid, + cmd: 42, + statusType: 0, + value3: 0, + alarmType: 1, + serial: serial, + value4: 0, + deviceId: 0, + value1: state, + value2: 0, + updateTimeSec: new Date().getTime(), + status: 0 + }; + + let pkt = Object.assign(Object.create(DKPacket), { + json: json, + id: id, + encryptionKey, encryptionKey + }); + + return pkt.build(); +}; + +let defaultPacket = function({serial, uid, cmd, id, encryptionKey}) { + + let json = { + uid: uid, + cmd: cmd, + serial: serial, + status: 0 + }; + + let pkt = Object.assign(Object.create(DKPacket), { + json: json, + id: id, + encryptionKey, encryptionKey + }); + + return pkt.build(); +}; + + +let updatePacket = function({ uid, state, serial, id, clientSessionId , deviceId, encryptionKey}) { + let json = { + uid: uid, + delayTime: 0, + cmd: 15, + order: state === 0 ? "on" : "off", + userName: "iloveorvibo@orvibo.com", + ver: "3.0.0", + value3: 0, + serial: serial, + value4: 0, + deviceId: deviceId, + value1: state, + value2: 0, + clientSessionId: clientSessionId + }; + + let pkt = Object.assign(Object.create(DKPacket), { + json: json, + id: id, + encryptionKey, encryptionKey + }); + + return pkt.build(); +}; + + +let encodePayload = function(json, key) { + let cipher = crypto.createCipheriv('aes-128-ecb', key, ''); + cipher.setAutoPadding(true); + let crypted = cipher.update(json,'utf8','hex'); + crypted += cipher.final('hex'); + return new Buffer(crypted, 'hex'); +}; + +let getLength = function(items, extra) { + let length = extra || 0; + for (let i = 0; i < items.length; i++) { + length += items[i].length; + } + return getHexLengthPadded(length); +}; + +let getHexLengthPadded = function(lengthDecimal) { + let lengthHex = lengthDecimal.toString(16); + let paddingLength = 4 - lengthHex.length; + let padding = ''; + for (let i = 0; i < paddingLength; i++) { + padding +=0; + } + return new Buffer(padding + lengthHex, 'hex'); +}; + + +module.exports.helloPacket = helloPacket; +module.exports.handshakePacket = handshakePacket; +module.exports.heartbeatPacket = heartbeatPacket; +module.exports.comfirmStatePacket = comfirmStatePacket; +module.exports.defaultPacket = defaultPacket; +module.exports.updatePacket = updatePacket; \ No newline at end of file diff --git a/README.md b/README.md index d5cbd46..cb27a5c 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,7 @@ You will need to add the Orvibo PK key to decrypt and encrypt the initial packet Once you have this key you will need to add it the ``OrviboSettings.js`` file. -Because these new sockets don't use UDP packets to communicate like the older versions you will also need to redirect all traffic from the host name -``` -homemate.orvibo.com -``` +Because these new sockets don't use UDP packets to communicate like the older versions you will also need to redirect all traffic from the host name ``homemate.orvibo.com`` on TCP port 10001 the computer running the server. I used an Ubuntu machine running dnsmasq and set this server as my DNS server in my router but depending on your network you might have to do it differntly. @@ -37,7 +34,6 @@ to the official Orvibo server. Clone the repo and then run ``` npm install - ``` to install the dependencies (buffer-crc32) @@ -50,7 +46,6 @@ To start the example http server run ``` npm start - ``` This will start the Orvibo socket server create a basic example HTTP server used for interacting with sockets. Calling this http server with no parameters will return the uid, state, modelId and name of the socket. diff --git a/Utils.js b/Utils.js new file mode 100644 index 0000000..df9f2cd --- /dev/null +++ b/Utils.js @@ -0,0 +1,27 @@ +module.exports.generateRandomTextValue = function(length) { + let chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + let result = ''; + for (let i = length; i > 0; --i) { + result += chars[Math.floor(Math.random() * chars.length)]; + } + return result; +}; + +module.exports.generateRandomHexValue = function(length) { + let chars = '0123456789abcdef'; + let result = ''; + for (let i = length; i > 0; --i) { + result += chars[Math.floor(Math.random() * chars.length)]; + } + return result; +}; + +module.exports.generateRandomNumber = function(length) { + let numbers = '0123456789'; + let result = ''; + for (let i = length; i > 0; --i) { + result += numbers[Math.floor(Math.random() * numbers.length)]; + } + return parseInt(result); +}; + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2830cde --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "orvibo-b25-server", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2bcecd0 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "orvibo-b25-server", + "version": "1.0.0", + "description": "A server to control the Orvibo B25 range of smart sockets", + "main": "Orvibo.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node Example.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/sandysound/orvibo-b25-server.git" + }, + "keywords": [ + "Orvibo", + "B25", + "Smart", + "Socket" + ], + "author": "Sandy Milne", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/sandysound/orvibo-b25-server/issues" + }, + "homepage": "https://github.com/sandysound/orvibo-b25-server#readme", + "dependencies": { + "buffer-crc32": "^0.2.13" + } +}