Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

V2 protocol and Source port specification support #90

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ Note: _boolean_ values are set using 0 or 1
| **fanspeed** | _auto_, _low_, _mediumLow_, _medium_, _mediumHigh_, _high_ | Fan speed |
| **swinghor** | _default_, _full_, _fixedLeft_, _fixedMidLeft_, _fixedMid_, _fixedMidRight_, _fixedRight_ | Horizontal Swing |
| **swingvert** | _default_, _full_, _fixedTop_, _fixedMidTop_, _fixedMid_, _fixedMidBottom_, _fixedBottom_, _swingBottom_, _swingMidBottom_, _swingMid_, _swingMidTop_, _swingTop_ | Vetical swing |
| **power** | _0_, _1_ | Turn device on/off |
| **health** | _0_, _1_ | Health ("Cold plasma") mode, only for devices equipped with "anion generator", which absorbs dust and kills bacteria |
| **powersave** | _0_, _1_ | Power Saving mode |
| **lights** | _0_, _1_ | Turn on/off device lights |
| **quiet** | _0_, _1_, _2_, _3_ | Quiet modes |
| **blow** | _0_, _1_ | Keeps the fan running for a while after shutting down (also called "X-Fan", only usable in Dry and Cool mode) |
| **power** | _off_, _on_ | Turn device on/off |
| **health** | _off_, _on_ | Health ("Cold plasma") mode, only for devices equipped with "anion generator", which absorbs dust and kills bacteria |
| **powersave** | _off_, _on_ | Power Saving mode |
| **lights** | _off_, _on_ | Turn on/off device lights |
| **quiet** | _off_, _mode1_, _mode2_, _mode3_ | Quiet modes |
| **blow** | _off_, _on_ | Keeps the fan running for a while after shutting down (also called "X-Fan", only usable in Dry and Cool mode) |
| **air** | _off_, _inside_, _outside_, _mode3_ | Fresh air valve |
| **sleep** | _0_, _1_ | Sleep mode |
| **turbo** | _0_, _1_ | Turbo mode |
| **sleep** | _off_, _on_ | Sleep mode |
| **turbo** | _off_, _on_ | Turbo mode |

## Hass.io addon

Expand Down Expand Up @@ -251,3 +251,4 @@ This project is licensed under the GNU GPLv3 - see the [LICENSE.md](LICENSE.md)
- [arthurkrupa](https://https://github.com/arthurkrupa) for the actual service
- [bkbilly](https://github.com/bkbilly) for service improvements to MQTT
- [aaronsb](https://github.com/aaronsb) for sweeping the Node floor
- [eibenp](https://github.com/eibenp) for V2 protol support
128 changes: 114 additions & 14 deletions app/deviceFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class Device {
// Set defaults
this.options = {
host: options.host || '192.168.1.255',
localPort: options.localPort || 0,
onStatus: options.onStatus || function () {},
onUpdate: options.onUpdate || function () {},
onConnected: options.onConnected || function () {}
Expand All @@ -37,6 +38,10 @@ class Device {
* @property {object} props - Properties
*/
this.device = {}
this.encryptionVersion = 1;
this.tag = '';
this.packetSentNo = 0;
this.packetReceivedNo = 0;

// Initialize connection and bind with device
this._connectToDevice(this.options.host)
Expand All @@ -51,13 +56,21 @@ class Device {
*/
_connectToDevice (address) {
try {
socket.bind(() => {
socket.bind(this.options.localPort, () => {
const message = Buffer.from(JSON.stringify({ t: 'scan' }))

socket.setBroadcast(true)
this.packetSentNo++;
//console.log('[UDP] Sent[%d]: %s', this.packetSentNo, message);

socket.setBroadcast(true)
socket.send(message, 0, message.length, 7000, address)

console.log('[UDP] Connected to device at %s', address)
if (this.options.localPort === 0) {
console.log('[UDP] Connected to device at %s', address)
} else {
console.log('[UDP] Connected to device at %s from port %d', address, this.options.localPort)
}

})
} catch (err) {
const timeout = 60
Expand All @@ -84,29 +97,57 @@ class Device {
this.device.bound = false
this.device.props = {}

console.log('[UDP] New device registered: %s', this.device.name)
console.log('[UDP] New device registered: %s @ %s', this.device.id, this.device.address)
}

/**
* Send binding request to device
* @param {Device} device Device object
*/
_sendBindRequest (device) {

let encryptedBoundMessage;

const message = {
mac: this.device.id,
t: 'bind',
uid: 0
}
const encryptedBoundMessage = encryptionService.encrypt(message)
const request = {

if(this.encryptionVersion === 1) {
encryptedBoundMessage = encryptionService.encrypt(message)
this.tag = '';
}
else if(this.encryptionVersion === 2) {
const encrypted = encryptionService.encrypt_v2(message)
encryptedBoundMessage = encrypted.pack
this.tag = encrypted.tag;
}

const request = (this.tag === '') ? {
tcid: this.device.id,
cid: 'app',
i: 1,
t: 'pack',
uid: 0,
pack: encryptedBoundMessage
} : {
tcid: this.device.id,
cid: 'app',
i: 1,
t: 'pack',
uid: 0,
tag: this.tag,
pack: encryptedBoundMessage
}

const toSend = Buffer.from(JSON.stringify(request))
socket.send(toSend, 0, toSend.length, device.port, device.address)

this.packetSentNo++;
//console.log('[UDP] Sent[%d]: %s', this.packetSentNo, toSend);

//console.log('[UDP] Device %s bind request sent...', this.device.id)
}

/**
Expand All @@ -117,7 +158,7 @@ class Device {
_confirmBinding (id, key) {
this.device.bound = true
this.device.key = key
console.log('[UDP] Device %s is bound!', this.device.name)
console.log('[UDP] Device %s is bound!', this.device.id)
}

/**
Expand All @@ -141,20 +182,51 @@ class Device {
* @param {number} rinfo.port Port number
*/
_handleResponse (msg, rinfo) {

this.packetReceivedNo++;

let pack;
const message = JSON.parse(msg + '')

//console.log('[UDP] Received[%d]: %s', this.packetReceivedNo, message);

// Extract encrypted package from message using device key (if available)
const pack = encryptionService.decrypt(message, (this.device || {}).key)
if (this.encryptionVersion === 1) {
//console.log('[UDP] Received packet with encryption version: 1')
pack = encryptionService.decrypt(message, message.i === 1 ? undefined : this.device.key)
}
else if (this.encryptionVersion === 2) {
//console.log('[UDP] Received packet with encryption version: 2')
pack = encryptionService.decrypt_v2(message, message.i === 1 ? undefined : this.device.key)
}

//console.log('[UDP] Pack %s', pack);

// If package type is response to handshake
if (pack.t === 'dev') {
this._setDevice(message.cid, pack.name, rinfo.address, rinfo.port)

//console.log('[UDP] Device respond for scan');

if (this.encryptionVersion === 1 && pack.ver && pack.ver.toString().startsWith('V2.')) {
//console.log('[UDP] Encryption switched to version: 2')
// first V2 version responded to scan command with V1 encryption but binding requires V2 encryption
this.encryptionVersion = 2;
this._setDevice(pack.cid, pack.name, rinfo.address, rinfo.port)
}
else {
this._setDevice(message.cid, pack.name, rinfo.address, rinfo.port)
}

this._sendBindRequest(this.device)
return

}

// If package type is binding confirmation
if (pack.t === 'bindok' && this.device.id) {

//console.log('[UDP] Device respond for bind');

this._confirmBinding(message.cid, pack.key)

// Start requesting device status on set interval
Expand All @@ -173,9 +245,10 @@ class Device {
}

// If package type is response, update device properties
if (pack.t === 'res' && this.device.bound) {
if (pack.t === 'res' && this.device.bound) {
pack.opt.forEach((opt, i) => {
this.device.props[opt] = pack.val[i]
const value = pack.p !== undefined ? pack.p[i] : pack.val[i];
this.device.props[opt] = value;
})
this.options.onUpdate(this.device)
return
Expand Down Expand Up @@ -208,16 +281,43 @@ class Device {
* @param {number} [port] Port number
*/
_sendRequest (message, address = this.device.address, port = this.device.port) {
const encryptedMessage = encryptionService.encrypt(message, this.device.key)
const request = {

let encryptedBoundMessage;

//console.log('[UDP] SendRequest: %s', message);

if(this.encryptionVersion === 1) {
encryptedBoundMessage = encryptionService.encrypt(message, this.device.key)
this.tag = '';
}
else if(this.encryptionVersion === 2) {
const encrypted = encryptionService.encrypt_v2(message, this.device.key)
encryptedBoundMessage = encrypted.pack
this.tag = encrypted.tag;
}

const request = (this.tag === '') ? {
tcid: this.device.id,
cid: 'app',
i: 0,
t: 'pack',
uid: 0,
pack: encryptedMessage
pack: encryptedBoundMessage
} : {
tcid: this.device.id,
cid: 'app',
i: 0,
t: 'pack',
uid: 0,
tag: this.tag,
pack: encryptedBoundMessage
}
const serializedRequest = Buffer.from(JSON.stringify(request))
socket.send(serializedRequest, 0, serializedRequest.length, port, address)

this.packetSentNo++;
//console.log('[UDP] Sent[%d]: %s', this.packetSentNo, serializedRequest);

};

/**
Expand Down
32 changes: 26 additions & 6 deletions app/encryptionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@

const crypto = require('crypto')

const genericKey_v1 = 'a3K8Bx%2r8Y7#xDh';
const genericKey_v2 = '{yxAHAY_Lm6pbC/<';
const iv_v2 = Buffer.from([0x54, 0x40, 0x78, 0x44, 0x49, 0x67, 0x5a, 0x51, 0x6c, 0x5e, 0x63, 0x13]);
const aad_v2 = Buffer.from('qualcomm-test');

/**
* Module containing encryption services
* @param {String} key AES general key
*/
module.exports = function (defaultKey = 'a3K8Bx%2r8Y7#xDh') {
module.exports = function (defaultKey = genericKey_v1) {
const EncryptionService = {

/**
Expand All @@ -23,16 +28,31 @@ module.exports = function (defaultKey = 'a3K8Bx%2r8Y7#xDh') {
},

/**
* Encrypt UDP message
* @param {object} output Request object
* @param {string} [key] AES key
*/
* Encrypt UDP message
* @param {object} output Request object
* @param {string} [key] AES key
*/
encrypt: (output, key = defaultKey) => {
const cipher = crypto.createCipheriv('aes-128-ecb', key, '')
const str = cipher.update(JSON.stringify(output), 'utf8', 'base64')
const request = str + cipher.final('base64')
return request
}
},

decrypt_v2: (data, key = genericKey_v2) => {
const tagbuffer = Buffer.from(data.tag + '', 'base64');
const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv_v2).setAuthTag(tagbuffer).setAAD(aad_v2);
return JSON.parse(decipher.update(data.pack + '', 'base64', 'utf8') + decipher.final('utf8'));
},

encrypt_v2: (data, key = genericKey_v2) => {
const str = JSON.stringify(data);
const cipher = crypto.createCipheriv('aes-128-gcm', key, iv_v2).setAAD(aad_v2);
const pack = cipher.update(str, 'utf8', 'base64') + cipher.final('base64');
const tag = cipher.getAuthTag().toString('base64');
return {pack, tag};
},

}

return EncryptionService
Expand Down
19 changes: 10 additions & 9 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const publishIfChanged = function (stateProp, newValue, mqttTopic) {

const deviceOptions = {
host: argv['hvac-host'],
localPort: argv['local-port'],
onStatus: (deviceModel) => {
publishIfChanged('temperature', deviceModel.props[commands.temperature.code].toString(), '/temperature/get')
publishIfChanged('fanSpeed', getKeyByValue(commands.fanSpeed.value, deviceModel.props[commands.fanSpeed.code]).toString(), '/fanspeed/get')
Expand Down Expand Up @@ -167,31 +168,31 @@ client.on('message', (topic, message) => {
hvac.setSwingVert(commands.swingVert.value[message])
return
case mqttTopicPrefix + '/power/set':
hvac.setPower(parseInt(message))
hvac.setPower(commands.power.value[message])
return
case mqttTopicPrefix + '/health/set':
hvac.setHealthMode(parseInt(message))
hvac.setHealthMode(commands.health.value[message])
return
case mqttTopicPrefix + '/powersave/set':
hvac.setPowerSave(parseInt(message))
hvac.setPowerSave(commands.powerSave.value[message])
return
case mqttTopicPrefix + '/lights/set':
hvac.setLights(parseInt(message))
hvac.setLights(commands.lights.value[message])
return
case mqttTopicPrefix + '/quiet/set':
hvac.setQuietMode(parseInt(message))
hvac.setQuietMode(commands.quiet.value[message])
return
case mqttTopicPrefix + '/blow/set':
hvac.setBlow(parseInt(message))
hvac.setBlow(commands.blow.value[message])
return
case mqttTopicPrefix + '/air/set':
hvac.setAir(parseInt(message))
hvac.setAir(commands.air.value[message])
return
case mqttTopicPrefix + '/sleep/set':
hvac.setSleepMode(parseInt(message))
hvac.setSleepMode(commands.sleep.value[message])
return
case mqttTopicPrefix + '/turbo/set':
hvac.setTurbo(parseInt(message))
hvac.setTurbo(commands.turbo.value[message])
return
}
console.log('[MQTT] No handler for topic %s', topic)
Expand Down
Loading