├── .gitignore ├── LICENSE ├── README.md ├── bin ├── cli-decode.js ├── cli-find.js └── cli.js ├── index.js ├── lib ├── AirConditionerAccessory.js ├── BaseAccessory.js ├── ContactSensorAccessory.js ├── ConvectorAccessory.js ├── CustomMultiOutletAccessory.js ├── EnergyCharacteristics.js ├── GarageDoorAccessory.js ├── MultiOutletAccessory.js ├── OutletAccessory.js ├── RGBTWLightAccessory.js ├── RGBTWOutletAccessory.js ├── SimpleBlindsAccessory.js ├── SimpleDimmerAccessory.js ├── SimpleHeaterAccessory.js ├── SimpleLightAccessory.js ├── TWLightAccessory.js ├── TuyaAccessory.js └── TuyaDiscovery.js ├── package-lock.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | /node_modules 3 | /*.iws 4 | /*.iml 5 | /*.ipr 6 | /bin/certs 7 | /bin/keys 8 | /dev 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Miki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homebridge-tuya-lan 2 | 3 | Homebridge plugin for IoT devices that use Tuya Smart's platform, allowing them to be exposed to Apple's HomeKit. 4 | 5 | ## Installation 6 | Install this plugin using `npm i -g homebridge-tuya-lan`. 7 | 8 | Update the `config.json` file of your Homebridge setup, by modifying the sample configuration below. Detailed steps for getting the `id` and `key` combinations of your devices can be found on the [Setup Instructions](https://github.com/AMoo-Miki/homebridge-tuya-lan/wiki/Setup-Instructions) page. 9 | 10 | ## Updating 11 | Update to the latest release of this plugin using `npm i -g homebridge-tuya-lan`. 12 | 13 | If you feel brave, want to help test unreleased devices, or are asked to update to the latest _unreleased_ version of the plugin, use `npm i -g AMoo-Miki/homebridge-tuya-lan`. 14 | 15 | ## Configurations 16 | The configuration parameters to enable your devices would need to be added to `platforms` section of the Homebridge configuration file. Examples of device configs can be found on the [Supported Devices](https://github.com/AMoo-Miki/homebridge-tuya-lan/wiki/Supported-Devices) page. Check out the [Common Problems](https://github.com/AMoo-Miki/homebridge-tuya-lan/wiki/Common-Problems) page for solutions or raise an issue if you face problems. 17 | ```json5 18 | { 19 | ... 20 | "platforms": [ 21 | ... 22 | /* The block you need to enable this plugin */ 23 | { 24 | "platform": "TuyaLan", 25 | "devices": [ 26 | /* The block you need for each device */ 27 | { 28 | "name": "Hallway Light", 29 | "type": "SimpleLight", 30 | "manufacturer": "Cotify", 31 | "model": "Smart Wifi Bulb Socket E26", 32 | "id": "011233455677899abbcd", 33 | "key": "0123456789abcdef" 34 | } 35 | /* End of the device definition block */ 36 | ] 37 | } 38 | /* End of the block needed to enable this plugin */ 39 | ] 40 | ... 41 | } 42 | ``` 43 | #### Parameters 44 | * `name` (required) is anything you'd like to use to identify this device. You can always change the name from within the Home app. 45 | * `type` (required) is a case-insensitive identifier that lets the plugin know how to handle your device. Find your device `type` on the [Supported Devices](https://github.com/AMoo-Miki/homebridge-tuya-lan/wiki/Supported-Devices) page. 46 | * `manufacturer` and `model` are anything you like; the purpose of them is to help you identify the device. 47 | * `id` (required) and `key` (required) are parameters for your device. If you don't have them, follow the steps found on the [Setup Instructions](https://github.com/AMoo-Miki/homebridge-tuya-lan/wiki/Setup-Instructions) page. 48 | * `ip` needs to be added **_only_** if you face discovery issues. See [Common Problems](https://github.com/AMoo-Miki/homebridge-tuya-lan/wiki/Common-Problems) for more details. 49 | 50 | > To find out which `id` belongs to which device, open the Tuya Smart app and check the `Device Information` by tapping the configuration icon of your devices; it is almost always a tiny icon on the top-right. 51 | 52 | ## Credit 53 | To create this plugin, I learnt a lot from [Max Isom](https://maxisom.me/)'s work on his [TuyAPI](https://github.com/codetheweb/tuyapi) project to create my communication driver. -------------------------------------------------------------------------------- /bin/cli-decode.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const YAML = require('yaml'); 4 | const fs = require('fs-extra'); 5 | const path = require('path'); 6 | const program = require('commander'); 7 | const crypto = require('crypto'); 8 | const readline = require('readline'); 9 | const async = require('async'); 10 | 11 | let file; 12 | 13 | program 14 | .name('tuya-lan decode') 15 | .option('--key ', 'device key') 16 | .option('--use ', 'override version string', '3.3') 17 | .arguments('') 18 | .action(loc => { 19 | file = loc; 20 | }) 21 | .parse(process.argv); 22 | 23 | const crc32LookupTable = []; 24 | (() => { 25 | for (let i = 0; i < 256; i++) { 26 | let crc = i; 27 | for (let j = 8; j > 0; j--) crc = (crc & 1) ? (crc >>> 1) ^ 3988292384 : crc >>> 1; 28 | crc32LookupTable.push(crc); 29 | } 30 | })(); 31 | 32 | const getCRC32 = buffer => { 33 | let crc = 0xffffffff; 34 | for (let i = 0, len = buffer.length; i < len; i++) crc = crc32LookupTable[buffer[i] ^ (crc & 0xff)] ^ (crc >>> 8); 35 | return ~crc; 36 | }; 37 | 38 | const checkKey = key => { 39 | if (!key) return false; 40 | if (!/^[0-9a-f]+$/i.test(key)) { 41 | console.log('*** The key contains invalid characters; try again.'); 42 | return false; 43 | } 44 | 45 | if (!{16:1, 24:1, 32: 1}[key.length]) { 46 | console.log('*** The key contains the wrong number of characters; try again.'); 47 | return false; 48 | } 49 | 50 | return true; 51 | }; 52 | 53 | const decodeLine = (key, input, log = true) => { 54 | const encoding = (input.substr(0, 8) === '000055aa') ? 'hex' : 'base64'; 55 | 56 | let buffer = Buffer.from(input, encoding); 57 | const raw = Buffer.from(input, encoding); 58 | const len = buffer.length; 59 | if (buffer.readUInt32BE(0) !== 0x000055aa || buffer.readUInt32BE(len - 4) !== 0x0000aa55) { 60 | console.log("*** Input doesn't match the expected signature:", buffer.readUInt32BE(0).toString(16).padStart(8, '0'), buffer.readUInt32BE(len - 4).toString(16).padStart(8, '0')); 61 | return rl.prompt(); 62 | } 63 | 64 | // Try 3.3 65 | const size = buffer.readUInt32BE(12); 66 | const cmd = buffer.readUInt32BE(8); 67 | const seq = buffer.readUInt32BE(4); 68 | const crcIn = buffer.readInt32BE(len - 8); 69 | const preHash = buffer.slice(0, len - 8); 70 | if (log) { 71 | console.log(`Cmd > ${cmd} \tLen > ${len}\tSize > ${size}\tSeq > ${seq}`); 72 | console.log(`CRC > \t${crcIn === getCRC32(preHash) ? `Pass` : `Fail ${crcIn} ≠ ${getCRC32(preHash)}`}`); 73 | } 74 | const flag = buffer.readUInt32BE(16) & 0xFFFFFF00; 75 | buffer = buffer.slice(len - size + (flag ? 0 : 4), len - 8); 76 | if (buffer.indexOf(program.use || '3.3') !== -1) buffer = buffer.slice(15 + buffer.indexOf(program.use || '3.3')); 77 | else if (buffer.indexOf('3.2') !== -1) buffer = buffer.slice(15 + buffer.indexOf('3.2')); 78 | 79 | switch (cmd) { 80 | case 7: 81 | case 8: 82 | case 10: 83 | case 13: 84 | case 16: 85 | if (buffer.length === 0) { 86 | console.log(`${('' + seq).padEnd(4)} Decoded ${cmd}> Empty`); 87 | break; 88 | } 89 | try { 90 | const decipher = crypto.createDecipheriv('aes-128-ecb', key, ''); 91 | let decryptedMsg = decipher.update(buffer, 'buffer', 'utf8'); 92 | decryptedMsg += decipher.final('utf8'); 93 | 94 | console.log(`${('' + seq).padEnd(4)} Decoded ${cmd}>`, decryptedMsg); 95 | if (log) console.log(`${('' + seq).padEnd(4)} Raw ${cmd}>`, raw.toString('hex')); 96 | } catch (ex) { 97 | console.log(`${('' + seq).padEnd(4)}*Failed ${cmd}>`, raw.toString('hex')); 98 | } 99 | break; 100 | 101 | case 9: 102 | console.log(`${('' + seq).padEnd(4)} Decoded ${cmd}>`, flag ? 'Ping' : 'Pong'); 103 | break; 104 | 105 | case 19: 106 | let decryptedMsg; 107 | try { 108 | const decipher = crypto.createDecipheriv('aes-128-ecb', key, ''); 109 | decryptedMsg = decipher.update(buffer, 'buffer', 'utf8'); 110 | decryptedMsg += decipher.final('utf8'); 111 | } catch (ex) { 112 | decryptedMsg = ''; 113 | } 114 | 115 | if (!decryptedMsg) { 116 | try { 117 | const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from('6c1ec8e2bb9bb59ab50b0daf649b410a', 'hex'), ''); 118 | decryptedMsg = decipher.update(buffer, 'buffer', 'utf8'); 119 | decryptedMsg += decipher.final('utf8'); 120 | } catch (ex) { 121 | decryptedMsg = ''; 122 | } 123 | } 124 | 125 | if (!decryptedMsg) decryptedMsg = buffer.toString('utf8'); 126 | 127 | try { 128 | JSON.parse(decryptedMsg); 129 | console.log(`${('' + seq).padEnd(4)} Decoded ${cmd}>`, decryptedMsg); 130 | if (log) console.log(`${('' + seq).padEnd(4)} Raw ${cmd}>`, raw.toString('hex')); 131 | } catch (ex) { 132 | console.log(`${('' + seq).padEnd(4)}*Failed ${cmd}>`, raw.toString('hex')); 133 | } 134 | break; 135 | 136 | default: 137 | console.log(`Unknown ${cmd}>`, raw.toString('hex')); 138 | } 139 | }; 140 | 141 | async.auto({ 142 | Key: next => { 143 | if (program.key && checkKey(program.key)) return next(null, program.key); 144 | 145 | const rl = readline.createInterface({ 146 | input: process.stdin, 147 | output: process.stdout, 148 | prompt: '\nEnter the device key: ', 149 | crlfDelay: Infinity 150 | }); 151 | 152 | rl.prompt(); 153 | 154 | rl.on('line', line => { 155 | const input = line.trim(); 156 | if (!checkKey(input)) return rl.prompt(); 157 | 158 | rl.close(); 159 | next(null, input); 160 | }); 161 | }, 162 | File: ['Key', (data, next) => { 163 | if (!file) return next(); 164 | let content; 165 | try { 166 | content = fs.readFileSync(path.resolve(file), 'utf8'); 167 | } catch (ex) { 168 | console.error('Filed to read the file'); 169 | console.log(ex); 170 | return next(true); 171 | } 172 | 173 | const packets = YAML.parseDocument(content); 174 | if (Array.isArray(packets.errors) && packets.errors.length > 0) { 175 | packets.errors.forEach(console.error); 176 | return next(true); 177 | } 178 | 179 | const rows = packets.toJSON(); 180 | 181 | Object.keys(rows).forEach(key => { 182 | decodeLine(data.Key, rows[key].replace(/\n/g, ''), false); 183 | }); 184 | 185 | next(); 186 | }], 187 | Line: ['File', (data, next) => { 188 | if (file) return next(); 189 | 190 | console.log('\n\n*** Hit Ctrl+C or key in "exit" to end ***'); 191 | 192 | const rl = readline.createInterface({ 193 | input: process.stdin, 194 | output: process.stdout, 195 | prompt: '\nEnter the encrypted message: ', 196 | crlfDelay: Infinity 197 | }); 198 | 199 | rl.prompt(); 200 | 201 | rl.on('line', line => { 202 | const input = line.trim(); 203 | if (input.toLowerCase() === 'exit') process.exit(0); 204 | 205 | decodeLine(data.Key, input); 206 | 207 | rl.prompt(); 208 | }).on('close', () => { 209 | process.exit(0); 210 | }); 211 | }] 212 | }); 213 | 214 | -------------------------------------------------------------------------------- /bin/cli-find.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const Proxy = require('http-mitm-proxy'); 4 | const EventEmitter = require('events'); 5 | const program = require('commander'); 6 | const QRCode = require('qrcode'); 7 | const path = require('path'); 8 | const os = require('os'); 9 | const JSON5 = require('json5'); 10 | const fs = require('fs-extra'); 11 | 12 | // Disable debug messages from the proxy 13 | try { 14 | require('debug').disable(); 15 | } catch(ex) {} 16 | 17 | const ROOT = path.resolve(__dirname); 18 | 19 | const pemFile = path.join(ROOT, 'certs', 'ca.pem'); 20 | 21 | let localIPs = []; 22 | const ifaces = os.networkInterfaces(); 23 | Object.keys(ifaces).forEach(name => { 24 | ifaces[name].forEach(network => { 25 | if (network.family === 'IPv4' && !network.internal) localIPs.push(network.address); 26 | }); 27 | }); 28 | 29 | const proxy = Proxy(); 30 | const emitter = new EventEmitter(); 31 | 32 | program 33 | .name('tuya-lan find') 34 | .option('--ip ', 'IP address to listen for requests') 35 | .option('-p, --port ', 'port the proxy should listen on', 8080) 36 | .option('--schema', 'include schema in the output') 37 | .parse(process.argv); 38 | 39 | 40 | if (program.ip) { 41 | if (localIPs.includes(program.ip)) localIPs = [program.ip]; 42 | else { 43 | console.log(`The requested IP, ${program.ip}, is not a valid external IPv4 address. The valid options are:\n\t${localIPs.join('\n\t')}`); 44 | process.exit(); 45 | } 46 | } 47 | if (localIPs.length > 1) { 48 | console.log(`You have multiple network interfaces: ${localIPs.join(', ')}\nChoose one by passing it with the --ip parameter.\n\nExample: tuya-lan-find --ip ${localIPs[0]}`); 49 | process.exit(); 50 | } 51 | const localIPPorts = localIPs.map(ip => `${ip}:${program.port}`); 52 | 53 | const escapeUnicode = str => str.replace(/[\u00A0-\uffff]/gu, c => "\\u" + ("000" + c.charCodeAt().toString(16)).slice(-4)); 54 | 55 | proxy.onError(function(ctx, err) { 56 | switch (err.code) { 57 | case 'ERR_STREAM_DESTROYED': 58 | case 'ECONNRESET': 59 | return; 60 | 61 | case 'ECONNREFUSED': 62 | console.error('Failed to intercept secure communications. This could happen due to bad CA certificate.'); 63 | return; 64 | 65 | case 'EACCES': 66 | console.error(`Permission was denied to use port ${program.port}.`); 67 | return; 68 | 69 | default: 70 | console.error('Error:', err.code, err); 71 | } 72 | }); 73 | 74 | proxy.onRequest(function(ctx, callback) { 75 | if (ctx.clientToProxyRequest.method === 'GET' && ctx.clientToProxyRequest.url === '/cert' && localIPPorts.includes(ctx.clientToProxyRequest.headers.host)) { 76 | ctx.use(Proxy.gunzip); 77 | console.log('Intercepted certificate request'); 78 | 79 | ctx.proxyToClientResponse.writeHeader(200, { 80 | 'Accept-Ranges': 'bytes', 81 | 'Cache-Control': 'public, max-age=0', 82 | 'Content-Type': 'application/x-x509-ca-cert', 83 | 'Content-Disposition': 'attachment; filename=cert.pem', 84 | 'Content-Transfer-Encoding': 'binary', 85 | 'Content-Length': fs.statSync(pemFile).size, 86 | 'Connection': 'keep-alive', 87 | }); 88 | //ctx.proxyToClientResponse.end(fs.readFileSync(path.join(ROOT, 'certs', 'ca.pem'))); 89 | ctx.proxyToClientResponse.write(fs.readFileSync(pemFile)); 90 | ctx.proxyToClientResponse.end(); 91 | 92 | return; 93 | 94 | } else if (ctx.clientToProxyRequest.method === 'POST' && /tuya/.test(ctx.clientToProxyRequest.headers.host)) { 95 | ctx.use(Proxy.gunzip); 96 | 97 | ctx.onRequestData(function(ctx, chunk, callback) { 98 | return callback(null, chunk); 99 | }); 100 | ctx.onRequestEnd(function(ctx, callback) { 101 | callback(); 102 | }); 103 | 104 | let chunks = []; 105 | ctx.onResponseData(function(ctx, chunk, callback) { 106 | chunks.push(chunk); 107 | return callback(null, chunk); 108 | }); 109 | ctx.onResponseEnd(function(ctx, callback) { 110 | emitter.emit('tuya-config', Buffer.concat(chunks).toString()); 111 | callback(); 112 | }); 113 | } 114 | 115 | return callback(); 116 | }); 117 | 118 | emitter.on('tuya-config', body => { 119 | if (body.indexOf('tuya.m.my.group.device.list') === -1) return; 120 | console.log('Intercepted config from Tuya'); 121 | let data; 122 | const fail = (msg, err) => { 123 | console.error(msg, err); 124 | process.exit(1); 125 | }; 126 | try { 127 | data = JSON.parse(body); 128 | } catch (ex) { 129 | return fail('There was a problem decoding config:', ex); 130 | } 131 | if (!Array.isArray(data.result)) return fail('Couldn\'t find a valid result-set.'); 132 | 133 | let devices = []; 134 | data.result.some(data => { 135 | if (data && data.a === 'tuya.m.my.group.device.list') { 136 | devices = data.result; 137 | return true; 138 | } 139 | return false; 140 | }); 141 | 142 | if (!Array.isArray(devices)) return fail('Couldn\'t find a good list of devices.'); 143 | 144 | console.log(`\nFound ${devices.length} device${devices.length === 1 ? '' : 's'}:`); 145 | 146 | const foundDevices = devices.map(device => { 147 | return { 148 | name: device.name, 149 | id: device.devId, 150 | key: device.localKey, 151 | pid: device.productId 152 | } 153 | }); 154 | 155 | if (program.schema) { 156 | let schemas = []; 157 | data.result.some(data => { 158 | if (data && data.a === 'tuya.m.device.ref.info.my.list') { 159 | schemas = data.result; 160 | return true; 161 | } 162 | return false; 163 | }); 164 | 165 | if (Array.isArray(schemas)) { 166 | const defs = {}; 167 | schemas.forEach(schema => { 168 | if (schema.id && schema.schemaInfo) { 169 | defs[schema.id] = {}; 170 | if (schema.schemaInfo.schema) defs[schema.id].schema = escapeUnicode(schema.schemaInfo.schema); 171 | if (schema.schemaInfo.schemaExt && schema.schemaInfo.schemaExt !== '[]') defs[schema.id].extras = escapeUnicode(schema.schemaInfo.schemaExt); 172 | } 173 | }); 174 | foundDevices.forEach(device => { 175 | if (defs[device.pid]) device.def = defs[device.pid]; 176 | }); 177 | } else console.log('Didn\'t find schema definitions. You will need to identify the data-points manually if this is a new device.'); 178 | } 179 | 180 | foundDevices.forEach(device => { 181 | delete device.pid; 182 | }); 183 | 184 | console.log(JSON5.stringify(foundDevices, '\n', 2)); 185 | 186 | setTimeout(() => { 187 | process.exit(0); 188 | }, 5000); 189 | }); 190 | 191 | proxy.listen({port: program.port, sslCaDir: ROOT}, err => { 192 | if (err) { 193 | console.error('Error starting proxy: ' + err); 194 | return setTimeout(() => { 195 | process.exit(0); 196 | }, 5000); 197 | } 198 | let {address, port} = proxy.httpServer.address(); 199 | if (address === '::' || address === '0.0.0.0') address = localIPs[0]; 200 | 201 | QRCode.toString(`http://${address}:${port}/cert`, {type: 'terminal'}, function(err, url) { 202 | console.log(url); 203 | console.log('\nFollow the instructions on https://github.com/AMoo-Miki/homebridge-tuya-lan/wiki/Setup-Instructions'); 204 | console.log(`Proxy IP: ${address}`); 205 | console.log(`Proxy Port: ${port}\n\n`); 206 | }) 207 | }); -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const program = require('commander'); 4 | const path = require('path'); 5 | const fs = require('fs-extra'); 6 | 7 | const ROOT = path.resolve(__dirname); 8 | 9 | program 10 | .version('v' + fs.readJSONSync(path.join(ROOT, '../package.json')).version, '-v, --version', 'output package version') 11 | .command('decode', 'decode a file or packet') 12 | .command('find', 'find device id and key combinations', {isDefault: true}) 13 | .parse(process.argv); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const TuyaAccessory = require('./lib/TuyaAccessory'); 2 | const TuyaDiscovery = require('./lib/TuyaDiscovery'); 3 | 4 | const OutletAccessory = require('./lib/OutletAccessory'); 5 | const SimpleLightAccessory = require('./lib/SimpleLightAccessory'); 6 | const MultiOutletAccessory = require('./lib/MultiOutletAccessory'); 7 | const CustomMultiOutletAccessory = require('./lib/CustomMultiOutletAccessory'); 8 | const RGBTWLightAccessory = require('./lib/RGBTWLightAccessory'); 9 | const RGBTWOutletAccessory = require('./lib/RGBTWOutletAccessory'); 10 | const TWLightAccessory = require('./lib/TWLightAccessory'); 11 | const AirConditionerAccessory = require('./lib/AirConditionerAccessory'); 12 | const ConvectorAccessory = require('./lib/ConvectorAccessory'); 13 | const GarageDoorAccessory = require('./lib/GarageDoorAccessory'); 14 | const SimpleDimmerAccessory = require('./lib/SimpleDimmerAccessory'); 15 | const SimpleBlindsAccessory = require('./lib/SimpleBlindsAccessory'); 16 | const SimpleHeaterAccessory = require('./lib/SimpleHeaterAccessory'); 17 | const ContactSensorAccessory = require('./lib/ContactSensorAccessory'); 18 | 19 | const PLUGIN_NAME = 'homebridge-tuya-lan'; 20 | const PLATFORM_NAME = 'TuyaLan'; 21 | 22 | const CLASS_DEF = { 23 | outlet: OutletAccessory, 24 | simplelight: SimpleLightAccessory, 25 | rgbtwlight: RGBTWLightAccessory, 26 | rgbtwoutlet: RGBTWOutletAccessory, 27 | twlight: TWLightAccessory, 28 | multioutlet: MultiOutletAccessory, 29 | custommultioutlet: CustomMultiOutletAccessory, 30 | airconditioner: AirConditionerAccessory, 31 | convector: ConvectorAccessory, 32 | garagedoor: GarageDoorAccessory, 33 | simpledimmer: SimpleDimmerAccessory, 34 | simpleblinds: SimpleBlindsAccessory, 35 | simpleheater: SimpleHeaterAccessory, 36 | contactsensor: ContactSensorAccessory 37 | }; 38 | 39 | let Characteristic, PlatformAccessory, Service, Categories, UUID; 40 | 41 | module.exports = function(homebridge) { 42 | ({ 43 | platformAccessory: PlatformAccessory, 44 | hap: {Characteristic, Service, Accessory: {Categories}, uuid: UUID} 45 | } = homebridge); 46 | 47 | homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, TuyaLan, true); 48 | }; 49 | 50 | class TuyaLan { 51 | constructor(...props) { 52 | [this.log, this.config, this.api] = [...props]; 53 | 54 | this.cachedAccessories = new Map(); 55 | this.api.hap.EnergyCharacteristics = require('./lib/EnergyCharacteristics')(this.api.hap.Characteristic); 56 | 57 | this._expectedUUIDs = this.config.devices.map(device => UUID.generate(PLUGIN_NAME +(device.fake ? ':fake:' : ':') + device.id)); 58 | 59 | this.api.on('didFinishLaunching', () => { 60 | this.discoverDevices(); 61 | }); 62 | } 63 | 64 | discoverDevices() { 65 | const devices = {}; 66 | const connectedDevices = []; 67 | const fakeDevices = []; 68 | this.config.devices.forEach(device => { 69 | try { 70 | device.id = ('' + device.id).trim(); 71 | device.key = ('' + device.key).trim(); 72 | device.type = ('' + device.type).trim(); 73 | 74 | device.ip = ('' + (device.ip || '')).trim(); 75 | } catch(ex) {} 76 | 77 | //if (!/^[0-9a-f]+$/i.test(device.id)) return this.log.error('%s, id for %s, is not a valid id.', device.id, device.name || 'unnamed device'); 78 | if (!/^[0-9a-f]+$/i.test(device.key)) return this.log.error('%s, key for %s (%s), is not a valid key.', device.key.replace(/.{4}$/, '****'), device.name || 'unnamed device', device.id); 79 | if (!{16:1, 24:1, 32: 1}[device.key.length]) return this.log.error('%s, key for %s (%s), doesn\'t have the expected length.', device.key.replace(/.{4}$/, '****'), device.name || 'unnamed device', device.id); 80 | if (!device.type) return this.log.error('%s (%s) doesn\'t have a type defined.', device.name || 'Unnamed device', device.id); 81 | if (!CLASS_DEF[device.type.toLowerCase()]) return this.log.error('%s (%s) doesn\'t have a valid type defined.', device.name || 'Unnamed device', device.id); 82 | 83 | if (device.fake) fakeDevices.push({name: device.id.slice(8), ...device}); 84 | else devices[device.id] = {name: device.id.slice(8), ...device}; 85 | }); 86 | 87 | const deviceIds = Object.keys(devices); 88 | if (deviceIds.length === 0) return this.log.error('No valid configured devices found.'); 89 | 90 | this.log.info('Starting discovery...'); 91 | 92 | TuyaDiscovery.start({ids: deviceIds}) 93 | .on('discover', config => { 94 | if (!config || !config.id) return; 95 | if (!devices[config.id]) return this.log.warn('Discovered a device that has not been configured yet (%s@%s).', config.id, config.ip); 96 | 97 | connectedDevices.push(config.id); 98 | 99 | this.log.info('Discovered %s (%s) identified as %s (%s)', devices[config.id].name, config.id, devices[config.id].type, config.version); 100 | 101 | const device = new TuyaAccessory({ 102 | ...devices[config.id], ...config, 103 | UUID: UUID.generate(PLUGIN_NAME + ':' + config.id), 104 | connect: false 105 | }); 106 | this.addAccessory(device); 107 | }); 108 | 109 | fakeDevices.forEach(config => { 110 | this.log.info('Adding fake device: %s', config.name); 111 | this.addAccessory(new TuyaAccessory({ 112 | ...config, 113 | UUID: UUID.generate(PLUGIN_NAME + ':fake:' + config.id), 114 | connect: false 115 | })); 116 | }); 117 | 118 | setTimeout(() => { 119 | deviceIds.forEach(deviceId => { 120 | if (connectedDevices.includes(deviceId)) return; 121 | 122 | if (devices[deviceId].ip) { 123 | 124 | this.log.info('Failed to discover %s (%s) in time but will connect via %s.', devices[deviceId].name, deviceId, devices[deviceId].ip); 125 | 126 | const device = new TuyaAccessory({ 127 | ...devices[deviceId], 128 | UUID: UUID.generate(PLUGIN_NAME + ':' + deviceId), 129 | connect: false 130 | }); 131 | this.addAccessory(device); 132 | } else { 133 | this.log.warn('Failed to discover %s (%s) in time but will keep looking.', devices[deviceId].name, deviceId); 134 | } 135 | }); 136 | }, 60000); 137 | } 138 | 139 | registerPlatformAccessories(platformAccessories) { 140 | this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, Array.isArray(platformAccessories) ? platformAccessories : [platformAccessories]); 141 | } 142 | 143 | configureAccessory(accessory) { 144 | if (accessory instanceof PlatformAccessory && this._expectedUUIDs.includes(accessory.UUID)) { 145 | this.cachedAccessories.set(accessory.UUID, accessory); 146 | accessory.services.forEach(service => { 147 | if (service.UUID === Service.AccessoryInformation.UUID) return; 148 | service.characteristics.some(characteristic => { 149 | if (!characteristic.props || 150 | !Array.isArray(characteristic.props.perms) || 151 | characteristic.props.perms.length !== 3 || 152 | !(characteristic.props.perms.includes(Characteristic.Perms.WRITE) && characteristic.props.perms.includes(Characteristic.Perms.NOTIFY)) 153 | ) return; 154 | 155 | this.log.info('Marked %s unreachable by faulting Service.%s.%s', accessory.displayName, service.displayName, characteristic.displayName); 156 | 157 | characteristic.updateValue(new Error('Unreachable')); 158 | return true; 159 | }); 160 | }); 161 | } else { 162 | /* 163 | * Irrespective of this unregistering, Homebridge continues 164 | * to "_prepareAssociatedHAPAccessory" and "addBridgedAccessory". 165 | * This timeout will hopefully remove the accessory after that has happened. 166 | */ 167 | setTimeout(() => { 168 | this.removeAccessory(accessory); 169 | }, 1000); 170 | } 171 | } 172 | 173 | addAccessory(device) { 174 | const deviceConfig = device.context; 175 | const type = (deviceConfig.type || '').toLowerCase(); 176 | 177 | const Accessory = CLASS_DEF[type]; 178 | 179 | let accessory = this.cachedAccessories.get(deviceConfig.UUID), 180 | isCached = true; 181 | 182 | if (accessory && accessory.category !== Accessory.getCategory(Categories)) { 183 | this.log.info("%s has a different type (%s vs %s)", accessory.displayName, accessory.category, Accessory.getCategory(Categories)); 184 | this.removeAccessory(accessory); 185 | accessory = null; 186 | } 187 | 188 | if (!accessory) { 189 | accessory = new PlatformAccessory(deviceConfig.name, deviceConfig.UUID, Accessory.getCategory(Categories)); 190 | accessory.getService(Service.AccessoryInformation) 191 | .setCharacteristic(Characteristic.Manufacturer, (PLATFORM_NAME + ' ' + deviceConfig.manufacturer).trim()) 192 | .setCharacteristic(Characteristic.Model, deviceConfig.model || "Unknown") 193 | .setCharacteristic(Characteristic.SerialNumber, deviceConfig.id.slice(8)); 194 | 195 | isCached = false; 196 | } 197 | 198 | this.cachedAccessories.set(deviceConfig.UUID, new Accessory(this, accessory, device, !isCached)); 199 | } 200 | 201 | removeAccessory(homebridgeAccessory) { 202 | if (!homebridgeAccessory) return; 203 | 204 | this.log.warn('Unregistering', homebridgeAccessory.displayName); 205 | 206 | delete this.cachedAccessories[homebridgeAccessory.UUID]; 207 | this.api.unregisterPlatformAccessories(PLATFORM_NAME, PLATFORM_NAME, [homebridgeAccessory]); 208 | } 209 | 210 | removeAccessoryByUUID(uuid) { 211 | if (uuid) this.removeAccessory(this.cachedAccessories.get(uuid)); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /lib/AirConditionerAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | 3 | const STATE_OTHER = 9; 4 | 5 | class AirConditionerAccessory extends BaseAccessory { 6 | static getCategory(Categories) { 7 | return Categories.AIR_CONDITIONER; 8 | } 9 | 10 | constructor(...props) { 11 | super(...props); 12 | 13 | this.cmdCool = 'COOL'; 14 | if (this.device.context.cmdCool) { 15 | if (/^c[a-z]+$/i.test(this.device.context.cmdCool)) this.cmdCool = ('' + this.device.context.cmdCool).trim(); 16 | else throw new Error('The cmdCool doesn\'t appear to be valid: ' + this.device.context.cmdCool); 17 | } 18 | 19 | this.cmdHeat = 'HEAT'; 20 | if (this.device.context.cmdHeat) { 21 | if (/^h[a-z]+$/i.test(this.device.context.cmdHeat)) this.cmdHeat = ('' + this.device.context.cmdHeat).trim(); 22 | else throw new Error('The cmdHeat doesn\'t appear to be valid: ' + this.device.context.cmdHeat); 23 | } 24 | 25 | this.cmdAuto = 'AUTO'; 26 | if (this.device.context.cmdAuto) { 27 | if (/^a[a-z]+$/i.test(this.device.context.cmdAuto)) this.cmdAuto = ('' + this.device.context.cmdAuto).trim(); 28 | else throw new Error('The cmdAuto doesn\'t appear to be valid: ' + this.device.context.cmdAuto); 29 | } 30 | 31 | // Disabling auto mode because I have not found a Tuya device config that has a temperature range for AUTO 32 | this.device.context.noAuto = true; 33 | 34 | if (!this.device.context.noRotationSpeed) { 35 | const fanSpeedSteps = (this.device.context.fanSpeedSteps && isFinite(this.device.context.fanSpeedSteps) && this.device.context.fanSpeedSteps > 0 && this.device.context.fanSpeedSteps < 100) ? this.device.context.fanSpeedSteps : 100; 36 | this._rotationSteps = [0]; 37 | this._rotationStops = {0: 0}; 38 | for (let i = 0; i++ < 100;) { 39 | const _rotationStep = Math.floor(fanSpeedSteps * (i - 1) / 100) + 1; 40 | this._rotationSteps.push(_rotationStep); 41 | this._rotationStops[_rotationStep] = i; 42 | } 43 | } 44 | } 45 | 46 | _registerPlatformAccessory() { 47 | const {Service} = this.hap; 48 | 49 | this.accessory.addService(Service.HeaterCooler, this.device.context.name); 50 | 51 | super._registerPlatformAccessory(); 52 | } 53 | 54 | _registerCharacteristics(dps) { 55 | const {Service, Characteristic} = this.hap; 56 | const service = this.accessory.getService(Service.HeaterCooler); 57 | this._checkServiceName(service, this.device.context.name); 58 | 59 | const characteristicActive = service.getCharacteristic(Characteristic.Active) 60 | .updateValue(this._getActive(dps['1'])) 61 | .on('get', this.getActive.bind(this)) 62 | .on('set', this.setActive.bind(this)); 63 | 64 | const characteristicCurrentHeaterCoolerState = service.getCharacteristic(Characteristic.CurrentHeaterCoolerState) 65 | .updateValue(this._getCurrentHeaterCoolerState(dps)) 66 | .on('get', this.getCurrentHeaterCoolerState.bind(this)); 67 | 68 | const _validTargetHeaterCoolerStateValues = [STATE_OTHER]; 69 | if (!this.device.context.noCool) _validTargetHeaterCoolerStateValues.unshift(Characteristic.TargetHeaterCoolerState.COOL); 70 | if (!this.device.context.noHeat) _validTargetHeaterCoolerStateValues.unshift(Characteristic.TargetHeaterCoolerState.HEAT); 71 | if (!this.device.context.noAuto) _validTargetHeaterCoolerStateValues.unshift(Characteristic.TargetHeaterCoolerState.AUTO); 72 | 73 | const characteristicTargetHeaterCoolerState = service.getCharacteristic(Characteristic.TargetHeaterCoolerState) 74 | .setProps({ 75 | maxValue: 9, 76 | validValues: _validTargetHeaterCoolerStateValues 77 | }) 78 | .updateValue(this._getTargetHeaterCoolerState(dps['4'])) 79 | .on('get', this.getTargetHeaterCoolerState.bind(this)) 80 | .on('set', this.setTargetHeaterCoolerState.bind(this)); 81 | 82 | const characteristicCurrentTemperature = service.getCharacteristic(Characteristic.CurrentTemperature) 83 | .updateValue(dps['3']) 84 | .on('get', this.getState.bind(this, '3')); 85 | 86 | let characteristicSwingMode; 87 | if (!this.device.context.noSwing) { 88 | characteristicSwingMode = service.getCharacteristic(Characteristic.SwingMode) 89 | .updateValue(this._getSwingMode(dps['104'])) 90 | .on('get', this.getSwingMode.bind(this)) 91 | .on('set', this.setSwingMode.bind(this)); 92 | } else this._removeCharacteristic(service, Characteristic.SwingMode); 93 | 94 | let characteristicLockPhysicalControls; 95 | if (!this.device.context.noChildLock) { 96 | characteristicLockPhysicalControls = service.getCharacteristic(Characteristic.LockPhysicalControls) 97 | .updateValue(this._getLockPhysicalControls(dps['6'])) 98 | .on('get', this.getLockPhysicalControls.bind(this)) 99 | .on('set', this.setLockPhysicalControls.bind(this)); 100 | } else this._removeCharacteristic(service, Characteristic.LockPhysicalControls); 101 | 102 | let characteristicCoolingThresholdTemperature; 103 | if (!this.device.context.noCool) { 104 | characteristicCoolingThresholdTemperature = service.getCharacteristic(Characteristic.CoolingThresholdTemperature) 105 | .setProps({ 106 | minValue: this.device.context.minTemperature || 10, 107 | maxValue: this.device.context.maxTemperature || 35, 108 | minStep: this.device.context.minTemperatureSteps || 1 109 | }) 110 | .updateValue(dps['2']) 111 | .on('get', this.getState.bind(this, '2')) 112 | .on('set', this.setTargetThresholdTemperature.bind(this, 'cool')); 113 | } else this._removeCharacteristic(service, Characteristic.CoolingThresholdTemperature); 114 | 115 | let characteristicHeatingThresholdTemperature; 116 | if (!this.device.context.noHeat) { 117 | characteristicHeatingThresholdTemperature = service.getCharacteristic(Characteristic.HeatingThresholdTemperature) 118 | .setProps({ 119 | minValue: this.device.context.minTemperature || 10, 120 | maxValue: this.device.context.maxTemperature || 35, 121 | minStep: this.device.context.minTemperatureSteps || 1 122 | }) 123 | .updateValue(dps['2']) 124 | .on('get', this.getState.bind(this, '2')) 125 | .on('set', this.setTargetThresholdTemperature.bind(this, 'heat')); 126 | } else this._removeCharacteristic(service, Characteristic.HeatingThresholdTemperature); 127 | 128 | const characteristicTemperatureDisplayUnits = service.getCharacteristic(Characteristic.TemperatureDisplayUnits) 129 | .updateValue(this._getTemperatureDisplayUnits(dps['19'])) 130 | .on('get', this.getTemperatureDisplayUnits.bind(this)) 131 | .on('set', this.setTemperatureDisplayUnits.bind(this)); 132 | 133 | let characteristicRotationSpeed; 134 | if (!this.device.context.noRotationSpeed) { 135 | characteristicRotationSpeed = service.getCharacteristic(Characteristic.RotationSpeed) 136 | .updateValue(this._getRotationSpeed(dps)) 137 | .on('get', this.getRotationSpeed.bind(this)) 138 | .on('set', this.setRotationSpeed.bind(this)); 139 | } else this._removeCharacteristic(service, Characteristic.RotationSpeed); 140 | 141 | this.characteristicCoolingThresholdTemperature = characteristicCoolingThresholdTemperature; 142 | this.characteristicHeatingThresholdTemperature = characteristicHeatingThresholdTemperature; 143 | 144 | this.device.on('change', (changes, state) => { 145 | if (changes.hasOwnProperty('1')) { 146 | const newActive = this._getActive(changes['1']); 147 | if (characteristicActive.value !== newActive) { 148 | characteristicActive.updateValue(newActive); 149 | 150 | if (!changes.hasOwnProperty('4')) { 151 | characteristicCurrentHeaterCoolerState.updateValue(this._getCurrentHeaterCoolerState(state)); 152 | } 153 | 154 | if (!changes.hasOwnProperty('5')) { 155 | characteristicRotationSpeed.updateValue(this._getRotationSpeed(state)); 156 | } 157 | } 158 | } 159 | 160 | if (characteristicLockPhysicalControls && changes.hasOwnProperty('6')) { 161 | const newLockPhysicalControls = this._getLockPhysicalControls(changes['6']); 162 | if (characteristicLockPhysicalControls.value !== newLockPhysicalControls) { 163 | characteristicLockPhysicalControls.updateValue(newLockPhysicalControls); 164 | } 165 | } 166 | 167 | if (changes.hasOwnProperty('2')) { 168 | if (!this.device.context.noCool && characteristicCoolingThresholdTemperature && characteristicCoolingThresholdTemperature.value !== changes['2']) 169 | characteristicCoolingThresholdTemperature.updateValue(changes['2']); 170 | if (!this.device.context.noHeat && characteristicHeatingThresholdTemperature && characteristicHeatingThresholdTemperature.value !== changes['2']) 171 | characteristicHeatingThresholdTemperature.updateValue(changes['2']); 172 | } 173 | 174 | if (changes.hasOwnProperty('3') && characteristicCurrentTemperature.value !== changes['3']) characteristicCurrentTemperature.updateValue(changes['3']); 175 | 176 | if (changes.hasOwnProperty('4')) { 177 | const newTargetHeaterCoolerState = this._getTargetHeaterCoolerState(changes['4']); 178 | const newCurrentHeaterCoolerState = this._getCurrentHeaterCoolerState(state); 179 | if (characteristicTargetHeaterCoolerState.value !== newTargetHeaterCoolerState) characteristicTargetHeaterCoolerState.updateValue(newTargetHeaterCoolerState); 180 | if (characteristicCurrentHeaterCoolerState.value !== newCurrentHeaterCoolerState) characteristicCurrentHeaterCoolerState.updateValue(newCurrentHeaterCoolerState); 181 | } 182 | 183 | if (changes.hasOwnProperty('104')) { 184 | const newSwingMode = this._getSwingMode(changes['104']); 185 | if (characteristicSwingMode.value !== newSwingMode) characteristicSwingMode.updateValue(newSwingMode); 186 | } 187 | 188 | if (changes.hasOwnProperty('19')) { 189 | const newTemperatureDisplayUnits = this._getTemperatureDisplayUnits(changes['19']); 190 | if (characteristicTemperatureDisplayUnits.value !== newTemperatureDisplayUnits) characteristicTemperatureDisplayUnits.updateValue(newTemperatureDisplayUnits); 191 | } 192 | 193 | if (changes.hasOwnProperty('5')) { 194 | const newRotationSpeed = this._getRotationSpeed(state); 195 | if (characteristicRotationSpeed.value !== newRotationSpeed) characteristicRotationSpeed.updateValue(newRotationSpeed); 196 | 197 | if (!changes.hasOwnProperty('4')) { 198 | characteristicCurrentHeaterCoolerState.updateValue(this._getCurrentHeaterCoolerState(state)); 199 | } 200 | } 201 | }); 202 | } 203 | 204 | getActive(callback) { 205 | this.getState('1', (err, dp) => { 206 | if (err) return callback(err); 207 | 208 | callback(null, this._getActive(dp)); 209 | }); 210 | } 211 | 212 | _getActive(dp) { 213 | const {Characteristic} = this.hap; 214 | 215 | return dp ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE; 216 | } 217 | 218 | setActive(value, callback) { 219 | const {Characteristic} = this.hap; 220 | 221 | switch (value) { 222 | case Characteristic.Active.ACTIVE: 223 | return this.setState('1', true, callback); 224 | 225 | case Characteristic.Active.INACTIVE: 226 | return this.setState('1', false, callback); 227 | } 228 | 229 | callback(); 230 | } 231 | 232 | getLockPhysicalControls(callback) { 233 | this.getState('6', (err, dp) => { 234 | if (err) return callback(err); 235 | 236 | callback(null, this._getLockPhysicalControls(dp)); 237 | }); 238 | } 239 | 240 | _getLockPhysicalControls(dp) { 241 | const {Characteristic} = this.hap; 242 | 243 | return dp ? Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED : Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED; 244 | } 245 | 246 | setLockPhysicalControls(value, callback) { 247 | const {Characteristic} = this.hap; 248 | 249 | switch (value) { 250 | case Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED: 251 | return this.setState('6', true, callback); 252 | 253 | case Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED: 254 | return this.setState('6', false, callback); 255 | } 256 | 257 | callback(); 258 | } 259 | 260 | getCurrentHeaterCoolerState(callback) { 261 | this.getState(['1', '4'], (err, dps) => { 262 | if (err) return callback(err); 263 | 264 | callback(null, this._getCurrentHeaterCoolerState(dps)); 265 | }); 266 | } 267 | 268 | _getCurrentHeaterCoolerState(dps) { 269 | const {Characteristic} = this.hap; 270 | if (!dps['1']) return Characteristic.CurrentHeaterCoolerState.INACTIVE; 271 | 272 | switch (dps['4']) { 273 | case this.cmdCool: 274 | return Characteristic.CurrentHeaterCoolerState.COOLING; 275 | 276 | case this.cmdHeat: 277 | return Characteristic.CurrentHeaterCoolerState.HEATING; 278 | 279 | default: 280 | return Characteristic.CurrentHeaterCoolerState.IDLE; 281 | } 282 | } 283 | 284 | getTargetHeaterCoolerState(callback) { 285 | this.getState('4', (err, dp) => { 286 | if (err) return callback(err); 287 | 288 | callback(null, this._getTargetHeaterCoolerState(dp)); 289 | }); 290 | } 291 | 292 | _getTargetHeaterCoolerState(dp) { 293 | const {Characteristic} = this.hap; 294 | 295 | switch (dp) { 296 | case this.cmdCool: 297 | if (this.device.context.noCool) return STATE_OTHER; 298 | return Characteristic.TargetHeaterCoolerState.COOL; 299 | 300 | case this.cmdHeat: 301 | if (this.device.context.noHeat) return STATE_OTHER; 302 | return Characteristic.TargetHeaterCoolerState.HEAT; 303 | 304 | case this.cmdAuto: 305 | if (this.device.context.noAuto) return STATE_OTHER; 306 | return Characteristic.TargetHeaterCoolerState.AUTO; 307 | 308 | default: 309 | return STATE_OTHER; 310 | } 311 | } 312 | 313 | setTargetHeaterCoolerState(value, callback) { 314 | const {Characteristic} = this.hap; 315 | 316 | switch (value) { 317 | case Characteristic.TargetHeaterCoolerState.COOL: 318 | if (this.device.context.noCool) return callback(); 319 | return this.setState('4', this.cmdCool, callback); 320 | 321 | case Characteristic.TargetHeaterCoolerState.HEAT: 322 | if (this.device.context.noHeat) return callback(); 323 | return this.setState('4', this.cmdHeat, callback); 324 | 325 | case Characteristic.TargetHeaterCoolerState.AUTO: 326 | if (this.device.context.noAuto) return callback(); 327 | return this.setState('4', this.cmdAuto, callback); 328 | } 329 | 330 | callback(); 331 | } 332 | 333 | getSwingMode(callback) { 334 | this.getState('104', (err, dp) => { 335 | if (err) return callback(err); 336 | 337 | callback(null, this._getSwingMode(dp)); 338 | }); 339 | } 340 | 341 | _getSwingMode(dp) { 342 | const {Characteristic} = this.hap; 343 | 344 | return dp ? Characteristic.SwingMode.SWING_ENABLED : Characteristic.SwingMode.SWING_DISABLED; 345 | } 346 | 347 | setSwingMode(value, callback) { 348 | if (this.device.context.noSwing) return callback(); 349 | 350 | const {Characteristic} = this.hap; 351 | 352 | switch (value) { 353 | case Characteristic.SwingMode.SWING_ENABLED: 354 | return this.setState('104', true, callback); 355 | 356 | case Characteristic.SwingMode.SWING_DISABLED: 357 | return this.setState('104', false, callback); 358 | } 359 | 360 | callback(); 361 | } 362 | 363 | setTargetThresholdTemperature(mode, value, callback) { 364 | this.setState('2', value, err => { 365 | if (err) return callback(err); 366 | 367 | if (mode === 'cool' && !this.device.context.noHeat && this.characteristicHeatingThresholdTemperature) { 368 | this.characteristicHeatingThresholdTemperature.updateValue(value); 369 | } else if (mode === 'heat' && !this.device.context.noCool && this.characteristicCoolingThresholdTemperature) { 370 | this.characteristicCoolingThresholdTemperature.updateValue(value); 371 | } 372 | 373 | callback(); 374 | }); 375 | } 376 | 377 | getTemperatureDisplayUnits(callback) { 378 | this.getState('19', (err, dp) => { 379 | if (err) return callback(err); 380 | 381 | callback(null, this._getTemperatureDisplayUnits(dp)); 382 | }); 383 | } 384 | 385 | _getTemperatureDisplayUnits(dp) { 386 | const {Characteristic} = this.hap; 387 | 388 | return dp === 'F' ? Characteristic.TemperatureDisplayUnits.FAHRENHEIT : Characteristic.TemperatureDisplayUnits.CELSIUS; 389 | } 390 | 391 | setTemperatureDisplayUnits(value, callback) { 392 | const {Characteristic} = this.hap; 393 | 394 | this.setState('19', value === Characteristic.TemperatureDisplayUnits.FAHRENHEIT ? 'F' : 'C', callback); 395 | } 396 | 397 | getRotationSpeed(callback) { 398 | this.getState(['1', '5'], (err, dps) => { 399 | if (err) return callback(err); 400 | 401 | callback(null, this._getRotationSpeed(dps)); 402 | }); 403 | } 404 | 405 | _getRotationSpeed(dps) { 406 | if (!dps['1']) return 0; 407 | 408 | if (this._hkRotationSpeed) { 409 | const currntRotationSpeed = this.convertRotationSpeedFromHomeKitToTuya(this._hkRotationSpeed); 410 | 411 | return currntRotationSpeed === dps['5'] ? this._hkRotationSpeed : this.convertRotationSpeedFromTuyaToHomeKit(dps['5']); 412 | } 413 | 414 | return this._hkRotationSpeed = this.convertRotationSpeedFromTuyaToHomeKit(dps['5']); 415 | } 416 | 417 | setRotationSpeed(value, callback) { 418 | const {Characteristic} = this.hap; 419 | 420 | if (value === 0) { 421 | this.setActive(Characteristic.Active.INACTIVE, callback); 422 | } else { 423 | this._hkRotationSpeed = value; 424 | this.setMultiState({'1': true, '5': this.convertRotationSpeedFromHomeKitToTuya(value)}, callback); 425 | } 426 | } 427 | 428 | convertRotationSpeedFromTuyaToHomeKit(value) { 429 | return this._rotationStops[parseInt(value)]; 430 | } 431 | 432 | convertRotationSpeedFromHomeKitToTuya(value) { 433 | return this.device.context.fanSpeedSteps ? '' + this._rotationSteps[value] : this._rotationSteps[value]; 434 | } 435 | } 436 | 437 | module.exports = AirConditionerAccessory; -------------------------------------------------------------------------------- /lib/BaseAccessory.js: -------------------------------------------------------------------------------- 1 | class BaseAccessory { 2 | constructor(...props) { 3 | let isNew; 4 | [this.platform, this.accessory, this.device, isNew = true] = [...props]; 5 | ({log: this.log, api: {hap: this.hap}} = this.platform); 6 | 7 | this.translators = {}; 8 | 9 | if (isNew) this._registerPlatformAccessory(); 10 | 11 | this.accessory.on('identify', function(paired, callback) { 12 | // ToDo: Add identification routine 13 | this.log("%s - identify", this.device.context.name); 14 | callback(); 15 | }.bind(this)); 16 | 17 | this.device.once('connect', () => { 18 | this.log('Connected to', this.device.context.name); 19 | }); 20 | 21 | this.device.once('change', () => { 22 | this.log(`Ready to handle ${this.device.context.name} (${this.device.context.type}:${this.device.context.version}) with signature ${JSON.stringify(this.device.state)}`); 23 | 24 | this._registerCharacteristics(this.device.state); 25 | }); 26 | 27 | this.device._connect(); 28 | } 29 | 30 | _registerPlatformAccessory() { 31 | this.platform.registerPlatformAccessories(this.accessory); 32 | } 33 | 34 | _checkServiceName(service, name) { 35 | const {Characteristic} = this.hap; 36 | 37 | if (service.displayName !== name) { 38 | const nameCharacteristic = service.getCharacteristic(Characteristic.Name) || service.addCharacteristic(Characteristic.Name); 39 | nameCharacteristic.setValue(name); 40 | service.displayName = name; 41 | } 42 | } 43 | 44 | _removeCharacteristic(service, characteristicType) { 45 | if (!service || !characteristicType || !characteristicType.UUID) return; 46 | 47 | service.characteristics.some(characteristic => { 48 | if (!characteristic || characteristic.UUID !== characteristicType.UUID) return false; 49 | service.removeCharacteristic(characteristic); 50 | return true; 51 | }); 52 | } 53 | 54 | _getCustomDP(numeral) { 55 | return (isFinite(numeral) && parseInt(numeral) > 0) ? String(numeral) : false; 56 | } 57 | 58 | _registerTranslators(translators) { 59 | this.translators = {...this.translators, ...translators}; 60 | delete this.translators[undefined]; 61 | delete this.translators[false]; 62 | delete this.translators[true]; 63 | } 64 | 65 | getState(dp, callback) { 66 | if (!this.device.connected) return callback(true); 67 | const _callback = () => { 68 | if (Array.isArray(dp)) { 69 | const ret = {}; 70 | dp.forEach(p => { 71 | ret[p] = this.device.state[p]; 72 | }); 73 | callback(null, ret); 74 | } else { 75 | callback(null, this.device.state[dp]); 76 | } 77 | }; 78 | 79 | process.nextTick(_callback); 80 | } 81 | 82 | setState(dp, value, callback) { 83 | this.setMultiState({[dp.toString()]: value}, callback); 84 | } 85 | 86 | setMultiState(dps, callback) { 87 | if (!this.device.connected) return callback(true); 88 | 89 | const ret = this.device.update(dps); 90 | callback && callback(!ret); 91 | } 92 | 93 | getDividedState(dp, divisor, callback) { 94 | this.getState(dp, (err, data) => { 95 | if (err) return callback(err); 96 | if (!isFinite(data)) return callback(true); 97 | 98 | callback(null, this._getDividedState(data, divisor)); 99 | }); 100 | } 101 | 102 | _getDividedState(value, divisor) { 103 | return (parseFloat(value) / divisor) || 0; 104 | } 105 | 106 | getTranslatedState(dp, callback) { 107 | this.getState(dp, (err, data) => { 108 | if (err) return callback(err); 109 | 110 | callback(null, this._getTranslatedState(dp, data)); 111 | }); 112 | } 113 | 114 | /* 0: translation of false, 0 115 | * 1: translation of true, 1 116 | * 2: transformer function, true for flipping 117 | */ 118 | _getTranslatedState(dp, data) { 119 | let transformer = this.translators[dp][2], 120 | value; 121 | 122 | if (typeof transformer === 'function') value = transformer(data); 123 | else value = data ^ transformer; 124 | 125 | return [0, 1, false, true].includes(value) ? this.translators[dp][value] : data; 126 | } 127 | 128 | _detectColorFunction(value) { 129 | this.colorFunction = this.device.context.colorFunction && {HSB: 'HSB', HEXHSB: 'HEXHSB'}[this.device.context.colorFunction.toUpperCase()]; 130 | if (!this.colorFunction && value) { 131 | this.colorFunction = {12: 'HSB', 14: 'HEXHSB'}[value.length] || 'Unknown'; 132 | if (this.colorFunction) console.log(`[TuyaAccessory] Color format for ${this.device.context.name} (${this.device.context.version}) identified as ${this.colorFunction} (length: ${value.length}).`); 133 | } 134 | if (!this.colorFunction) { 135 | this.colorFunction = 'Unknown'; 136 | console.log(`[TuyaAccessory] Color format for ${this.device.context.name} (${this.device.context.version}) is undetectable.`); 137 | } else if (this.colorFunction === 'HSB') { 138 | // If not overridden by config, use the scale of 1000 139 | if (!this.device.context.scaleBrightness) this.device.context.scaleBrightness = 1000; 140 | if (!this.device.context.scaleWhiteColor) this.device.context.scaleWhiteColor = 1000; 141 | } 142 | } 143 | 144 | convertBrightnessFromHomeKitToTuya(value) { 145 | const min = this.device.context.minBrightness || 27; 146 | const scale = this.device.context.scaleBrightness || 255; 147 | return Math.round(((scale - min) * value + 100 * min - scale) / 99); 148 | } 149 | 150 | convertBrightnessFromTuyaToHomeKit(value) { 151 | const min = this.device.context.minBrightness || 27; 152 | const scale = this.device.context.scaleBrightness || 255; 153 | return Math.round((99 * (value || 0) - 100 * min + scale) / (scale - min)); 154 | } 155 | 156 | convertColorTemperatureFromHomeKitToTuya(value) { 157 | const min = this.device.context.minWhiteColor || 140; 158 | const max = this.device.context.maxWhiteColor || 400; 159 | const scale = this.device.context.scaleWhiteColor || 255; 160 | const adjustedValue = (value - 71) * (max - min) / (600 - 71) + 153; 161 | const convertedValue = Math.round((scale * min / (max - min)) * ((max / adjustedValue) - 1)); 162 | return Math.min(scale, Math.max(0, convertedValue)); 163 | } 164 | 165 | convertColorTemperatureFromTuyaToHomeKit(value) { 166 | const min = this.device.context.minWhiteColor || 140; 167 | const max = this.device.context.maxWhiteColor || 400; 168 | const scale = this.device.context.scaleWhiteColor || 255; 169 | const unadjustedValue = max / ((value * (max - min) / (scale * min)) + 1); 170 | const convertedValue = Math.round((unadjustedValue - 153) * (600 - 71) / (max - min) + 71); 171 | return Math.min(600, Math.max(71, convertedValue)); 172 | } 173 | 174 | convertColorFromHomeKitToTuya(value, dpValue) { 175 | switch (this.device.context.colorFunction) { 176 | case 'HSB': 177 | return this.convertColorFromHomeKitToTuya_HSB(value, dpValue); 178 | 179 | default: 180 | return this.convertColorFromHomeKitToTuya_HEXHSB(value, dpValue); 181 | } 182 | } 183 | convertColorFromHomeKitToTuya_HEXHSB(value, dpValue) { 184 | const cached = this.convertColorFromTuya_HEXHSB_ToHomeKit(dpValue || this.device.state[this.dpColor]); 185 | let {h, s, b} = {...cached, ...value}; 186 | const hsb = h.toString(16).padStart(4, '0') + Math.round(2.55 * s).toString(16).padStart(2, '0') + Math.round(2.55 * b).toString(16).padStart(2, '0'); 187 | h /= 60; 188 | s /= 100; 189 | b *= 2.55; 190 | 191 | const 192 | i = Math.floor(h), 193 | f = h - i, 194 | p = b * (1 - s), 195 | q = b * (1 - s * f), 196 | t = b * (1 - s * (1 - f)), 197 | rgb = (() => { 198 | switch (i % 6) { 199 | case 0: 200 | return [b, t, p]; 201 | case 1: 202 | return [q, b, p]; 203 | case 2: 204 | return [p, b, t]; 205 | case 3: 206 | return [p, q, b]; 207 | case 4: 208 | return [t, p, b]; 209 | case 5: 210 | return [b, p, q]; 211 | } 212 | })().map(c => Math.round(c).toString(16).padStart(2, '0')), 213 | hex = rgb.join(''); 214 | 215 | return hex + hsb; 216 | } 217 | 218 | convertColorFromHomeKitToTuya_HSB(value, dpValue) { 219 | const cached = this.convertColorFromTuya_HSB_ToHomeKit(dpValue || this.device.state[this.dpColor]); 220 | let {h, s, b} = {...cached, ...value}; 221 | return h.toString(16).padStart(4, '0') + (10 * s).toString(16).padStart(4, '0') + (10 * b).toString(16).padStart(4, '0'); 222 | } 223 | 224 | convertColorFromTuyaToHomeKit(value) { 225 | switch (this.device.context.colorFunction) { 226 | case 'HSB': 227 | return this.convertColorFromTuya_HSB_ToHomeKit(value); 228 | 229 | default: 230 | return this.convertColorFromTuya_HEXHSB_ToHomeKit(value); 231 | } 232 | } 233 | 234 | convertColorFromTuya_HEXHSB_ToHomeKit(value) { 235 | const [, h, s, b] = (value || '0000000000ffff').match(/^.{6}([0-9a-f]{4})([0-9a-f]{2})([0-9a-f]{2})$/i) || [0, '0', 'ff', 'ff']; 236 | return { 237 | h: parseInt(h, 16), 238 | s: Math.round(parseInt(s, 16) / 2.55), 239 | b: Math.round(parseInt(b, 16) / 2.55) 240 | }; 241 | } 242 | 243 | convertColorFromTuya_HSB_ToHomeKit(value) { 244 | const [, h, s, b] = (value || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8']; 245 | return { 246 | h: parseInt(h, 16), 247 | s: Math.round(parseInt(s, 16) / 10), 248 | b: Math.round(parseInt(b, 16) / 10) 249 | }; 250 | } 251 | 252 | 253 | /* Based on works of: 254 | * Tanner Helland (http://www.tannerhelland.com/4435/convert-temperature-rgb-algorithm-code/) 255 | * Neil Bartlett (http://www.zombieprototypes.com/?p=210) 256 | */ 257 | 258 | convertHomeKitColorTemperatureToHomeKitColor(value) { 259 | const dKelvin = 10000 / value; 260 | const rgb = [ 261 | dKelvin > 66 ? 351.97690566805693 + 0.114206453784165 * (dKelvin - 55) - 40.25366309332127 * Math.log(dKelvin - 55) : 255, 262 | dKelvin > 66 ? 325.4494125711974 + 0.07943456536662342 * (dKelvin - 50) - 28.0852963507957 * Math.log(dKelvin - 55) : 104.49216199393888 * Math.log(dKelvin - 2) - 0.44596950469579133 * (dKelvin - 2) - 155.25485562709179, 263 | dKelvin > 66 ? 255 : 115.67994401066147 * Math.log(dKelvin - 10) + 0.8274096064007395 * (dKelvin - 10) - 254.76935184120902 264 | ].map(v => Math.max(0, Math.min(255, v)) / 255); 265 | const max = Math.max(...rgb); 266 | const min = Math.min(...rgb); 267 | let d = max - min, 268 | h = 0, 269 | s = max ? 100 * d / max : 0, 270 | b = 100 * max; 271 | 272 | if (d) { 273 | switch (max) { 274 | case rgb[0]: h = (rgb[1] - rgb[2]) / d + (rgb[1] < rgb[2] ? 6 : 0); break; 275 | case rgb[1]: h = (rgb[2] - rgb[0]) / d + 2; break; 276 | default: h = (rgb[0] - rgb[1]) / d + 4; break; 277 | } 278 | h *= 60; 279 | } 280 | return { 281 | h: Math.round(h), 282 | s: Math.round(s), 283 | b: Math.round(b) 284 | }; 285 | } 286 | } 287 | 288 | module.exports = BaseAccessory; -------------------------------------------------------------------------------- /lib/ContactSensorAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | 3 | class ContactSensorAccessory extends BaseAccessory { 4 | static getCategory(Categories) { 5 | return Categories.SENSOR; 6 | } 7 | 8 | constructor(...props) { 9 | super(...props); 10 | } 11 | 12 | _registerPlatformAccessory() { 13 | const {Service} = this.hap; 14 | 15 | this.accessory.addService(Service.ContactSensor, this.device.context.name); 16 | 17 | super._registerPlatformAccessory(); 18 | } 19 | 20 | _registerCharacteristics(dps) { 21 | const {Service, Characteristic} = this.hap; 22 | const service = this.accessory.getService(Service.ContactSensor); 23 | this._checkServiceName(service, this.device.context.name); 24 | 25 | const dpState = this._getCustomDP(this.device.context.dpState) || '101'; 26 | const dpFlipState = this.device.context.dpFlipState && this.device.context.dpFlipState !== 'false'; 27 | const dpActive = this._getCustomDP(this.device.context.dpActive); 28 | const dpFlipActive = this.device.context.dpFlipActive && this.device.context.dpFlipActive !== 'false'; 29 | const dpTamperedAlarm = this._getCustomDP(this.device.context.dpTamperedAlarm); 30 | const dpFaultAlarm = this._getCustomDP(this.device.context.dpFaultAlarm); 31 | const dpBatteryLevel = this._getCustomDP(this.device.context.dpBatteryLevel); 32 | 33 | this._registerTranslators({ 34 | [dpState]: [Characteristic.ContactSensorState.CONTACT_NOT_DETECTED, Characteristic.ContactSensorState.CONTACT_DETECTED, dpFlipState], 35 | [dpActive]: [false, true, dpFlipActive], 36 | [dpTamperedAlarm]: [Characteristic.StatusTampered.NOT_TAMPERED, Characteristic.StatusTampered.TAMPERED], 37 | [dpFaultAlarm]: [Characteristic.StatusFault.NO_FAULT, Characteristic.StatusFault.GENERAL_FAULT], 38 | [dpBatteryLevel]: [Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL, Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW, val => val <= 10] 39 | }); 40 | 41 | const characteristic = {}; 42 | const collection = Object.entries({ 43 | ContactSensorState: dpState, 44 | StatusActive: dpActive, 45 | StatusTampered: dpTamperedAlarm, 46 | StatusFault: dpFaultAlarm, 47 | StatusLowBattery: dpBatteryLevel 48 | }).filter((charName, dpKey) => dpKey && dpKey !== true); 49 | 50 | for (let [charName, dpKey] of collection) { 51 | characteristic[charName] = service.getCharacteristic(Characteristic[charName]) 52 | .updateValue(this._getTranslatedState(dpKey, dps[dpKey])) 53 | .on('get', this.getTranslatedState.bind(this, dpKey)); 54 | } 55 | 56 | this.device.on('change', (changes, state) => { 57 | for (let [charName, dpKey] of collection) { 58 | if (characteristic[charName] && changes.hasOwnProperty(dpKey)) { 59 | const value = this._getTranslatedState(dpKey, changes[dpKey]); 60 | if (characteristic[charName].value !== value) characteristic[charName].updateValue(value); 61 | } 62 | } 63 | 64 | console.log('[TuyaAccessory] ContactSensor changed: ' + JSON.stringify(state)); 65 | }); 66 | } 67 | } 68 | 69 | module.exports = ContactSensorAccessory; -------------------------------------------------------------------------------- /lib/ConvectorAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | 3 | class ConvectorAccessory extends BaseAccessory { 4 | static getCategory(Categories) { 5 | return Categories.AIR_HEATER; 6 | } 7 | 8 | constructor(...props) { 9 | super(...props); 10 | } 11 | 12 | _registerPlatformAccessory() { 13 | const {Service} = this.hap; 14 | 15 | this.accessory.addService(Service.HeaterCooler, this.device.context.name); 16 | 17 | super._registerPlatformAccessory(); 18 | } 19 | 20 | _registerCharacteristics(dps) { 21 | const {Service, Characteristic} = this.hap; 22 | const service = this.accessory.getService(Service.HeaterCooler); 23 | this._checkServiceName(service, this.device.context.name); 24 | 25 | this.dpActive = this._getCustomDP(this.device.context.dpActive) || '7'; 26 | this.dpDesiredTemperature = this._getCustomDP(this.device.context.dpDesiredTemperature) || '2'; 27 | this.dpCurrentTemperature = this._getCustomDP(this.device.context.dpCurrentTemperature) || '3'; 28 | this.dpRotationSpeed = this._getCustomDP(this.device.context.dpRotationSpeed) || '4'; 29 | this.dpChildLock = this._getCustomDP(this.device.context.dpChildLock) || '6'; 30 | this.dpTemperatureDisplayUnits = this._getCustomDP(this.device.context.dpTemperatureDisplayUnits) || '19'; 31 | 32 | this.cmdLow = 'LOW'; 33 | if (this.device.context.cmdLow) { 34 | if (/^l[a-z]+$/i.test(this.device.context.cmdLow)) this.cmdLow = ('' + this.device.context.cmdLow).trim(); 35 | else throw new Error('The cmdLow doesn\'t appear to be valid: ' + this.device.context.cmdLow); 36 | } 37 | 38 | this.cmdHigh = 'HIGH'; 39 | if (this.device.context.cmdHigh) { 40 | if (/^h[a-z]+$/i.test(this.device.context.cmdHigh)) this.cmdHigh = ('' + this.device.context.cmdHigh).trim(); 41 | else throw new Error('The cmdHigh doesn\'t appear to be valid: ' + this.device.context.cmdHigh); 42 | } 43 | 44 | this.enableFlipSpeedSlider = !!this.device.context.enableFlipSpeedSlider; 45 | 46 | const characteristicActive = service.getCharacteristic(Characteristic.Active) 47 | .updateValue(this._getActive(dps[this.dpActive])) 48 | .on('get', this.getActive.bind(this)) 49 | .on('set', this.setActive.bind(this)); 50 | 51 | service.getCharacteristic(Characteristic.CurrentHeaterCoolerState) 52 | .updateValue(this._getCurrentHeaterCoolerState(dps)) 53 | .on('get', this.getCurrentHeaterCoolerState.bind(this)); 54 | 55 | service.getCharacteristic(Characteristic.TargetHeaterCoolerState) 56 | .setProps({ 57 | minValue: 1, 58 | maxValue: 1, 59 | validValues: [Characteristic.TargetHeaterCoolerState.HEAT] 60 | }) 61 | .updateValue(this._getTargetHeaterCoolerState()) 62 | .on('get', this.getTargetHeaterCoolerState.bind(this)) 63 | .on('set', this.setTargetHeaterCoolerState.bind(this)); 64 | 65 | const characteristicCurrentTemperature = service.getCharacteristic(Characteristic.CurrentTemperature) 66 | .updateValue(dps[this.dpCurrentTemperature]) 67 | .on('get', this.getState.bind(this, this.dpCurrentTemperature)); 68 | 69 | 70 | const characteristicHeatingThresholdTemperature = service.getCharacteristic(Characteristic.HeatingThresholdTemperature) 71 | .setProps({ 72 | minValue: this.device.context.minTemperature || 15, 73 | maxValue: this.device.context.maxTemperature || 35, 74 | minStep: this.device.context.minTemperatureSteps || 1 75 | }) 76 | .updateValue(dps[this.dpDesiredTemperature]) 77 | .on('get', this.getState.bind(this, this.dpDesiredTemperature)) 78 | .on('set', this.setTargetThresholdTemperature.bind(this)); 79 | 80 | 81 | let characteristicTemperatureDisplayUnits; 82 | if (!this.device.context.noTemperatureUnit) { 83 | characteristicTemperatureDisplayUnits = service.getCharacteristic(Characteristic.TemperatureDisplayUnits) 84 | .updateValue(this._getTemperatureDisplayUnits(dps[this.dpTemperatureDisplayUnits])) 85 | .on('get', this.getTemperatureDisplayUnits.bind(this)) 86 | .on('set', this.setTemperatureDisplayUnits.bind(this)); 87 | } else this._removeCharacteristic(service, Characteristic.TemperatureDisplayUnits); 88 | 89 | let characteristicLockPhysicalControls; 90 | if (!this.device.context.noChildLock) { 91 | characteristicLockPhysicalControls = service.getCharacteristic(Characteristic.LockPhysicalControls) 92 | .updateValue(this._getLockPhysicalControls(dps[this.dpChildLock])) 93 | .on('get', this.getLockPhysicalControls.bind(this)) 94 | .on('set', this.setLockPhysicalControls.bind(this)); 95 | } else this._removeCharacteristic(service, Characteristic.LockPhysicalControls); 96 | 97 | const characteristicRotationSpeed = service.getCharacteristic(Characteristic.RotationSpeed) 98 | .updateValue(this._getRotationSpeed(dps)) 99 | .on('get', this.getRotationSpeed.bind(this)) 100 | .on('set', this.setRotationSpeed.bind(this)); 101 | 102 | this.characteristicActive = characteristicActive; 103 | this.characteristicHeatingThresholdTemperature = characteristicHeatingThresholdTemperature; 104 | this.characteristicRotationSpeed = characteristicRotationSpeed; 105 | 106 | this.device.on('change', (changes, state) => { 107 | if (changes.hasOwnProperty(this.dpActive)) { 108 | const newActive = this._getActive(changes[this.dpActive]); 109 | if (characteristicActive.value !== newActive) { 110 | characteristicActive.updateValue(newActive); 111 | 112 | if (!changes.hasOwnProperty(this.dpRotationSpeed)) { 113 | characteristicRotationSpeed.updateValue(this._getRotationSpeed(state)); 114 | } 115 | } 116 | } 117 | 118 | if (characteristicLockPhysicalControls && changes.hasOwnProperty(this.dpChildLock)) { 119 | const newLockPhysicalControls = this._getLockPhysicalControls(changes[this.dpChildLock]); 120 | if (characteristicLockPhysicalControls.value !== newLockPhysicalControls) { 121 | characteristicLockPhysicalControls.updateValue(newLockPhysicalControls); 122 | } 123 | } 124 | 125 | if (changes.hasOwnProperty(this.dpDesiredTemperature)) { 126 | if (characteristicHeatingThresholdTemperature.value !== changes[this.dpDesiredTemperature]) 127 | characteristicHeatingThresholdTemperature.updateValue(changes[this.dpDesiredTemperature]); 128 | } 129 | 130 | if (changes.hasOwnProperty(this.dpCurrentTemperature) && characteristicCurrentTemperature.value !== changes[this.dpCurrentTemperature]) characteristicCurrentTemperature.updateValue(changes[this.dpCurrentTemperature]); 131 | 132 | if (characteristicTemperatureDisplayUnits && changes.hasOwnProperty(this.dpTemperatureDisplayUnits)) { 133 | const newTemperatureDisplayUnits = this._getTemperatureDisplayUnits(changes[this.dpTemperatureDisplayUnits]); 134 | if (characteristicTemperatureDisplayUnits.value !== newTemperatureDisplayUnits) characteristicTemperatureDisplayUnits.updateValue(newTemperatureDisplayUnits); 135 | } 136 | 137 | if (changes.hasOwnProperty(this.dpRotationSpeed)) { 138 | const newRotationSpeed = this._getRotationSpeed(state); 139 | if (characteristicRotationSpeed.value !== newRotationSpeed) characteristicRotationSpeed.updateValue(newRotationSpeed); 140 | } 141 | }); 142 | } 143 | 144 | getActive(callback) { 145 | this.getState(this.dpActive, (err, dp) => { 146 | if (err) return callback(err); 147 | 148 | callback(null, this._getActive(dp)); 149 | }); 150 | } 151 | 152 | _getActive(dp) { 153 | const {Characteristic} = this.hap; 154 | 155 | return dp ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE; 156 | } 157 | 158 | setActive(value, callback) { 159 | const {Characteristic} = this.hap; 160 | 161 | switch (value) { 162 | case Characteristic.Active.ACTIVE: 163 | return this.setState(this.dpActive, true, callback); 164 | 165 | case Characteristic.Active.INACTIVE: 166 | return this.setState(this.dpActive, false, callback); 167 | } 168 | 169 | callback(); 170 | } 171 | 172 | getLockPhysicalControls(callback) { 173 | this.getState(this.dpChildLock, (err, dp) => { 174 | if (err) return callback(err); 175 | 176 | callback(null, this._getLockPhysicalControls(dp)); 177 | }); 178 | } 179 | 180 | _getLockPhysicalControls(dp) { 181 | const {Characteristic} = this.hap; 182 | 183 | return dp ? Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED : Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED; 184 | } 185 | 186 | setLockPhysicalControls(value, callback) { 187 | const {Characteristic} = this.hap; 188 | 189 | switch (value) { 190 | case Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED: 191 | return this.setState(this.dpChildLock, true, callback); 192 | 193 | case Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED: 194 | return this.setState(this.dpChildLock, false, callback); 195 | } 196 | 197 | callback(); 198 | } 199 | 200 | getCurrentHeaterCoolerState(callback) { 201 | this.getState([this.dpActive], (err, dps) => { 202 | if (err) return callback(err); 203 | 204 | callback(null, this._getCurrentHeaterCoolerState(dps)); 205 | }); 206 | } 207 | 208 | _getCurrentHeaterCoolerState(dps) { 209 | const {Characteristic} = this.hap; 210 | return dps[this.dpActive] ? Characteristic.CurrentHeaterCoolerState.HEATING : Characteristic.CurrentHeaterCoolerState.INACTIVE; 211 | } 212 | 213 | getTargetHeaterCoolerState(callback) { 214 | callback(null, this._getTargetHeaterCoolerState()); 215 | } 216 | 217 | _getTargetHeaterCoolerState() { 218 | const {Characteristic} = this.hap; 219 | return Characteristic.TargetHeaterCoolerState.HEAT; 220 | } 221 | 222 | setTargetHeaterCoolerState(value, callback) { 223 | this.setState(this.dpActive, true, callback); 224 | } 225 | 226 | setTargetThresholdTemperature(value, callback) { 227 | this.setState(this.dpDesiredTemperature, value, err => { 228 | if (err) return callback(err); 229 | 230 | if (this.characteristicHeatingThresholdTemperature) { 231 | this.characteristicHeatingThresholdTemperature.updateValue(value); 232 | } 233 | 234 | callback(); 235 | }); 236 | } 237 | 238 | getTemperatureDisplayUnits(callback) { 239 | this.getState(this.dpTemperatureDisplayUnits, (err, dp) => { 240 | if (err) return callback(err); 241 | 242 | callback(null, this._getTemperatureDisplayUnits(dp)); 243 | }); 244 | } 245 | 246 | _getTemperatureDisplayUnits(dp) { 247 | const {Characteristic} = this.hap; 248 | 249 | return dp === 'F' ? Characteristic.TemperatureDisplayUnits.FAHRENHEIT : Characteristic.TemperatureDisplayUnits.CELSIUS; 250 | } 251 | 252 | setTemperatureDisplayUnits(value, callback) { 253 | const {Characteristic} = this.hap; 254 | 255 | this.setState(this.dpTemperatureDisplayUnits, value === Characteristic.TemperatureDisplayUnits.FAHRENHEIT ? 'F' : 'C', callback); 256 | } 257 | 258 | getRotationSpeed(callback) { 259 | this.getState([this.dpActive, this.dpRotationSpeed], (err, dps) => { 260 | if (err) return callback(err); 261 | 262 | callback(null, this._getRotationSpeed(dps)); 263 | }); 264 | } 265 | 266 | _getRotationSpeed(dps) { 267 | if (!dps[this.dpActive]) return 0; 268 | 269 | if (this._hkRotationSpeed) { 270 | const currntRotationSpeed = this.convertRotationSpeedFromHomeKitToTuya(this._hkRotationSpeed); 271 | 272 | return currntRotationSpeed === dps[this.dpRotationSpeed] ? this._hkRotationSpeed : this.convertRotationSpeedFromTuyaToHomeKit(dps[this.dpRotationSpeed]); 273 | } 274 | 275 | return this._hkRotationSpeed = this.convertRotationSpeedFromTuyaToHomeKit(dps[this.dpRotationSpeed]); 276 | } 277 | 278 | setRotationSpeed(value, callback) { 279 | const {Characteristic} = this.hap; 280 | 281 | if (value === 0) { 282 | this.setActive(Characteristic.Active.INACTIVE, callback); 283 | } else { 284 | this._hkRotationSpeed = value; 285 | 286 | const newSpeed = this.convertRotationSpeedFromHomeKitToTuya(value); 287 | const currentSpeed = this.convertRotationSpeedFromHomeKitToTuya(this.characteristicRotationSpeed.value); 288 | 289 | if (this.enableFlipSpeedSlider) this._hkRotationSpeed = this.convertRotationSpeedFromTuyaToHomeKit(newSpeed); 290 | 291 | if (newSpeed !== currentSpeed) { 292 | this.characteristicRotationSpeed.updateValue(this._hkRotationSpeed); 293 | this.setMultiState({[this.dpActive]: true, [this.dpRotationSpeed]: newSpeed}, callback); 294 | } else { 295 | callback(); 296 | if (this.enableFlipSpeedSlider) 297 | process.nextTick(() => { 298 | this.characteristicRotationSpeed.updateValue(this._hkRotationSpeed); 299 | }); 300 | } 301 | } 302 | } 303 | 304 | convertRotationSpeedFromTuyaToHomeKit(value) { 305 | return {[this.cmdLow]: 1, [this.cmdHigh]: 100}[value]; 306 | } 307 | 308 | convertRotationSpeedFromHomeKitToTuya(value) { 309 | return value < 50 ? this.cmdLow : this.cmdHigh; 310 | } 311 | } 312 | 313 | module.exports = ConvectorAccessory; -------------------------------------------------------------------------------- /lib/CustomMultiOutletAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | const async = require('async'); 3 | 4 | class CustomMultiOutletAccessory extends BaseAccessory { 5 | static getCategory(Categories) { 6 | return Categories.OUTLET; 7 | } 8 | 9 | constructor(...props) { 10 | super(...props); 11 | } 12 | 13 | _registerPlatformAccessory() { 14 | this._verifyCachedPlatformAccessory(); 15 | this._justRegistered = true; 16 | 17 | super._registerPlatformAccessory(); 18 | } 19 | 20 | _verifyCachedPlatformAccessory() { 21 | if (this._justRegistered) return; 22 | 23 | const {Service} = this.hap; 24 | 25 | if (!Array.isArray(this.device.context.outlets)) { 26 | throw new Error('The outlets definition is missing or is malformed: ' + this.device.context.outlets); 27 | } 28 | const _validServices = []; 29 | this.device.context.outlets.forEach((outlet, i) => { 30 | if (!outlet || !outlet.hasOwnProperty('name') || !outlet.hasOwnProperty('dp') || !isFinite(outlet.dp)) 31 | throw new Error('The outlet definition #${i} is missing or is malformed: ' + outlet); 32 | 33 | const name = ((outlet.name || '').trim() || 'Unnamed') + ' - ' + this.device.context.name; 34 | let service = this.accessory.getServiceByUUIDAndSubType(Service.Outlet, 'outlet ' + outlet.dp); 35 | if (service) this._checkServiceName(service, name); 36 | else service = this.accessory.addService(Service.Outlet, name, 'outlet ' + outlet.dp); 37 | 38 | _validServices.push(service); 39 | }); 40 | 41 | this.accessory.services 42 | .filter(service => service.UUID === Service.Outlet.UUID && !_validServices.includes(service)) 43 | .forEach(service => { 44 | this.accessory.removeService(service); 45 | }); 46 | } 47 | 48 | _registerCharacteristics(dps) { 49 | this._verifyCachedPlatformAccessory(); 50 | 51 | const {Service, Characteristic} = this.hap; 52 | 53 | const characteristics = {}; 54 | this.accessory.services.forEach(service => { 55 | if (service.UUID !== Service.Outlet.UUID || !service.subtype) return false; 56 | 57 | let match; 58 | if ((match = service.subtype.match(/^outlet (\d+)$/)) === null) return; 59 | 60 | characteristics[match[1]] = service.getCharacteristic(Characteristic.On) 61 | .updateValue(dps[match[1]]) 62 | .on('get', this.getPower.bind(this, match[1])) 63 | .on('set', this.setPower.bind(this, match[1])); 64 | }); 65 | 66 | this.device.on('change', (changes, state) => { 67 | Object.keys(changes).forEach(key => { 68 | if (characteristics[key] && characteristics[key].value !== changes[key]) characteristics[key].updateValue(changes[key]); 69 | }); 70 | }); 71 | } 72 | 73 | getPower(dp, callback) { 74 | callback(null, this.device.state[dp]); 75 | } 76 | 77 | setPower(dp, value, callback) { 78 | if (!this._pendingPower) { 79 | this._pendingPower = {props: {}, callbacks: []}; 80 | } 81 | 82 | if (dp) { 83 | if (this._pendingPower.timer) clearTimeout(this._pendingPower.timer); 84 | 85 | this._pendingPower.props = {...this._pendingPower.props, ...{[dp]: value}}; 86 | this._pendingPower.callbacks.push(callback); 87 | 88 | this._pendingPower.timer = setTimeout(() => { 89 | this.setPower(); 90 | }, 500); 91 | return; 92 | } 93 | 94 | const callbacks = this._pendingPower.callbacks; 95 | const callEachBack = err => { 96 | async.eachSeries(callbacks, (callback, next) => { 97 | try { 98 | callback(err); 99 | } catch (ex) {} 100 | next(); 101 | }); 102 | }; 103 | 104 | const newValue = this._pendingPower.props; 105 | this._pendingPower = null; 106 | 107 | this.setMultiState(newValue, callEachBack); 108 | } 109 | } 110 | 111 | module.exports = CustomMultiOutletAccessory; -------------------------------------------------------------------------------- /lib/EnergyCharacteristics.js: -------------------------------------------------------------------------------- 1 | // Thanks to homebridge-tplink-smarthome 2 | 3 | module.exports = function(Characteristic) { 4 | class EnergyCharacteristic extends Characteristic { 5 | constructor(displayName, UUID, props) { 6 | super(displayName, UUID); 7 | this.setProps(Object.assign({ 8 | format: Characteristic.Formats.FLOAT, 9 | minValue: 0, 10 | maxValue: 65535, 11 | perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] 12 | }, props)); 13 | this.value = this.getDefaultValue(); 14 | } 15 | } 16 | 17 | class Amperes extends EnergyCharacteristic { 18 | constructor() { 19 | super('Amperes', Amperes.UUID, { 20 | unit: 'A', 21 | minStep: 0.001 22 | }); 23 | } 24 | } 25 | 26 | Amperes.UUID = 'E863F126-079E-48FF-8F27-9C2605A29F52'; 27 | 28 | class KilowattHours extends EnergyCharacteristic { 29 | constructor() { 30 | super('Total Consumption', KilowattHours.UUID, { 31 | unit: 'kWh', 32 | minStep: 0.001 33 | }); 34 | } 35 | } 36 | 37 | KilowattHours.UUID = 'E863F10C-079E-48FF-8F27-9C2605A29F52'; 38 | 39 | class KilowattVoltAmpereHour extends EnergyCharacteristic { 40 | constructor() { 41 | super('Apparent Energy', KilowattVoltAmpereHour.UUID, { 42 | format: Characteristic.Formats.UINT32, 43 | unit: 'kVAh', 44 | minStep: 1 45 | }); 46 | } 47 | } 48 | 49 | KilowattVoltAmpereHour.UUID = 'E863F127-079E-48FF-8F27-9C2605A29F52'; 50 | 51 | class VoltAmperes extends EnergyCharacteristic { 52 | constructor() { 53 | super('Apparent Power', VoltAmperes.UUID, { 54 | format: Characteristic.Formats.UINT16, 55 | unit: 'VA', 56 | minStep: 1 57 | }); 58 | } 59 | } 60 | 61 | VoltAmperes.UUID = 'E863F110-079E-48FF-8F27-9C2605A29F52'; 62 | 63 | class Volts extends EnergyCharacteristic { 64 | constructor() { 65 | super('Volts', Volts.UUID, { 66 | unit: 'V', 67 | minStep: 0.1 68 | }); 69 | } 70 | } 71 | 72 | Volts.UUID = 'E863F10A-079E-48FF-8F27-9C2605A29F52'; 73 | 74 | class Watts extends EnergyCharacteristic { 75 | constructor() { 76 | super('Consumption', Watts.UUID, { 77 | unit: 'W', 78 | minStep: 0.1 79 | }); 80 | } 81 | } 82 | 83 | Watts.UUID = 'E863F10D-079E-48FF-8F27-9C2605A29F52'; 84 | 85 | return {Amperes, KilowattHours, KilowattVoltAmpereHour, VoltAmperes, Volts, Watts}; 86 | }; 87 | -------------------------------------------------------------------------------- /lib/GarageDoorAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | 3 | class GarageDoorAccessory extends BaseAccessory { 4 | static getCategory(Categories) { 5 | return Categories.GARAGE_DOOR_OPENER; 6 | } 7 | 8 | constructor(...props) { 9 | super(...props); 10 | } 11 | 12 | _registerPlatformAccessory() { 13 | const {Service} = this.hap; 14 | 15 | this.accessory.addService(Service.GarageDoorOpener, this.device.context.name); 16 | 17 | super._registerPlatformAccessory(); 18 | } 19 | 20 | _registerCharacteristics(dps) { 21 | const {Service, Characteristic} = this.hap; 22 | const service = this.accessory.getService(Service.GarageDoorOpener); 23 | this._checkServiceName(service, this.device.context.name); 24 | 25 | this.dpAction = this._getCustomDP(this.device.context.dpAction) || '1'; 26 | this.dpStatus = this._getCustomDP(this.device.context.dpStatus) || '2'; 27 | 28 | this.currentOpen = Characteristic.CurrentDoorState.OPEN; 29 | this.currentOpening = Characteristic.CurrentDoorState.OPENING; 30 | this.currentClosing = Characteristic.CurrentDoorState.CLOSING; 31 | this.currentClosed = Characteristic.CurrentDoorState.CLOSED; 32 | this.targetOpen = Characteristic.TargetDoorState.OPEN; 33 | this.targetClosed = Characteristic.TargetDoorState.CLOSED; 34 | if (!!this.device.context.flipState) { 35 | this.currentOpen = Characteristic.CurrentDoorState.CLOSED; 36 | this.currentOpening = Characteristic.CurrentDoorState.CLOSING; 37 | this.currentClosing = Characteristic.CurrentDoorState.OPENING; 38 | this.currentClosed = Characteristic.CurrentDoorState.OPEN; 39 | this.targetOpen = Characteristic.TargetDoorState.CLOSED; 40 | this.targetClosed = Characteristic.TargetDoorState.OPEN; 41 | } 42 | 43 | const characteristicTargetDoorState = service.getCharacteristic(Characteristic.TargetDoorState) 44 | .updateValue(this._getTargetDoorState(dps[this.dpAction])) 45 | .on('get', this.getTargetDoorState.bind(this)) 46 | .on('set', this.setTargetDoorState.bind(this)); 47 | 48 | const characteristicCurrentDoorState = service.getCharacteristic(Characteristic.CurrentDoorState) 49 | .updateValue(this._getCurrentDoorState(dps)) 50 | .on('get', this.getCurrentDoorState.bind(this)); 51 | 52 | this.device.on('change', (changes, state) => { 53 | console.log('[TuyaAccessory] GarageDoor changed: ' + JSON.stringify(state)); 54 | 55 | if (changes.hasOwnProperty(this.dpAction)) { 56 | const newCurrentDoorState = this._getCurrentDoorState(state); 57 | if (characteristicCurrentDoorState.value !== newCurrentDoorState) characteristicCurrentDoorState.updateValue(newCurrentDoorState); 58 | } 59 | }); 60 | } 61 | 62 | getTargetDoorState(callback) { 63 | this.getState(this.dpAction, (err, dp) => { 64 | if (err) return callback(err); 65 | 66 | callback(null, this._getTargetDoorState(dp)); 67 | }); 68 | } 69 | 70 | _getTargetDoorState(dp) { 71 | return dp ? this.targetOpen : this.targetClosed; 72 | } 73 | 74 | setTargetDoorState(value, callback) { 75 | this.setState(this.dpAction, value === this.targetOpen, callback); 76 | } 77 | 78 | getCurrentDoorState(callback) { 79 | this.getState([this.dpAction, this.dpStatus], (err, dps) => { 80 | if (err) return callback(err); 81 | 82 | callback(null, this._getCurrentDoorState(dps)); 83 | }); 84 | } 85 | 86 | _getCurrentDoorState(dps) { 87 | // ToDo: Check other `dps` for opening and closing states 88 | return dps[this.dpAction] ? this.currentOpen : this.currentClosed; 89 | } 90 | } 91 | 92 | module.exports = GarageDoorAccessory; -------------------------------------------------------------------------------- /lib/MultiOutletAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | const async = require('async'); 3 | 4 | class MultiOutletAccessory extends BaseAccessory { 5 | static getCategory(Categories) { 6 | return Categories.OUTLET; 7 | } 8 | 9 | constructor(...props) { 10 | super(...props); 11 | } 12 | 13 | _registerPlatformAccessory() { 14 | this._verifyCachedPlatformAccessory(); 15 | this._justRegistered = true; 16 | 17 | super._registerPlatformAccessory(); 18 | } 19 | 20 | _verifyCachedPlatformAccessory() { 21 | if (this._justRegistered) return; 22 | 23 | const {Service} = this.hap; 24 | 25 | const outletCount = parseInt(this.device.context.outletCount) || 1; 26 | const _validServices = []; 27 | for (let i = 0; i++ < outletCount;) { 28 | let service = this.accessory.getServiceByUUIDAndSubType(Service.Outlet, 'outlet ' + i); 29 | if (service) this._checkServiceName(service, this.device.context.name + ' ' + i); 30 | else service = this.accessory.addService(Service.Outlet, this.device.context.name + ' ' + i, 'outlet ' + i); 31 | 32 | _validServices.push(service); 33 | } 34 | 35 | this.accessory.services 36 | .filter(service => service.UUID === Service.Outlet.UUID && !_validServices.includes(service)) 37 | .forEach(service => { 38 | console.log('Removing', service.displayName); 39 | this.accessory.removeService(service); 40 | }); 41 | } 42 | 43 | _registerCharacteristics(dps) { 44 | this._verifyCachedPlatformAccessory(); 45 | 46 | const {Service, Characteristic} = this.hap; 47 | 48 | const characteristics = {}; 49 | this.accessory.services.forEach(service => { 50 | if (service.UUID !== Service.Outlet.UUID || !service.subtype) return false; 51 | 52 | let match; 53 | if ((match = service.subtype.match(/^outlet (\d+)$/)) === null) return; 54 | 55 | characteristics[match[1]] = service.getCharacteristic(Characteristic.On) 56 | .updateValue(dps[match[1]]) 57 | .on('get', this.getPower.bind(this, match[1])) 58 | .on('set', this.setPower.bind(this, match[1])); 59 | }); 60 | 61 | this.device.on('change', (changes, state) => { 62 | Object.keys(changes).forEach(key => { 63 | if (characteristics[key] && characteristics[key].value !== changes[key]) characteristics[key].updateValue(changes[key]); 64 | }); 65 | }); 66 | } 67 | 68 | getPower(dp, callback) { 69 | callback(null, this.device.state[dp]); 70 | } 71 | 72 | setPower(dp, value, callback) { 73 | if (!this._pendingPower) { 74 | this._pendingPower = {props: {}, callbacks: []}; 75 | } 76 | 77 | if (dp) { 78 | if (this._pendingPower.timer) clearTimeout(this._pendingPower.timer); 79 | 80 | this._pendingPower.props = {...this._pendingPower.props, ...{[dp]: value}}; 81 | this._pendingPower.callbacks.push(callback); 82 | 83 | this._pendingPower.timer = setTimeout(() => { 84 | this.setPower(); 85 | }, 500); 86 | return; 87 | } 88 | 89 | const callbacks = this._pendingPower.callbacks; 90 | const callEachBack = err => { 91 | async.eachSeries(callbacks, (callback, next) => { 92 | try { 93 | callback(err); 94 | } catch (ex) {} 95 | next(); 96 | }); 97 | }; 98 | 99 | const newValue = this._pendingPower.props; 100 | this._pendingPower = null; 101 | 102 | this.setMultiState(newValue, callEachBack); 103 | } 104 | } 105 | 106 | module.exports = MultiOutletAccessory; -------------------------------------------------------------------------------- /lib/OutletAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | 3 | class OutletAccessory extends BaseAccessory { 4 | static getCategory(Categories) { 5 | return Categories.OUTLET; 6 | } 7 | 8 | constructor(...props) { 9 | super(...props); 10 | } 11 | 12 | _registerPlatformAccessory() { 13 | const {Service} = this.hap; 14 | 15 | this.accessory.addService(Service.Outlet, this.device.context.name); 16 | 17 | super._registerPlatformAccessory(); 18 | } 19 | 20 | _registerCharacteristics(dps) { 21 | const {Service, Characteristic, EnergyCharacteristics} = this.hap; 22 | const service = this.accessory.getService(Service.Outlet); 23 | this._checkServiceName(service, this.device.context.name); 24 | 25 | this.dpPower = this._getCustomDP(this.device.context.dpPower) || '1'; 26 | 27 | const energyKeys = { 28 | volts: this._getCustomDP(this.device.context.voltsId), 29 | voltsDivisor: parseInt(this.device.context.voltsDivisor) || 10, 30 | amps: this._getCustomDP(this.device.context.ampsId), 31 | ampsDivisor: parseInt(this.device.context.ampsDivisor) || 1000, 32 | watts: this._getCustomDP(this.device.context.wattsId), 33 | wattsDivisor: parseInt(this.device.context.wattsDivisor) || 10 34 | }; 35 | 36 | let characteristicVolts; 37 | if (energyKeys.volts) { 38 | characteristicVolts = service.getCharacteristic(EnergyCharacteristics.Volts) 39 | .updateValue(this._getDividedState(dps[energyKeys.volts], energyKeys.voltsDivisor)) 40 | .on('get', this.getDividedState.bind(this, energyKeys.volts, energyKeys.voltsDivisor)); 41 | } else this._removeCharacteristic(service, EnergyCharacteristics.Volts); 42 | 43 | let characteristicAmps; 44 | if (energyKeys.amps) { 45 | characteristicAmps = service.getCharacteristic(EnergyCharacteristics.Amperes) 46 | .updateValue(this._getDividedState(dps[energyKeys.amps], energyKeys.ampsDivisor)) 47 | .on('get', this.getDividedState.bind(this, energyKeys.amps, energyKeys.ampsDivisor)); 48 | } else this._removeCharacteristic(service, EnergyCharacteristics.Amperes); 49 | 50 | let characteristicWatts; 51 | if (energyKeys.watts) { 52 | characteristicWatts = service.getCharacteristic(EnergyCharacteristics.Watts) 53 | .updateValue(this._getDividedState(dps[energyKeys.watts], energyKeys.wattsDivisor)) 54 | .on('get', this.getDividedState.bind(this, energyKeys.watts, energyKeys.wattsDivisor)); 55 | } else this._removeCharacteristic(service, EnergyCharacteristics.Watts); 56 | 57 | const characteristicOn = service.getCharacteristic(Characteristic.On) 58 | .updateValue(dps[this.dpPower]) 59 | .on('get', this.getState.bind(this, this.dpPower)) 60 | .on('set', this.setState.bind(this, this.dpPower)); 61 | 62 | this.device.on('change', changes => { 63 | if (changes.hasOwnProperty(this.dpPower) && characteristicOn.value !== changes[this.dpPower]) characteristicOn.updateValue(changes[this.dpPower]); 64 | 65 | if (changes.hasOwnProperty(energyKeys.volts) && characteristicVolts) { 66 | const newVolts = this._getDividedState(changes[energyKeys.volts], energyKeys.voltsDivisor); 67 | if (characteristicVolts.value !== newVolts) characteristicVolts.updateValue(newVolts); 68 | } 69 | 70 | if (changes.hasOwnProperty(energyKeys.amps) && characteristicAmps) { 71 | const newAmps = this._getDividedState(changes[energyKeys.amps], energyKeys.ampsDivisor); 72 | if (characteristicAmps.value !== newAmps) characteristicAmps.updateValue(newAmps); 73 | } 74 | 75 | if (changes.hasOwnProperty(energyKeys.watts) && characteristicWatts) { 76 | const newWatts = this._getDividedState(changes[energyKeys.watts], energyKeys.wattsDivisor); 77 | if (characteristicWatts.value !== newWatts) characteristicWatts.updateValue(newWatts); 78 | } 79 | }); 80 | } 81 | } 82 | 83 | module.exports = OutletAccessory; -------------------------------------------------------------------------------- /lib/RGBTWLightAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | const async = require('async'); 3 | 4 | class RGBTWLightAccessory extends BaseAccessory { 5 | static getCategory(Categories) { 6 | return Categories.LIGHTBULB; 7 | } 8 | 9 | constructor(...props) { 10 | super(...props); 11 | } 12 | 13 | _registerPlatformAccessory() { 14 | const {Service} = this.hap; 15 | 16 | this.accessory.addService(Service.Lightbulb, this.device.context.name); 17 | 18 | super._registerPlatformAccessory(); 19 | } 20 | 21 | _registerCharacteristics(dps) { 22 | const {Service, Characteristic} = this.hap; 23 | const service = this.accessory.getService(Service.Lightbulb); 24 | this._checkServiceName(service, this.device.context.name); 25 | 26 | this.dpPower = this._getCustomDP(this.device.context.dpPower) || '1'; 27 | this.dpMode = this._getCustomDP(this.device.context.dpMode) || '2'; 28 | this.dpBrightness = this._getCustomDP(this.device.context.dpBrightness) || '3'; 29 | this.dpColorTemperature = this._getCustomDP(this.device.context.dpColorTemperature) || '4'; 30 | this.dpColor = this._getCustomDP(this.device.context.dpColor) || '5'; 31 | 32 | this._detectColorFunction(dps[this.dpColor]); 33 | 34 | this.cmdWhite = 'white'; 35 | if (this.device.context.cmdWhite) { 36 | if (/^w[a-z]+$/i.test(this.device.context.cmdWhite)) this.cmdWhite = ('' + this.device.context.cmdWhite).trim(); 37 | else throw new Error(`The cmdWhite doesn't appear to be valid: ${this.device.context.cmdWhite}`); 38 | } 39 | 40 | this.cmdColor = 'colour'; 41 | if (this.device.context.cmdColor) { 42 | if (/^c[a-z]+$/i.test(this.device.context.cmdColor)) this.cmdColor = ('' + this.device.context.cmdColor).trim(); 43 | else throw new Error(`The cmdColor doesn't appear to be valid: ${this.device.context.cmdColor}`); 44 | } else if (this.device.context.cmdColour) { 45 | if (/^c[a-z]+$/i.test(this.device.context.cmdColour)) this.cmdColor = ('' + this.device.context.cmdColour).trim(); 46 | else throw new Error(`The cmdColour doesn't appear to be valid: ${this.device.context.cmdColour}`); 47 | } 48 | 49 | const characteristicOn = service.getCharacteristic(Characteristic.On) 50 | .updateValue(dps[this.dpPower]) 51 | .on('get', this.getState.bind(this, this.dpPower)) 52 | .on('set', this.setState.bind(this, this.dpPower)); 53 | 54 | const characteristicBrightness = service.getCharacteristic(Characteristic.Brightness) 55 | .updateValue(dps[this.dpMode] === this.cmdWhite ? this.convertBrightnessFromTuyaToHomeKit(dps[this.dpBrightness]) : this.convertColorFromTuyaToHomeKit(dps[this.dpColor]).b) 56 | .on('get', this.getBrightness.bind(this)) 57 | .on('set', this.setBrightness.bind(this)); 58 | 59 | const characteristicColorTemperature = service.getCharacteristic(Characteristic.ColorTemperature) 60 | .setProps({ 61 | minValue: 0, 62 | maxValue: 600 63 | }) 64 | .updateValue(dps[this.dpMode] === this.cmdWhite ? this.convertColorTemperatureFromTuyaToHomeKit(dps[this.dpColorTemperature]) : 0) 65 | .on('get', this.getColorTemperature.bind(this)) 66 | .on('set', this.setColorTemperature.bind(this)); 67 | 68 | const characteristicHue = service.getCharacteristic(Characteristic.Hue) 69 | .updateValue(dps[this.dpMode] === this.cmdWhite ? 0 : this.convertColorFromTuyaToHomeKit(dps[this.dpColor]).h) 70 | .on('get', this.getHue.bind(this)) 71 | .on('set', this.setHue.bind(this)); 72 | 73 | const characteristicSaturation = service.getCharacteristic(Characteristic.Saturation) 74 | .updateValue(dps[this.dpMode] === this.cmdWhite ? 0 : this.convertColorFromTuyaToHomeKit(dps[this.dpColor]).s) 75 | .on('get', this.getSaturation.bind(this)) 76 | .on('set', this.setSaturation.bind(this)); 77 | 78 | this.characteristicHue = characteristicHue; 79 | this.characteristicSaturation = characteristicSaturation; 80 | this.characteristicColorTemperature = characteristicColorTemperature; 81 | 82 | this.device.on('change', (changes, state) => { 83 | if (changes.hasOwnProperty(this.dpPower) && characteristicOn.value !== changes[this.dpPower]) characteristicOn.updateValue(changes[this.dpPower]); 84 | 85 | switch (state[this.dpMode]) { 86 | case this.cmdWhite: 87 | if (changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(characteristicBrightness.value) !== changes[this.dpBrightness]) 88 | characteristicBrightness.updateValue(this.convertBrightnessFromTuyaToHomeKit(changes[this.dpBrightness])); 89 | 90 | if (changes.hasOwnProperty(this.dpColorTemperature) && this.convertColorTemperatureFromHomeKitToTuya(characteristicColorTemperature.value) !== changes[this.dpColorTemperature]) { 91 | 92 | const newColorTemperature = this.convertColorTemperatureFromTuyaToHomeKit(changes[this.dpColorTemperature]); 93 | const newColor = this.convertHomeKitColorTemperatureToHomeKitColor(newColorTemperature); 94 | 95 | characteristicHue.updateValue(newColor.h); 96 | characteristicSaturation.updateValue(newColor.s); 97 | characteristicColorTemperature.updateValue(newColorTemperature); 98 | 99 | } else if (changes[this.dpMode] && !changes.hasOwnProperty(this.dpColorTemperature)) { 100 | 101 | const newColorTemperature = this.convertColorTemperatureFromTuyaToHomeKit(state[this.dpColorTemperature]); 102 | const newColor = this.convertHomeKitColorTemperatureToHomeKitColor(newColorTemperature); 103 | 104 | characteristicHue.updateValue(newColor.h); 105 | characteristicSaturation.updateValue(newColor.s); 106 | characteristicColorTemperature.updateValue(newColorTemperature); 107 | } 108 | 109 | break; 110 | 111 | default: 112 | if (changes.hasOwnProperty(this.dpColor)) { 113 | const oldColor = this.convertColorFromTuyaToHomeKit(this.convertColorFromHomeKitToTuya({ 114 | h: characteristicHue.value, 115 | s: characteristicSaturation.value, 116 | b: characteristicBrightness.value 117 | })); 118 | const newColor = this.convertColorFromTuyaToHomeKit(changes[this.dpColor]); 119 | 120 | if (oldColor.b !== newColor.b) characteristicBrightness.updateValue(newColor.b); 121 | if (oldColor.h !== newColor.h) characteristicHue.updateValue(newColor.h); 122 | 123 | if (oldColor.s !== newColor.s) characteristicSaturation.updateValue(newColor.h); 124 | 125 | if (characteristicColorTemperature.value !== 0) characteristicColorTemperature.updateValue(0); 126 | 127 | } else if (changes[this.dpMode]) { 128 | if (characteristicColorTemperature.value !== 0) characteristicColorTemperature.updateValue(0); 129 | } 130 | } 131 | }); 132 | } 133 | 134 | getBrightness(callback) { 135 | if (this.device.state[this.dpMode] === this.cmdWhite) return callback(null, this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness])); 136 | callback(null, this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).b); 137 | } 138 | 139 | setBrightness(value, callback) { 140 | if (this.device.state[this.dpMode] === this.cmdWhite) return this.setState(this.dpBrightness, this.convertBrightnessFromHomeKitToTuya(value), callback); 141 | this.setState(this.dpColor, this.convertColorFromHomeKitToTuya({b: value}), callback); 142 | } 143 | 144 | getColorTemperature(callback) { 145 | if (this.device.state[this.dpMode] !== this.cmdWhite) return callback(null, 0); 146 | callback(null, this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature])); 147 | } 148 | 149 | setColorTemperature(value, callback) { 150 | console.log(`[TuyaAccessory] setColorTemperature: ${value}`); 151 | if (value === 0) return callback(null, true); 152 | 153 | const newColor = this.convertHomeKitColorTemperatureToHomeKitColor(value); 154 | this.characteristicHue.updateValue(newColor.h); 155 | this.characteristicSaturation.updateValue(newColor.s); 156 | 157 | // Avoid cross-mode complications due rapid commands 158 | this.device.state[this.dpMode] = this.cmdWhite; 159 | 160 | this.setMultiState({[this.dpMode]: this.cmdWhite, [this.dpColorTemperature]: this.convertColorTemperatureFromHomeKitToTuya(value)}, callback); 161 | } 162 | 163 | getHue(callback) { 164 | if (this.device.state[this.dpMode] === this.cmdWhite) return callback(null, 0); 165 | callback(null, this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).h); 166 | } 167 | 168 | setHue(value, callback) { 169 | this._setHueSaturation({h: value}, callback); 170 | } 171 | 172 | getSaturation(callback) { 173 | if (this.device.state[this.dpMode] === this.cmdWhite) return callback(null, 0); 174 | callback(null, this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).s); 175 | } 176 | 177 | setSaturation(value, callback) { 178 | this._setHueSaturation({s: value}, callback); 179 | } 180 | 181 | _setHueSaturation(prop, callback) { 182 | if (!this._pendingHueSaturation) { 183 | this._pendingHueSaturation = {props: {}, callbacks: []}; 184 | } 185 | 186 | if (prop) { 187 | if (this._pendingHueSaturation.timer) clearTimeout(this._pendingHueSaturation.timer); 188 | 189 | this._pendingHueSaturation.props = {...this._pendingHueSaturation.props, ...prop}; 190 | this._pendingHueSaturation.callbacks.push(callback); 191 | 192 | this._pendingHueSaturation.timer = setTimeout(() => { 193 | this._setHueSaturation(); 194 | }, 500); 195 | return; 196 | } 197 | 198 | //this.characteristicColorTemperature.updateValue(0); 199 | 200 | const callbacks = this._pendingHueSaturation.callbacks; 201 | const callEachBack = err => { 202 | async.eachSeries(callbacks, (callback, next) => { 203 | try { 204 | callback(err); 205 | } catch (ex) {} 206 | next(); 207 | }, () => { 208 | this.characteristicColorTemperature.updateValue(0); 209 | }); 210 | }; 211 | 212 | const isSham = this._pendingHueSaturation.props.h === 0 && this._pendingHueSaturation.props.s === 0; 213 | const newValue = this.convertColorFromHomeKitToTuya(this._pendingHueSaturation.props); 214 | this._pendingHueSaturation = null; 215 | 216 | 217 | if (this.device.state[this.dpMode] === this.cmdWhite && isSham) return callEachBack(); 218 | 219 | // Avoid cross-mode complications due rapid commands 220 | this.device.state[this.dpMode] = this.cmdColor; 221 | 222 | this.setMultiState({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue}, callEachBack); 223 | } 224 | } 225 | 226 | module.exports = RGBTWLightAccessory; -------------------------------------------------------------------------------- /lib/RGBTWOutletAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | const async = require('async'); 3 | 4 | class RGBTWOutletAccessory extends BaseAccessory { 5 | static getCategory(Categories) { 6 | return Categories.OUTLET; 7 | } 8 | 9 | constructor(...props) { 10 | super(...props); 11 | } 12 | 13 | _registerPlatformAccessory() { 14 | this._verifyCachedPlatformAccessory(); 15 | this._justRegistered = true; 16 | 17 | super._registerPlatformAccessory(); 18 | } 19 | 20 | _verifyCachedPlatformAccessory() { 21 | if (this._justRegistered) return; 22 | 23 | const {Service} = this.hap; 24 | 25 | const outletName = 'Outlet - ' + this.device.context.name; 26 | let outletService = this.accessory.getServiceByUUIDAndSubType(Service.Outlet, 'outlet'); 27 | if (outletService) this._checkServiceName(outletService, outletName); 28 | else outletService = this.accessory.addService(Service.Outlet, outletName, 'outlet'); 29 | 30 | const lightName = 'RGBTWLight - ' + this.device.context.name; 31 | let lightService = this.accessory.getServiceByUUIDAndSubType(Service.Lightbulb, 'lightbulb'); 32 | if (lightService) this._checkServiceName(lightService, lightName); 33 | else lightService = this.accessory.addService(Service.Lightbulb, lightName, 'lightbulb'); 34 | 35 | this.accessory.services 36 | .forEach(service => { 37 | if ((service.UUID === Service.Outlet.UUID && service !== outletService) || (service.UUID === Service.Lightbulb.UUID && service !== lightService)) 38 | this.accessory.removeService(service); 39 | }); 40 | } 41 | 42 | _registerCharacteristics(dps) { 43 | this._verifyCachedPlatformAccessory(); 44 | 45 | const {Service, Characteristic, EnergyCharacteristics} = this.hap; 46 | 47 | const outletService = this.accessory.getServiceByUUIDAndSubType(Service.Outlet, 'outlet'); 48 | const lightService = this.accessory.getServiceByUUIDAndSubType(Service.Lightbulb, 'lightbulb'); 49 | 50 | this.dpLight = this._getCustomDP(this.device.context.dpLight) || '1'; 51 | this.dpMode = this._getCustomDP(this.device.context.dpMode) || '2'; 52 | this.dpBrightness = this._getCustomDP(this.device.context.dpBrightness) || '3'; 53 | this.dpColorTemperature = this._getCustomDP(this.device.context.dpColorTemperature) || '4'; 54 | this.dpColor = this._getCustomDP(this.device.context.dpColor) || '5'; 55 | 56 | this.dpPower = this._getCustomDP(this.device.context.dpPower) || '101'; 57 | 58 | this._detectColorFunction(dps[this.dpColor]); 59 | 60 | this.cmdWhite = 'white'; 61 | if (this.device.context.cmdWhite) { 62 | if (/^w[a-z]+$/i.test(this.device.context.cmdWhite)) this.cmdWhite = ('' + this.device.context.cmdWhite).trim(); 63 | else throw new Error(`The cmdWhite doesn't appear to be valid: ${this.device.context.cmdWhite}`); 64 | } 65 | 66 | this.cmdColor = 'colour'; 67 | if (this.device.context.cmdColor) { 68 | if (/^c[a-z]+$/i.test(this.device.context.cmdColor)) this.cmdColor = ('' + this.device.context.cmdColor).trim(); 69 | else throw new Error(`The cmdColor doesn't appear to be valid: ${this.device.context.cmdColor}`); 70 | } else if (this.device.context.cmdColour) { 71 | if (/^c[a-z]+$/i.test(this.device.context.cmdColour)) this.cmdColor = ('' + this.device.context.cmdColour).trim(); 72 | else throw new Error(`The cmdColour doesn't appear to be valid: ${this.device.context.cmdColour}`); 73 | } 74 | 75 | const energyKeys = { 76 | volts: this._getCustomDP(this.device.context.voltsId), 77 | voltsDivisor: parseInt(this.device.context.voltsDivisor) || 10, 78 | amps: this._getCustomDP(this.device.context.ampsId), 79 | ampsDivisor: parseInt(this.device.context.ampsDivisor) || 1000, 80 | watts: this._getCustomDP(this.device.context.wattsId), 81 | wattsDivisor: parseInt(this.device.context.wattsDivisor) || 10 82 | }; 83 | 84 | const characteristicLightOn = lightService.getCharacteristic(Characteristic.On) 85 | .updateValue(dps[this.dpLight]) 86 | .on('get', this.getState.bind(this, this.dpLight)) 87 | .on('set', this.setState.bind(this, this.dpLight)); 88 | 89 | const characteristicBrightness = lightService.getCharacteristic(Characteristic.Brightness) 90 | .updateValue(dps[this.dpMode] === this.cmdWhite ? this.convertBrightnessFromTuyaToHomeKit(dps[this.dpBrightness]) : this.convertColorFromTuyaToHomeKit(dps[this.dpColor]).b) 91 | .on('get', this.getBrightness.bind(this)) 92 | .on('set', this.setBrightness.bind(this)); 93 | 94 | const characteristicColorTemperature = lightService.getCharacteristic(Characteristic.ColorTemperature) 95 | .setProps({ 96 | minValue: 0, 97 | maxValue: 600 98 | }) 99 | .updateValue(dps[this.dpMode] === this.cmdWhite ? this.convertColorTemperatureFromTuyaToHomeKit(dps[this.dpColorTemperature]) : 0) 100 | .on('get', this.getColorTemperature.bind(this)) 101 | .on('set', this.setColorTemperature.bind(this)); 102 | 103 | const characteristicHue = lightService.getCharacteristic(Characteristic.Hue) 104 | .updateValue(dps[this.dpMode] === this.cmdWhite ? 0 : this.convertColorFromTuyaToHomeKit(dps[this.dpColor]).h) 105 | .on('get', this.getHue.bind(this)) 106 | .on('set', this.setHue.bind(this)); 107 | 108 | const characteristicSaturation = lightService.getCharacteristic(Characteristic.Saturation) 109 | .updateValue(dps[this.dpMode] === this.cmdWhite ? 0 : this.convertColorFromTuyaToHomeKit(dps[this.dpColor]).s) 110 | .on('get', this.getSaturation.bind(this)) 111 | .on('set', this.setSaturation.bind(this)); 112 | 113 | this.characteristicHue = characteristicHue; 114 | this.characteristicSaturation = characteristicSaturation; 115 | this.characteristicColorTemperature = characteristicColorTemperature; 116 | 117 | let characteristicVolts; 118 | if (energyKeys.volts) { 119 | characteristicVolts = outletService.getCharacteristic(EnergyCharacteristics.Volts) 120 | .updateValue(this._getDividedState(dps[energyKeys.volts], energyKeys.voltsDivisor)) 121 | .on('get', this.getDividedState.bind(this, energyKeys.volts, energyKeys.voltsDivisor)); 122 | } else this._removeCharacteristic(outletService, EnergyCharacteristics.Volts); 123 | 124 | let characteristicAmps; 125 | if (energyKeys.amps) { 126 | characteristicAmps = outletService.getCharacteristic(EnergyCharacteristics.Amperes) 127 | .updateValue(this._getDividedState(dps[energyKeys.amps], energyKeys.ampsDivisor)) 128 | .on('get', this.getDividedState.bind(this, energyKeys.amps, energyKeys.ampsDivisor)); 129 | } else this._removeCharacteristic(outletService, EnergyCharacteristics.Amperes); 130 | 131 | let characteristicWatts; 132 | if (energyKeys.watts) { 133 | characteristicWatts = outletService.getCharacteristic(EnergyCharacteristics.Watts) 134 | .updateValue(this._getDividedState(dps[energyKeys.watts], energyKeys.wattsDivisor)) 135 | .on('get', this.getDividedState.bind(this, energyKeys.watts, energyKeys.wattsDivisor)); 136 | } else this._removeCharacteristic(outletService, EnergyCharacteristics.Watts); 137 | 138 | const characteristicOutletOn = outletService.getCharacteristic(Characteristic.On) 139 | .updateValue(dps[this.dpPower]) 140 | .on('get', this.getState.bind(this, this.dpPower)) 141 | .on('set', this.setState.bind(this, this.dpPower)); 142 | 143 | this.device.on('change', (changes, state) => { 144 | if (changes.hasOwnProperty(this.dpLight) && characteristicLightOn.value !== changes[this.dpLight]) characteristicLightOn.updateValue(changes[this.dpLight]); 145 | 146 | switch (state[this.dpMode]) { 147 | case this.cmdWhite: 148 | if (changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(characteristicBrightness.value) !== changes[this.dpBrightness]) 149 | characteristicBrightness.updateValue(this.convertBrightnessFromTuyaToHomeKit(changes[this.dpBrightness])); 150 | 151 | if (changes.hasOwnProperty(this.dpColorTemperature) && this.convertColorTemperatureFromHomeKitToTuya(characteristicColorTemperature.value) !== changes[this.dpColorTemperature]) { 152 | 153 | const newColorTemperature = this.convertColorTemperatureFromTuyaToHomeKit(changes[this.dpColorTemperature]); 154 | const newColor = this.convertHomeKitColorTemperatureToHomeKitColor(newColorTemperature); 155 | 156 | characteristicHue.updateValue(newColor.h); 157 | characteristicSaturation.updateValue(newColor.s); 158 | characteristicColorTemperature.updateValue(newColorTemperature); 159 | 160 | } else if (changes[this.dpMode] && !changes.hasOwnProperty(this.dpColorTemperature)) { 161 | 162 | const newColorTemperature = this.convertColorTemperatureFromTuyaToHomeKit(state[this.dpColorTemperature]); 163 | const newColor = this.convertHomeKitColorTemperatureToHomeKitColor(newColorTemperature); 164 | 165 | characteristicHue.updateValue(newColor.h); 166 | characteristicSaturation.updateValue(newColor.s); 167 | characteristicColorTemperature.updateValue(newColorTemperature); 168 | } 169 | 170 | break; 171 | 172 | default: 173 | if (changes.hasOwnProperty(this.dpColor)) { 174 | const oldColor = this.convertColorFromTuyaToHomeKit(this.convertColorFromHomeKitToTuya({ 175 | h: characteristicHue.value, 176 | s: characteristicSaturation.value, 177 | b: characteristicBrightness.value 178 | })); 179 | const newColor = this.convertColorFromTuyaToHomeKit(changes[this.dpColor]); 180 | 181 | if (oldColor.b !== newColor.b) characteristicBrightness.updateValue(newColor.b); 182 | if (oldColor.h !== newColor.h) characteristicHue.updateValue(newColor.h); 183 | 184 | if (oldColor.s !== newColor.s) characteristicSaturation.updateValue(newColor.h); 185 | 186 | if (characteristicColorTemperature.value !== 0) characteristicColorTemperature.updateValue(0); 187 | 188 | } else if (changes[this.dpMode]) { 189 | if (characteristicColorTemperature.value !== 0) characteristicColorTemperature.updateValue(0); 190 | } 191 | } 192 | 193 | if (changes.hasOwnProperty(this.dpPower) && characteristicOutletOn.value !== changes[this.dpPower]) characteristicOutletOn.updateValue(changes[this.dpPower]); 194 | 195 | if (changes.hasOwnProperty(energyKeys.volts) && characteristicVolts) { 196 | const newVolts = this._getDividedState(changes[energyKeys.volts], energyKeys.voltsDivisor); 197 | if (characteristicVolts.value !== newVolts) characteristicVolts.updateValue(newVolts); 198 | } 199 | 200 | if (changes.hasOwnProperty(energyKeys.amps) && characteristicAmps) { 201 | const newAmps = this._getDividedState(changes[energyKeys.amps], energyKeys.ampsDivisor); 202 | if (characteristicAmps.value !== newAmps) characteristicAmps.updateValue(newAmps); 203 | } 204 | 205 | if (changes.hasOwnProperty(energyKeys.watts) && characteristicWatts) { 206 | const newWatts = this._getDividedState(changes[energyKeys.watts], energyKeys.wattsDivisor); 207 | if (characteristicWatts.value !== newWatts) characteristicWatts.updateValue(newWatts); 208 | } 209 | }); 210 | } 211 | 212 | getBrightness(callback) { 213 | if (this.device.state[this.dpMode] === this.cmdWhite) return callback(null, this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness])); 214 | callback(null, this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).b); 215 | } 216 | 217 | setBrightness(value, callback) { 218 | if (this.device.state[this.dpMode] === this.cmdWhite) return this.setState(this.dpBrightness, this.convertBrightnessFromHomeKitToTuya(value), callback); 219 | this.setState(this.dpColor, this.convertColorFromHomeKitToTuya({b: value}), callback); 220 | } 221 | 222 | getColorTemperature(callback) { 223 | if (this.device.state[this.dpMode] !== this.cmdWhite) return callback(null, 0); 224 | callback(null, this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature])); 225 | } 226 | 227 | setColorTemperature(value, callback) { 228 | if (value === 0) return callback(null, true); 229 | 230 | const newColor = this.convertHomeKitColorTemperatureToHomeKitColor(value); 231 | this.characteristicHue.updateValue(newColor.h); 232 | this.characteristicSaturation.updateValue(newColor.s); 233 | 234 | // Avoid cross-mode complications due rapid commands 235 | this.device.state[this.dpMode] = this.cmdWhite; 236 | 237 | this.setMultiState({[this.dpMode]: this.cmdWhite, [this.dpColorTemperature]: this.convertColorTemperatureFromHomeKitToTuya(value)}, callback); 238 | } 239 | 240 | getHue(callback) { 241 | if (this.device.state[this.dpMode] === this.cmdWhite) return callback(null, 0); 242 | callback(null, this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).h); 243 | } 244 | 245 | setHue(value, callback) { 246 | this._setHueSaturation({h: value}, callback); 247 | } 248 | 249 | getSaturation(callback) { 250 | if (this.device.state[this.dpMode] === this.cmdWhite) return callback(null, 0); 251 | callback(null, this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).s); 252 | } 253 | 254 | setSaturation(value, callback) { 255 | this._setHueSaturation({s: value}, callback); 256 | } 257 | 258 | _setHueSaturation(prop, callback) { 259 | if (!this._pendingHueSaturation) { 260 | this._pendingHueSaturation = {props: {}, callbacks: []}; 261 | } 262 | 263 | if (prop) { 264 | if (this._pendingHueSaturation.timer) clearTimeout(this._pendingHueSaturation.timer); 265 | 266 | this._pendingHueSaturation.props = {...this._pendingHueSaturation.props, ...prop}; 267 | this._pendingHueSaturation.callbacks.push(callback); 268 | 269 | this._pendingHueSaturation.timer = setTimeout(() => { 270 | this._setHueSaturation(); 271 | }, 500); 272 | return; 273 | } 274 | 275 | //this.characteristicColorTemperature.updateValue(0); 276 | 277 | const callbacks = this._pendingHueSaturation.callbacks; 278 | const callEachBack = err => { 279 | async.eachSeries(callbacks, (callback, next) => { 280 | try { 281 | callback(err); 282 | } catch (ex) {} 283 | next(); 284 | }, () => { 285 | this.characteristicColorTemperature.updateValue(0); 286 | }); 287 | }; 288 | 289 | const isSham = this._pendingHueSaturation.props.h === 0 && this._pendingHueSaturation.props.s === 0; 290 | const newValue = this.convertColorFromHomeKitToTuya(this._pendingHueSaturation.props); 291 | this._pendingHueSaturation = null; 292 | 293 | 294 | if (this.device.state[this.dpMode] === this.cmdWhite && isSham) return callEachBack(); 295 | 296 | // Avoid cross-mode complications due rapid commands 297 | this.device.state[this.dpMode] = this.cmdColor; 298 | 299 | this.setMultiState({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue}, callEachBack); 300 | } 301 | } 302 | 303 | module.exports = RGBTWOutletAccessory; -------------------------------------------------------------------------------- /lib/SimpleBlindsAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | 3 | const BLINDS_OPENING = 'opening'; 4 | const BLINDS_CLOSING = 'closing'; 5 | const BLINDS_STOPPED = 'stopped'; 6 | 7 | const BLINDS_OPEN = 100; 8 | const BLINDS_CLOSED = 0; 9 | 10 | class SimpleBlindsAccessory extends BaseAccessory { 11 | static getCategory(Categories) { 12 | return Categories.WINDOW_COVERING; 13 | } 14 | 15 | constructor(...props) { 16 | super(...props); 17 | } 18 | 19 | _registerPlatformAccessory() { 20 | const {Service} = this.hap; 21 | 22 | this.accessory.addService(Service.WindowCovering, this.device.context.name); 23 | 24 | super._registerPlatformAccessory(); 25 | } 26 | 27 | _registerCharacteristics(dps) { 28 | const {Service, Characteristic} = this.hap; 29 | const service = this.accessory.getService(Service.WindowCovering); 30 | this._checkServiceName(service, this.device.context.name); 31 | 32 | this.dpAction = this._getCustomDP(this.device.context.dpAction) || '1'; 33 | 34 | let _cmdOpen = '1'; 35 | if (this.device.context.cmdOpen) { 36 | _cmdOpen = ('' + this.device.context.cmdOpen).trim(); 37 | } 38 | 39 | let _cmdClose = '2'; 40 | if (this.device.context.cmdClose) { 41 | _cmdClose = ('' + this.device.context.cmdClose).trim(); 42 | } 43 | 44 | this.cmdStop = '3'; 45 | if (this.device.context.cmdStop) { 46 | this.cmdStop = ('' + this.device.context.cmdStop).trim(); 47 | } 48 | 49 | this.cmdOpen = _cmdOpen; 50 | this.cmdClose = _cmdClose; 51 | if (!!this.device.context.flipState) { 52 | this.cmdOpen = _cmdClose; 53 | this.cmdClose = _cmdOpen; 54 | } 55 | 56 | this.duration = parseInt(this.device.context.timeToOpen) || 45; 57 | const endingDuration = parseInt(this.device.context.timeToTighten) || 0; 58 | this.minPosition = endingDuration ? Math.round(endingDuration * -100 / (this.duration - endingDuration)) : BLINDS_CLOSED; 59 | 60 | // If the blinds are closed, note it; if not, assume open because there is no way to know where it is 61 | this.assumedPosition = dps[this.dpAction] === this.cmdClose ? this.minPosition : BLINDS_OPEN; 62 | this.assumedState = BLINDS_STOPPED; 63 | this.changeTime = this.targetPosition = false; 64 | 65 | const characteristicCurrentPosition = service.getCharacteristic(Characteristic.CurrentPosition) 66 | .updateValue(this._getCurrentPosition(dps[this.dpAction])) 67 | .on('get', this.getCurrentPosition.bind(this)); 68 | 69 | const characteristicTargetPosition = service.getCharacteristic(Characteristic.TargetPosition) 70 | .updateValue(this._getTargetPosition(dps[this.dpAction])) 71 | .on('get', this.getTargetPosition.bind(this)) 72 | .on('set', this.setTargetPosition.bind(this)); 73 | 74 | const characteristicPositionState = service.getCharacteristic(Characteristic.PositionState) 75 | .updateValue(this._getPositionState()) 76 | .on('get', this.getPositionState.bind(this)); 77 | 78 | this.device.on('change', changes => { 79 | console.log("[TuyaAccessory] Blinds saw change to " + changes[this.dpAction]); 80 | if (changes.hasOwnProperty(this.dpAction)) { 81 | switch (changes[this.dpAction]) { 82 | case this.cmdOpen: // Starting to open 83 | this.assumedState = BLINDS_OPENING; 84 | characteristicPositionState.updateValue(Characteristic.PositionState.INCREASING); 85 | 86 | // Only if change was external or someone internally asked for open 87 | if (this.targetPosition === false || this.targetPosition === BLINDS_OPEN) { 88 | this.targetPosition = false; 89 | 90 | const durationToOpen = Math.abs(this.assumedPosition - BLINDS_OPEN) * this.duration * 10; 91 | this.changeTime = Date.now() - durationToOpen; 92 | 93 | console.log("[TuyaAccessory] Blinds will be marked open in " + durationToOpen + "ms"); 94 | 95 | if (this.changeTimeout) clearTimeout(this.changeTimeout); 96 | this.changeTimeout = setTimeout(() => { 97 | characteristicCurrentPosition.updateValue(BLINDS_OPEN); 98 | characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED); 99 | this.changeTime = false; 100 | this.assumedPosition = BLINDS_OPEN; 101 | this.assumedState = BLINDS_STOPPED; 102 | console.log("[TuyaAccessory] Blinds marked open"); 103 | }, durationToOpen); 104 | } 105 | break; 106 | 107 | case this.cmdClose: // Starting to close 108 | this.assumedState = BLINDS_CLOSING; 109 | characteristicPositionState.updateValue(Characteristic.PositionState.DECREASING); 110 | 111 | // Only if change was external or someone internally asked for close 112 | if (this.targetPosition === false || this.targetPosition === BLINDS_CLOSED) { 113 | this.targetPosition = false; 114 | 115 | const durationToClose = Math.abs(this.assumedPosition - BLINDS_CLOSED) * this.duration * 10; 116 | this.changeTime = Date.now() - durationToClose; 117 | 118 | console.log("[TuyaAccessory] Blinds will be marked closed in " + durationToClose + "ms"); 119 | 120 | if (this.changeTimeout) clearTimeout(this.changeTimeout); 121 | this.changeTimeout = setTimeout(() => { 122 | characteristicCurrentPosition.updateValue(BLINDS_CLOSED); 123 | characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED); 124 | this.changeTime = false; 125 | this.assumedPosition = this.minPosition; 126 | this.assumedState = BLINDS_STOPPED; 127 | console.log("[TuyaAccessory] Blinds marked closed"); 128 | }, durationToClose); 129 | } 130 | break; 131 | 132 | case this.cmdStop: // Stopped in middle 133 | if (this.changeTimeout) clearTimeout(this.changeTimeout); 134 | 135 | console.log("[TuyaAccessory] Blinds last change was " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago'); 136 | 137 | if (this.changeTime) { 138 | /* 139 | this.assumedPosition = Math.min(100 - this.minPosition, Math.max(0, Math.round((Date.now() - this.changeTime) / (10 * this.duration)))); 140 | if (this.assumedState === BLINDS_CLOSING) this.assumedPosition = 100 - this.assumedPosition; 141 | else this.assumedPosition += this.minPosition; 142 | */ 143 | const disposition = ((Date.now() - this.changeTime) / (10 * this.duration)); 144 | if (this.assumedState === BLINDS_CLOSING) { 145 | this.assumedPosition = BLINDS_OPEN - disposition; 146 | } else { 147 | this.assumedPosition = this.minPosition + disposition; 148 | } 149 | } 150 | 151 | const adjustedPosition = Math.max(0, Math.round(this.assumedPosition)); 152 | characteristicCurrentPosition.updateValue(adjustedPosition); 153 | characteristicTargetPosition.updateValue(adjustedPosition); 154 | characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED); 155 | console.log("[TuyaAccessory] Blinds marked stopped at " + adjustedPosition + "; assumed to be at " + this.assumedPosition); 156 | 157 | this.changeTime = this.targetPosition = false; 158 | this.assumedState = BLINDS_STOPPED; 159 | break; 160 | } 161 | } 162 | }); 163 | } 164 | 165 | getCurrentPosition(callback) { 166 | this.getState(this.dpAction, (err, dp) => { 167 | if (err) return callback(err); 168 | 169 | callback(null, this._getCurrentPosition(dp)); 170 | }); 171 | } 172 | 173 | _getCurrentPosition(dp) { 174 | switch (dp) { 175 | case this.cmdOpen: 176 | return BLINDS_OPEN; 177 | 178 | case this.cmdClose: 179 | return BLINDS_CLOSED; 180 | 181 | default: 182 | return Math.max(BLINDS_CLOSED, Math.round(this.assumedPosition)); 183 | } 184 | } 185 | 186 | getTargetPosition(callback) { 187 | this.getState(this.dpAction, (err, dp) => { 188 | if (err) return callback(err); 189 | 190 | callback(null, this._getTargetPosition(dp)); 191 | }); 192 | } 193 | 194 | _getTargetPosition(dp) { 195 | switch (dp) { 196 | case this.cmdOpen: 197 | return BLINDS_OPEN; 198 | 199 | case this.cmdClose: 200 | return BLINDS_CLOSED; 201 | 202 | default: 203 | return Math.max(BLINDS_CLOSED, Math.round(this.assumedPosition)); 204 | } 205 | } 206 | 207 | setTargetPosition(value, callback) { 208 | console.log('[TuyaAccessory] Blinds asked to move from ' + this.assumedPosition + ' to ' + value); 209 | 210 | if (this.changeTimeout) clearTimeout(this.changeTimeout); 211 | this.targetPosition = value; 212 | 213 | if (this.changeTime !== false) { 214 | console.log("[TuyaAccessory] Blinds " + (this.assumedState === BLINDS_CLOSING ? 'closing' : 'opening') + " had started " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago'); 215 | const disposition = ((Date.now() - this.changeTime) / (10 * this.duration)); 216 | if (this.assumedState === BLINDS_CLOSING) { 217 | this.assumedPosition = BLINDS_OPEN - disposition; 218 | } else { 219 | this.assumedPosition = this.minPosition + disposition; 220 | } 221 | console.log("[TuyaAccessory] Blinds' adjusted assumedPosition is " + this.assumedPosition); 222 | } 223 | 224 | const duration = Math.abs(this.assumedPosition - value) * this.duration * 10; 225 | 226 | if (Math.abs(value - this.assumedPosition) < 1) { 227 | return this.setState(this.dpAction, this.cmdStop, callback); 228 | } else if (value > this.assumedPosition) { 229 | this.assumedState = BLINDS_OPENING; 230 | this.setState(this.dpAction, this.cmdOpen, callback); 231 | this.changeTime = Date.now() - Math.abs(this.assumedPosition - this.minPosition) * this.duration * 10; 232 | } else { 233 | this.assumedState = BLINDS_CLOSING; 234 | this.setState(this.dpAction, this.cmdClose, callback); 235 | this.changeTime = Date.now() - Math.abs(this.assumedPosition - BLINDS_OPEN) * this.duration * 10; 236 | } 237 | 238 | if (value !== BLINDS_OPEN && value !== BLINDS_CLOSED) { 239 | console.log("[TuyaAccessory] Blinds will stop in " + duration + "ms"); 240 | console.log("[TuyaAccessory] Blinds assumed started " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago'); 241 | this.changeTimeout = setTimeout(() => { 242 | console.log("[TuyaAccessory] Blinds asked to stop"); 243 | this.setState(this.dpAction, this.cmdStop); 244 | }, duration); 245 | } 246 | } 247 | 248 | getPositionState(callback) { 249 | const state = this._getPositionState(); 250 | process.nextTick(() => { 251 | callback(null, state); 252 | }); 253 | } 254 | 255 | _getPositionState() { 256 | const {Characteristic} = this.hap; 257 | 258 | switch (this.assumedState) { 259 | case BLINDS_OPENING: 260 | return Characteristic.PositionState.INCREASING; 261 | 262 | case BLINDS_CLOSING: 263 | return Characteristic.PositionState.DECREASING; 264 | 265 | default: 266 | return Characteristic.PositionState.STOPPED; 267 | } 268 | } 269 | } 270 | 271 | module.exports = SimpleBlindsAccessory; -------------------------------------------------------------------------------- /lib/SimpleDimmerAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | 3 | class SimpleDimmerAccessory extends BaseAccessory { 4 | static getCategory(Categories) { 5 | return Categories.LIGHTBULB; 6 | } 7 | 8 | constructor(...props) { 9 | super(...props); 10 | } 11 | 12 | _registerPlatformAccessory() { 13 | const {Service} = this.hap; 14 | 15 | this.accessory.addService(Service.Lightbulb, this.device.context.name); 16 | 17 | super._registerPlatformAccessory(); 18 | } 19 | 20 | _registerCharacteristics(dps) { 21 | const {Service, Characteristic} = this.hap; 22 | const service = this.accessory.getService(Service.Lightbulb); 23 | this._checkServiceName(service, this.device.context.name); 24 | 25 | this.dpPower = this._getCustomDP(this.device.context.dpPower) || '1'; 26 | this.dpBrightness = this._getCustomDP(this.device.context.dpBrightness) || this._getCustomDP(this.device.context.dp) || '2'; 27 | 28 | const characteristicOn = service.getCharacteristic(Characteristic.On) 29 | .updateValue(dps[this.dpPower]) 30 | .on('get', this.getState.bind(this, this.dpPower)) 31 | .on('set', this.setState.bind(this, this.dpPower)); 32 | 33 | const characteristicBrightness = service.getCharacteristic(Characteristic.Brightness) 34 | .updateValue(this.convertBrightnessFromTuyaToHomeKit(dps[this.dpBrightness])) 35 | .on('get', this.getBrightness.bind(this)) 36 | .on('set', this.setBrightness.bind(this)); 37 | 38 | this.device.on('change', changes => { 39 | if (changes.hasOwnProperty(this.dpPower) && characteristicOn.value !== changes[this.dpPower]) characteristicOn.updateValue(changes[this.dpPower]); 40 | if (changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(characteristicBrightness.value) !== changes[this.dpBrightness]) 41 | characteristicBrightness.updateValue(this.convertBrightnessFromTuyaToHomeKit(changes[this.dpBrightness])); 42 | }); 43 | } 44 | 45 | getBrightness(callback) { 46 | callback(null, this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness])); 47 | } 48 | 49 | setBrightness(value, callback) { 50 | this.setState(this.dpBrightness, this.convertBrightnessFromHomeKitToTuya(value), callback); 51 | } 52 | } 53 | 54 | module.exports = SimpleDimmerAccessory; -------------------------------------------------------------------------------- /lib/SimpleHeaterAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | 3 | class SimpleHeaterAccessory extends BaseAccessory { 4 | static getCategory(Categories) { 5 | return Categories.AIR_HEATER; 6 | } 7 | 8 | constructor(...props) { 9 | super(...props); 10 | } 11 | 12 | _registerPlatformAccessory() { 13 | const {Service} = this.hap; 14 | 15 | this.accessory.addService(Service.HeaterCooler, this.device.context.name); 16 | 17 | super._registerPlatformAccessory(); 18 | } 19 | 20 | _registerCharacteristics(dps) { 21 | const {Service, Characteristic} = this.hap; 22 | const service = this.accessory.getService(Service.HeaterCooler); 23 | this._checkServiceName(service, this.device.context.name); 24 | 25 | this.dpActive = this._getCustomDP(this.device.context.dpActive) || '1'; 26 | this.dpDesiredTemperature = this._getCustomDP(this.device.context.dpDesiredTemperature) || '2'; 27 | this.dpCurrentTemperature = this._getCustomDP(this.device.context.dpCurrentTemperature) || '3'; 28 | this.temperatureDivisor = parseInt(this.device.context.temperatureDivisor) || 1; 29 | 30 | const characteristicActive = service.getCharacteristic(Characteristic.Active) 31 | .updateValue(this._getActive(dps[this.dpActive])) 32 | .on('get', this.getActive.bind(this)) 33 | .on('set', this.setActive.bind(this)); 34 | 35 | service.getCharacteristic(Characteristic.CurrentHeaterCoolerState) 36 | .updateValue(this._getCurrentHeaterCoolerState(dps)) 37 | .on('get', this.getCurrentHeaterCoolerState.bind(this)); 38 | 39 | service.getCharacteristic(Characteristic.TargetHeaterCoolerState) 40 | .setProps({ 41 | minValue: 1, 42 | maxValue: 1, 43 | validValues: [Characteristic.TargetHeaterCoolerState.HEAT] 44 | }) 45 | .updateValue(this._getTargetHeaterCoolerState()) 46 | .on('get', this.getTargetHeaterCoolerState.bind(this)) 47 | .on('set', this.setTargetHeaterCoolerState.bind(this)); 48 | 49 | const characteristicCurrentTemperature = service.getCharacteristic(Characteristic.CurrentTemperature) 50 | .updateValue(this._getDividedState(dps[this.dpCurrentTemperature], this.temperatureDivisor)) 51 | .on('get', this.getDividedState.bind(this, this.dpCurrentTemperature, this.temperatureDivisor)); 52 | 53 | 54 | const characteristicHeatingThresholdTemperature = service.getCharacteristic(Characteristic.HeatingThresholdTemperature) 55 | .setProps({ 56 | minValue: this.device.context.minTemperature || 15, 57 | maxValue: this.device.context.maxTemperature || 35, 58 | minStep: this.device.context.minTemperatureSteps || 1 59 | }) 60 | .updateValue(this._getDividedState(dps[this.dpDesiredTemperature], this.temperatureDivisor)) 61 | .on('get', this.getDividedState.bind(this, this.dpDesiredTemperature, this.temperatureDivisor)) 62 | .on('set', this.setTargetThresholdTemperature.bind(this)); 63 | 64 | this.characteristicHeatingThresholdTemperature = characteristicHeatingThresholdTemperature; 65 | 66 | this.device.on('change', (changes, state) => { 67 | if (changes.hasOwnProperty(this.dpActive)) { 68 | const newActive = this._getActive(changes[this.dpActive]); 69 | if (characteristicActive.value !== newActive) { 70 | characteristicActive.updateValue(newActive); 71 | } 72 | } 73 | 74 | if (changes.hasOwnProperty(this.dpDesiredTemperature)) { 75 | if (characteristicHeatingThresholdTemperature.value !== changes[this.dpDesiredTemperature]) 76 | characteristicHeatingThresholdTemperature.updateValue(changes[this.dpDesiredTemperature]); 77 | } 78 | 79 | if (changes.hasOwnProperty(this.dpCurrentTemperature) && characteristicCurrentTemperature.value !== changes[this.dpCurrentTemperature]) characteristicCurrentTemperature.updateValue(changes[this.dpCurrentTemperature]); 80 | 81 | console.log('[TuyaAccessory] SimpleHeater changed: ' + JSON.stringify(state)); 82 | }); 83 | } 84 | 85 | getActive(callback) { 86 | this.getState(this.dpActive, (err, dp) => { 87 | if (err) return callback(err); 88 | 89 | callback(null, this._getActive(dp)); 90 | }); 91 | } 92 | 93 | _getActive(dp) { 94 | const {Characteristic} = this.hap; 95 | 96 | return dp ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE; 97 | } 98 | 99 | setActive(value, callback) { 100 | const {Characteristic} = this.hap; 101 | 102 | switch (value) { 103 | case Characteristic.Active.ACTIVE: 104 | return this.setState(this.dpActive, true, callback); 105 | 106 | case Characteristic.Active.INACTIVE: 107 | return this.setState(this.dpActive, false, callback); 108 | } 109 | 110 | callback(); 111 | } 112 | 113 | getCurrentHeaterCoolerState(callback) { 114 | this.getState([this.dpActive], (err, dps) => { 115 | if (err) return callback(err); 116 | 117 | callback(null, this._getCurrentHeaterCoolerState(dps)); 118 | }); 119 | } 120 | 121 | _getCurrentHeaterCoolerState(dps) { 122 | const {Characteristic} = this.hap; 123 | return dps[this.dpActive] ? Characteristic.CurrentHeaterCoolerState.HEATING : Characteristic.CurrentHeaterCoolerState.INACTIVE; 124 | } 125 | 126 | getTargetHeaterCoolerState(callback) { 127 | callback(null, this._getTargetHeaterCoolerState()); 128 | } 129 | 130 | _getTargetHeaterCoolerState() { 131 | const {Characteristic} = this.hap; 132 | return Characteristic.TargetHeaterCoolerState.HEAT; 133 | } 134 | 135 | setTargetHeaterCoolerState(value, callback) { 136 | this.setState(this.dpActive, true, callback); 137 | } 138 | 139 | setTargetThresholdTemperature(value, callback) { 140 | this.setState(this.dpDesiredTemperature, value * this.temperatureDivisor, err => { 141 | if (err) return callback(err); 142 | 143 | if (this.characteristicHeatingThresholdTemperature) { 144 | this.characteristicHeatingThresholdTemperature.updateValue(value); 145 | } 146 | 147 | callback(); 148 | }); 149 | } 150 | } 151 | 152 | module.exports = SimpleHeaterAccessory; -------------------------------------------------------------------------------- /lib/SimpleLightAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | 3 | class SimpleLightAccessory extends BaseAccessory { 4 | static getCategory(Categories) { 5 | return Categories.LIGHTBULB; 6 | } 7 | 8 | constructor(...props) { 9 | super(...props); 10 | } 11 | 12 | _registerPlatformAccessory() { 13 | const {Service} = this.hap; 14 | 15 | this.accessory.addService(Service.Lightbulb, this.device.context.name); 16 | 17 | super._registerPlatformAccessory(); 18 | } 19 | 20 | _registerCharacteristics(dps) { 21 | const {Service, Characteristic} = this.hap; 22 | const service = this.accessory.getService(Service.Lightbulb); 23 | this._checkServiceName(service, this.device.context.name); 24 | 25 | this.dpPower = this._getCustomDP(this.device.context.dpPower) || '1'; 26 | 27 | const characteristicOn = service.getCharacteristic(Characteristic.On) 28 | .updateValue(dps[this.dpPower]) 29 | .on('get', this.getState.bind(this, this.dpPower)) 30 | .on('set', this.setState.bind(this, this.dpPower)); 31 | 32 | this.device.on('change', (changes, state) => { 33 | if (changes.hasOwnProperty(this.dpPower) && characteristicOn.value !== changes[this.dpPower]) characteristicOn.updateValue(changes[this.dpPower]); 34 | console.log('[TuyaAccessory] SimpleLight changed: ' + JSON.stringify(state)); 35 | }); 36 | } 37 | } 38 | 39 | module.exports = SimpleLightAccessory; -------------------------------------------------------------------------------- /lib/TWLightAccessory.js: -------------------------------------------------------------------------------- 1 | const BaseAccessory = require('./BaseAccessory'); 2 | const async = require('async'); 3 | 4 | class TWLightAccessory extends BaseAccessory { 5 | static getCategory(Categories) { 6 | return Categories.LIGHTBULB; 7 | } 8 | 9 | constructor(...props) { 10 | super(...props); 11 | } 12 | 13 | _registerPlatformAccessory() { 14 | const {Service} = this.hap; 15 | 16 | this.accessory.addService(Service.Lightbulb, this.device.context.name); 17 | 18 | super._registerPlatformAccessory(); 19 | } 20 | 21 | _registerCharacteristics(dps) { 22 | const {Service, Characteristic} = this.hap; 23 | const service = this.accessory.getService(Service.Lightbulb); 24 | this._checkServiceName(service, this.device.context.name); 25 | 26 | this.dpPower = this._getCustomDP(this.device.context.dpPower) || '1'; 27 | this.dpBrightness = this._getCustomDP(this.device.context.dpBrightness) || '2'; 28 | this.dpColorTemperature = this._getCustomDP(this.device.context.dpColorTemperature) || '3'; 29 | 30 | const characteristicOn = service.getCharacteristic(Characteristic.On) 31 | .updateValue(dps[this.dpPower]) 32 | .on('get', this.getState.bind(this, this.dpPower)) 33 | .on('set', this.setState.bind(this, this.dpPower)); 34 | 35 | const characteristicBrightness = service.getCharacteristic(Characteristic.Brightness) 36 | .updateValue(this.convertBrightnessFromTuyaToHomeKit(dps[this.dpBrightness])) 37 | .on('get', this.getBrightness.bind(this)) 38 | .on('set', this.setBrightness.bind(this)); 39 | 40 | const characteristicColorTemperature = service.getCharacteristic(Characteristic.ColorTemperature) 41 | .setProps({ 42 | minValue: 0, 43 | maxValue: 600 44 | }) 45 | .updateValue(this.convertColorTemperatureFromTuyaToHomeKit(dps[this.dpColorTemperature])) 46 | .on('get', this.getColorTemperature.bind(this)) 47 | .on('set', this.setColorTemperature.bind(this)); 48 | 49 | this.characteristicColorTemperature = characteristicColorTemperature; 50 | 51 | this.device.on('change', (changes, state) => { 52 | if (changes.hasOwnProperty(this.dpPower) && characteristicOn.value !== changes[this.dpPower]) characteristicOn.updateValue(changes[this.dpPower]); 53 | 54 | if (changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(characteristicBrightness.value) !== changes[this.dpBrightness]) 55 | characteristicBrightness.updateValue(this.convertBrightnessFromTuyaToHomeKit(changes[this.dpBrightness])); 56 | 57 | if (changes.hasOwnProperty(this.dpColorTemperature)) { 58 | if (this.convertColorTemperatureFromHomeKitToTuya(characteristicColorTemperature.value) !== changes[this.dpColorTemperature]) 59 | characteristicColorTemperature.updateValue(this.convertColorTemperatureFromTuyaToHomeKit(changes[this.dpColorTemperature])); 60 | } else if (changes[this.dpBrightness]) { 61 | characteristicColorTemperature.updateValue(this.convertColorTemperatureFromTuyaToHomeKit(state[this.dpColorTemperature])); 62 | } 63 | }); 64 | } 65 | 66 | getBrightness(callback) { 67 | return callback(null, this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness])); 68 | } 69 | 70 | setBrightness(value, callback) { 71 | return this.setState(this.dpBrightness, this.convertBrightnessFromHomeKitToTuya(value), callback); 72 | } 73 | 74 | getColorTemperature(callback) { 75 | callback(null, this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature])); 76 | } 77 | 78 | setColorTemperature(value, callback) { 79 | if (value === 0) return callback(null, true); 80 | 81 | this.setState(this.dpColorTemperature, this.convertColorTemperatureFromHomeKitToTuya(value), callback); 82 | } 83 | } 84 | 85 | module.exports = TWLightAccessory; -------------------------------------------------------------------------------- /lib/TuyaAccessory.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | const async = require('async'); 3 | const crypto = require('crypto'); 4 | const EventEmitter = require('events'); 5 | 6 | const isNonEmptyPlainObject = o => { 7 | if (!o) return false; 8 | for (let i in o) return true; 9 | return false; 10 | }; 11 | 12 | class TuyaAccessory extends EventEmitter { 13 | constructor(props) { 14 | super(); 15 | 16 | if (!(props.id && props.key && props.ip) && !props.fake) return console.log('[TuyaAccessory] Insufficient details to initialize:', props); 17 | 18 | this.context = {version: '3.1', port: 6668, ...props}; 19 | 20 | this.state = {}; 21 | this._cachedBuffer = Buffer.allocUnsafe(0); 22 | 23 | this._msgQueue = async.queue(this[this.context.version < 3.2 ? '_msgHandler_3_1': '_msgHandler_3_3'].bind(this), 1); 24 | 25 | if (this.context.version >= 3.2) { 26 | this.context.pingGap = Math.min(this.context.pingGap || 9, 9); 27 | console.log(`[TuyaAccessory] Changing ping gap for ${this.context.name} to ${this.context.pingGap}s`); 28 | } 29 | 30 | this.connected = false; 31 | if (props.connect !== false) this._connect(); 32 | 33 | this._connectionAttempts = 0; 34 | this._sendCounter = 0; 35 | } 36 | 37 | _connect() { 38 | if (this.context.fake) { 39 | this.connected = true; 40 | return setTimeout(() => { 41 | this.emit('change', {}, this.state); 42 | }, 1000); 43 | } 44 | 45 | this._socket = net.Socket(); 46 | 47 | this._incrementAttemptCounter(); 48 | 49 | (this._socket.reconnect = () => { 50 | if (this._socket._pinger) { 51 | clearTimeout(this._socket._pinger); 52 | this._socket._pinger = null; 53 | } 54 | 55 | if (this._socket._connTimeout) { 56 | clearTimeout(this._socket._connTimeout); 57 | this._socket._connTimeout = null; 58 | } 59 | 60 | this._socket.setKeepAlive(true); 61 | this._socket.setNoDelay(true); 62 | 63 | this._socket._connTimeout = setTimeout(() => { 64 | this._socket.emit('error', new Error('ERR_CONNECTION_TIMED_OUT')); 65 | //this._socket.destroy(); 66 | //process.nextTick(this._connect.bind(this)); 67 | }, (this.context.connectTimeout || 30) * 1000); 68 | 69 | this._socket.connect(this.context.port, this.context.ip); 70 | })(); 71 | 72 | this._socket._ping = () => { 73 | if (this._socket._pinger) clearTimeout(this._socket._pinger); 74 | this._socket._pinger = setTimeout(() => { 75 | //Retry ping 76 | this._socket._pinger = setTimeout(() => { 77 | this._socket.emit('error', new Error('ERR_PING_TIMED_OUT')); 78 | }, 5000); 79 | 80 | this._send({ 81 | cmd: 9 82 | }); 83 | }, (this.context.pingTimeout || 30) * 1000); 84 | 85 | this._send({ 86 | cmd: 9 87 | }); 88 | }; 89 | 90 | this._socket.on('connect', () => { 91 | clearTimeout(this._socket._connTimeout); 92 | 93 | this.connected = true; 94 | this.emit('connect'); 95 | setTimeout(() => this._socket._ping(), 1000); 96 | 97 | 98 | if (this.context.intro === false) { 99 | this.emit('change', {}, this.state); 100 | process.nextTick(this.update.bind(this)); 101 | } 102 | }); 103 | 104 | this._socket.on('ready', () => { 105 | if (this.context.intro === false) return; 106 | this.connected = true; 107 | this.update(); 108 | }); 109 | 110 | this._socket.on('data', msg => { 111 | this._cachedBuffer = Buffer.concat([this._cachedBuffer, msg]); 112 | 113 | do { 114 | let startingIndex = this._cachedBuffer.indexOf('000055aa', 'hex'); 115 | if (startingIndex === -1) { 116 | this._cachedBuffer = Buffer.allocUnsafe(0); 117 | break; 118 | } 119 | if (startingIndex !== 0) this._cachedBuffer = this._cachedBuffer.slice(startingIndex); 120 | 121 | let endingIndex = this._cachedBuffer.indexOf('0000aa55', 'hex'); 122 | if (endingIndex === -1) break; 123 | 124 | endingIndex += 4; 125 | 126 | this._msgQueue.push({msg: this._cachedBuffer.slice(0, endingIndex)}); 127 | 128 | this._cachedBuffer = this._cachedBuffer.slice(endingIndex); 129 | } while (this._cachedBuffer.length); 130 | }); 131 | 132 | this._socket.on('error', err => { 133 | this.connected = false; 134 | console.log(`[TuyaAccessory] Socket had a problem and will reconnect to ${this.context.name} (${err && err.code || err})`); 135 | 136 | if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE') && this._connectionAttempts < 10) { 137 | return process.nextTick(this._socket.reconnect.bind(this)); 138 | } 139 | 140 | this._socket.destroy(); 141 | 142 | let delay = 5000; 143 | if (err) { 144 | if (err.code === 'ENOBUFS') { 145 | console.warn('[TuyaAccessory] Operating system complained of resource exhaustion; did I open too many sockets?'); 146 | console.log('[TuyaAccessory] Slowing down retry attempts; if you see this happening often, it could mean some sort of incompatibility.'); 147 | delay = 60000; 148 | } else if (this._connectionAttempts > 10) { 149 | console.log('[TuyaAccessory] Slowing down retry attempts; if you see this happening often, it could mean some sort of incompatibility.'); 150 | delay = 60000; 151 | } 152 | } 153 | 154 | setTimeout(() => { 155 | process.nextTick(this._connect.bind(this)); 156 | }, delay); 157 | }); 158 | 159 | this._socket.on('close', err => { 160 | this.connected = false; 161 | //console.log('[TuyaAccessory] Closed connection with', this.context.name); 162 | }); 163 | 164 | this._socket.on('end', () => { 165 | this.connected = false; 166 | console.log('[TuyaAccessory] Disconnected from', this.context.name); 167 | }); 168 | } 169 | 170 | _incrementAttemptCounter() { 171 | this._connectionAttempts++; 172 | setTimeout(() => { 173 | this._connectionAttempts--; 174 | }, 10000); 175 | } 176 | 177 | _msgHandler_3_1(task, callback) { 178 | if (!(task.msg instanceof Buffer)) return callback(); 179 | 180 | const len = task.msg.length; 181 | if (len < 16 || 182 | task.msg.readUInt32BE(0) !== 0x000055aa || 183 | task.msg.readUInt32BE(len - 4) !== 0x0000aa55 184 | ) return callback(); 185 | 186 | const size = task.msg.readUInt32BE(12); 187 | if (len - 8 < size) return callback(); 188 | 189 | const cmd = task.msg.readUInt32BE(8); 190 | let data = task.msg.slice(len - size, len - 8).toString('utf8').trim().replace(/\0/g, ''); 191 | 192 | if (this.context.intro === false && cmd !== 9) 193 | console.log('[TuyaAccessory] Message from', this.context.name + ':', data); 194 | 195 | switch (cmd) { 196 | case 7: 197 | // ignoring 198 | break; 199 | 200 | case 9: 201 | if (this._socket._pinger) clearTimeout(this._socket._pinger); 202 | this._socket._pinger = setTimeout(() => { 203 | this._socket._ping(); 204 | }, (this.context.pingGap || 20) * 1000); 205 | break; 206 | 207 | case 8: 208 | let decryptedMsg; 209 | try { 210 | const decipher = crypto.createDecipheriv('aes-128-ecb', this.context.key, ''); 211 | decryptedMsg = decipher.update(data.substr(19), 'base64', 'utf8'); 212 | decryptedMsg += decipher.final('utf8'); 213 | } catch(ex) { 214 | decryptedMsg = data.substr(19).toString('utf8'); 215 | } 216 | 217 | try { 218 | data = JSON.parse(decryptedMsg); 219 | } catch (ex) { 220 | data = decryptedMsg; 221 | console.log(`[TuyaAccessory] Odd message from ${this.context.name} with command ${cmd}:`, data); 222 | console.log(`[TuyaAccessory] Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex')); 223 | break; 224 | } 225 | 226 | if (data && data.dps) { 227 | //console.log('[TuyaAccessory] Update from', this.context.name, 'with command', cmd + ':', data.dps); 228 | this._change(data.dps); 229 | } 230 | break; 231 | 232 | case 10: 233 | if (data) { 234 | if (data === 'json obj data unvalid' || data === 'data format error') { 235 | console.log(`[TuyaAccessory] ${this.context.name} (${this.context.version}) didn't respond with its current state.`); 236 | this.emit('change', {}, this.state); 237 | break; 238 | } 239 | 240 | try { 241 | data = JSON.parse(data); 242 | } catch (ex) { 243 | console.log(`[TuyaAccessory] Malformed update from ${this.context.name} with command ${cmd}:`, data); 244 | console.log(`[TuyaAccessory] Raw update from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex')); 245 | break; 246 | } 247 | 248 | if (data && data.dps) this._change(data.dps); 249 | } 250 | break; 251 | 252 | default: 253 | console.log(`[TuyaAccessory] Odd message from ${this.context.name} with command ${cmd}:`, data); 254 | console.log(`[TuyaAccessory] Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex')); 255 | } 256 | 257 | callback(); 258 | } 259 | 260 | _msgHandler_3_3(task, callback) { 261 | if (!(task.msg instanceof Buffer)) return callback; 262 | 263 | const len = task.msg.length; 264 | if (len < 16 || 265 | task.msg.readUInt32BE(0) !== 0x000055aa || 266 | task.msg.readUInt32BE(len - 4) !== 0x0000aa55 267 | ) return callback(); 268 | 269 | const size = task.msg.readUInt32BE(12); 270 | if (len - 8 < size) return callback(); 271 | 272 | const cmd = task.msg.readUInt32BE(8); 273 | 274 | if (cmd === 7) return callback(); // ignoring 275 | if (cmd === 9) { 276 | if (this._socket._pinger) clearTimeout(this._socket._pinger); 277 | this._socket._pinger = setTimeout(() => { 278 | this._socket._ping(); 279 | }, (this.context.pingGap || 20) * 1000); 280 | 281 | return callback(); 282 | } 283 | 284 | let versionPos = task.msg.indexOf('3.3'); 285 | if (versionPos === -1) versionPos = task.msg.indexOf('3.2'); 286 | const cleanMsg = task.msg.slice(versionPos === -1 ? len - size + ((task.msg.readUInt32BE(16) & 0xFFFFFF00) ? 0 : 4) : 15 + versionPos, len - 8); 287 | 288 | let decryptedMsg; 289 | try { 290 | const decipher = crypto.createDecipheriv('aes-128-ecb', this.context.key, ''); 291 | decryptedMsg = decipher.update(cleanMsg, 'buffer', 'utf8'); 292 | decryptedMsg += decipher.final('utf8'); 293 | } catch (ex) { 294 | decryptedMsg = cleanMsg.toString('utf8'); 295 | } 296 | 297 | if (cmd === 10 && (decryptedMsg === 'json obj data unvalid' || decryptedMsg === 'data format error')) { 298 | console.log(`[TuyaAccessory] ${this.context.name} (${this.context.version}) didn't respond with its current state.`); 299 | this.emit('change', {}, this.state); 300 | return callback(); 301 | } 302 | 303 | let data; 304 | try { 305 | data = JSON.parse(decryptedMsg); 306 | } catch(ex) { 307 | console.log(`[TuyaAccessory] Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg); 308 | console.log(`[TuyaAccessory] Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex')); 309 | return callback(); 310 | } 311 | 312 | switch (cmd) { 313 | case 8: 314 | case 10: 315 | if (data) { 316 | if (data.dps) { 317 | console.log(`[TuyaAccessory] Heard back from ${this.context.name} with command ${cmd}`); 318 | this._change(data.dps); 319 | } else { 320 | console.log(`[TuyaAccessory] Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg); 321 | console.log(`[TuyaAccessory] Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex')); 322 | } 323 | } 324 | break; 325 | 326 | default: 327 | console.log(`[TuyaAccessory] Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg); 328 | console.log(`[TuyaAccessory] Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex')); 329 | } 330 | 331 | callback(); 332 | } 333 | 334 | update(o) { 335 | const dps = {}; 336 | let hasDataPoint = false; 337 | o && Object.keys(o).forEach(key => { 338 | if (!isNaN(key)) { 339 | dps['' + key] = o[key]; 340 | hasDataPoint = true; 341 | } 342 | }); 343 | 344 | if (this.context.fake) { 345 | if (hasDataPoint) this._fakeUpdate(dps); 346 | return true; 347 | } 348 | 349 | let result = false; 350 | if (hasDataPoint) { 351 | console.log("[TuyaAccessory] Sending", this.context.name, JSON.stringify(dps)); 352 | result = this._send({ 353 | data: { 354 | devId: this.context.id, 355 | uid: '', 356 | t: (Date.now() / 1000).toFixed(0), 357 | dps: dps 358 | }, 359 | cmd: 7 360 | }); 361 | if (result !== true) console.log("[TuyaAccessory] Result", result); 362 | if (this.context.sendEmptyUpdate) { 363 | console.log("[TuyaAccessory] Sending", this.context.name, 'empty signature'); 364 | this._send({cmd: 7}); 365 | } 366 | } else { 367 | console.log(`[TuyaAccessory] Sending first query to ${this.context.name} (${this.context.version})`); 368 | result = this._send({ 369 | data: { 370 | gwId: this.context.id, 371 | devId: this.context.id 372 | }, 373 | cmd: 10 374 | }); 375 | } 376 | 377 | return result; 378 | } 379 | 380 | _change(data) { 381 | if (!isNonEmptyPlainObject(data)) return; 382 | 383 | const changes = {}; 384 | Object.keys(data).forEach(key => { 385 | if (data[key] !== this.state[key]) { 386 | changes[key] = data[key]; 387 | } 388 | }); 389 | 390 | if (isNonEmptyPlainObject(changes)) { 391 | this.state = {...this.state, ...data}; 392 | this.emit('change', changes, this.state); 393 | } 394 | } 395 | 396 | _send(o) { 397 | if (this.context.fake) return; 398 | if (!this.connected) return false; 399 | 400 | if (this.context.version < 3.2) return this._send_3_1(o); 401 | return this._send_3_3(o); 402 | } 403 | 404 | _send_3_1(o) { 405 | const {cmd, data} = {...o}; 406 | 407 | let msg = ''; 408 | 409 | //data 410 | if (data) { 411 | switch (cmd) { 412 | case 7: 413 | const cipher = crypto.createCipheriv('aes-128-ecb', this.context.key, ''); 414 | let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'base64'); 415 | encrypted += cipher.final('base64'); 416 | 417 | const hash = crypto.createHash('md5').update(`data=${encrypted}||lpv=${this.context.version}||${this.context.key}`, 'utf8').digest('hex').substr(8, 16); 418 | 419 | msg = this.context.version + hash + encrypted; 420 | break; 421 | 422 | case 10: 423 | msg = JSON.stringify(data); 424 | break; 425 | 426 | } 427 | } 428 | 429 | const payload = Buffer.from(msg); 430 | const prefix = Buffer.from('000055aa00000000000000' + cmd.toString(16).padStart(2, '0'), 'hex'); 431 | const suffix = Buffer.concat([payload, Buffer.from('000000000000aa55', 'hex')]); 432 | 433 | const len = Buffer.allocUnsafe(4); 434 | len.writeInt32BE(suffix.length, 0); 435 | 436 | return this._socket.write(Buffer.concat([prefix, len, suffix])); 437 | } 438 | 439 | _send_3_3(o) { 440 | const {cmd, data} = {...o}; 441 | 442 | // If sending empty dp-update command, we should not increment the sequence 443 | if (cmd !== 7 || data) this._sendCounter++; 444 | 445 | const hex = [ 446 | '000055aa', //header 447 | this._sendCounter.toString(16).padStart(8, '0'), //sequence 448 | cmd.toString(16).padStart(8, '0'), //command 449 | '00000000' //size 450 | ]; 451 | //version 452 | if (cmd === 7 && !data) hex.push('00000000'); 453 | else if (cmd !== 9 && cmd !== 10) hex.push('332e33000000000000000000000000'); 454 | //data 455 | if (data) { 456 | const cipher = crypto.createCipheriv('aes-128-ecb', this.context.key, ''); 457 | let encrypted = cipher.update(Buffer.from(JSON.stringify(data)), 'utf8', 'hex'); 458 | encrypted += cipher.final('hex'); 459 | hex.push(encrypted); 460 | } 461 | //crc32 462 | hex.push('00000000'); 463 | //tail 464 | hex.push('0000aa55'); 465 | 466 | const payload = Buffer.from(hex.join(''), 'hex'); 467 | //length 468 | payload.writeUInt32BE(payload.length - 16, 12); 469 | //crc 470 | payload.writeInt32BE(getCRC32(payload.slice(0, payload.length - 8)), payload.length - 8); 471 | 472 | return this._socket.write(payload); 473 | } 474 | 475 | _fakeUpdate(dps) { 476 | console.log('[TuyaAccessory] Fake update:', JSON.stringify(dps)); 477 | Object.keys(dps).forEach(dp => { 478 | this.state[dp] = dps[dp]; 479 | }); 480 | setTimeout(() => { 481 | this.emit('change', dps, this.state); 482 | }, 1000); 483 | } 484 | } 485 | 486 | const crc32LookupTable = []; 487 | (() => { 488 | for (let i = 0; i < 256; i++) { 489 | let crc = i; 490 | for (let j = 8; j > 0; j--) crc = (crc & 1) ? (crc >>> 1) ^ 3988292384 : crc >>> 1; 491 | crc32LookupTable.push(crc); 492 | } 493 | })(); 494 | 495 | const getCRC32 = buffer => { 496 | let crc = 0xffffffff; 497 | for (let i = 0, len = buffer.length; i < len; i++) crc = crc32LookupTable[buffer[i] ^ (crc & 0xff)] ^ (crc >>> 8); 498 | return ~crc; 499 | }; 500 | 501 | 502 | module.exports = TuyaAccessory; -------------------------------------------------------------------------------- /lib/TuyaDiscovery.js: -------------------------------------------------------------------------------- 1 | const dgram = require('dgram'); 2 | const crypto = require('crypto'); 3 | const EventEmitter = require('events'); 4 | 5 | const UDP_KEY = Buffer.from('6c1ec8e2bb9bb59ab50b0daf649b410a', 'hex'); 6 | 7 | class TuyaDiscovery extends EventEmitter { 8 | constructor() { 9 | super(); 10 | 11 | this.discovered = new Map(); 12 | this.limitedIds = []; 13 | this._servers = {}; 14 | this._running = false; 15 | } 16 | 17 | start(props) { 18 | const opts = props || {}; 19 | 20 | if (opts.clear) { 21 | this.removeAllListeners(); 22 | this.discovered.clear(); 23 | } 24 | 25 | this.limitedIds.splice(0); 26 | if (Array.isArray(opts.ids)) this.limitedIds = [].push.apply(this.limitedIds, opts.ids); 27 | 28 | this._running = true; 29 | this._start(6666); 30 | this._start(6667); 31 | 32 | return this; 33 | } 34 | 35 | stop() { 36 | this._running = false; 37 | this._stop(6666); 38 | this._stop(6667); 39 | 40 | return this; 41 | } 42 | 43 | end() { 44 | this.stop(); 45 | process.nextTick(() => { 46 | this.removeAllListeners(); 47 | this.discovered.clear(); 48 | console.log('[TuyaAccessory] Discovery ended.'); 49 | this.emit('end'); 50 | }); 51 | 52 | return this; 53 | } 54 | 55 | _start(port) { 56 | this._stop(port); 57 | 58 | const server = this._servers[port] = dgram.createSocket({type: 'udp4', reuseAddr: true}); 59 | server.on('error', this._onDgramError.bind(this, port)); 60 | server.on('close', this._onDgramClose.bind(this, port)); 61 | server.on('message', this._onDgramMessage.bind(this, port)); 62 | 63 | server.bind(port, () => { 64 | console.log(`[TuyaDiscovery] Discovery started on port ${port}.`); 65 | }); 66 | } 67 | 68 | _stop(port) { 69 | if (this._servers[port]) { 70 | this._servers[port].removeAllListeners(); 71 | this._servers[port].close(); 72 | this._servers[port] = null; 73 | } 74 | } 75 | 76 | _onDgramError(port, err) { 77 | this._stop(port); 78 | 79 | if (err && err.code === 'EADDRINUSE') { 80 | console.warn(`[TuyaDiscovery] Port ${port} is in use. Will retry in 15 seconds.`); 81 | 82 | setTimeout(() => { 83 | this._start(port); 84 | }, 15000); 85 | } else { 86 | console.error(`[TuyaDiscovery] Port ${port} failed:\n${err.stack}`); 87 | } 88 | } 89 | 90 | _onDgramClose(port) { 91 | this._stop(port); 92 | 93 | console.info(`[TuyaDiscovery] Port ${port} closed.${this._running ? ' Restarting...' : ''}`); 94 | if (this._running) 95 | setTimeout(() => { 96 | this._start(port); 97 | }, 1000); 98 | } 99 | 100 | _onDgramMessage(port, msg, info) { 101 | const len = msg.length; 102 | console.log(`[TuyaDiscovery] UDP from ${info.address}:${port} 0x${msg.readUInt32BE(0).toString(16).padStart(8, '0')}...0x${msg.readUInt32BE(len - 4).toString(16).padStart(8, '0')}`); 103 | if (len < 16 || 104 | msg.readUInt32BE(0) !== 0x000055aa || 105 | msg.readUInt32BE(len - 4) !== 0x0000aa55 106 | ) { 107 | console.log(`[TuyaDiscovery] ERROR: UDP from ${info.address}:${port}`, msg.toString('hex')); 108 | return; 109 | } 110 | 111 | const size = msg.readUInt32BE(12); 112 | if (len - size < 8) { 113 | console.log(`[TuyaDiscovery] ERROR: UDP from ${info.address}:${port} size ${len - size}`); 114 | return; 115 | } 116 | 117 | //const result = {cmd: msg.readUInt32BE(8)}; 118 | const cleanMsg = msg.slice(len - size + 4, len - 8); 119 | 120 | let decryptedMsg; 121 | if (port === 6667) { 122 | try { 123 | const decipher = crypto.createDecipheriv('aes-128-ecb', UDP_KEY, ''); 124 | decryptedMsg = decipher.update(cleanMsg, 'utf8', 'utf8'); 125 | decryptedMsg += decipher.final('utf8'); 126 | } catch (ex) {} 127 | } 128 | 129 | if (!decryptedMsg) decryptedMsg = cleanMsg.toString('utf8'); 130 | 131 | try { 132 | const result = JSON.parse(decryptedMsg); 133 | if (result && result.gwId && result.ip) this._onDiscover(result); 134 | else console.log(`[TuyaDiscovery] ERROR: UDP from ${info.address}:${port} decrypted`, cleanMsg.toString('hex')); 135 | } catch (ex) { 136 | console.error(`[TuyaDiscovery] Failed to parse discovery response on port ${port}: ${decryptedMsg}`); 137 | console.error(`[TuyaDiscovery] Failed to parse discovery raw message on port ${port}: ${msg.toString('hex')}`); 138 | } 139 | } 140 | 141 | _onDiscover(data) { 142 | if (this.discovered.has(data.gwId)) return; 143 | 144 | data.id = data.gwId; 145 | delete data.gwId; 146 | 147 | this.discovered.set(data.id, data.ip); 148 | 149 | this.emit('discover', data); 150 | 151 | if (this.limitedIds.length && 152 | this.limitedIds.includes(data.id) && // Just to avoid checking the rest unnecessarily 153 | this.limitedIds.length <= this.discovered.size && 154 | this.limitedIds.every(id => this.discovered.has(id)) 155 | ) { 156 | process.nextTick(() => { 157 | this.end(); 158 | }); 159 | } 160 | } 161 | } 162 | 163 | module.exports = new TuyaDiscovery(); -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-tuya-lan", 3 | "version": "1.5.0-rc.12", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@babel/runtime": { 8 | "version": "7.6.0", 9 | "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.6.0.tgz", 10 | "integrity": "sha512-89eSBLJsxNxOERC0Op4vd+0Bqm6wRMqMbFtV3i0/fbaWw/mJ8Q3eBvgX0G4SyrOOLCtbu98HspF8o09MRT+KzQ==", 11 | "requires": { 12 | "regenerator-runtime": "^0.13.2" 13 | } 14 | }, 15 | "ansi-regex": { 16 | "version": "4.1.0", 17 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", 18 | "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" 19 | }, 20 | "ansi-styles": { 21 | "version": "3.2.1", 22 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", 23 | "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", 24 | "requires": { 25 | "color-convert": "^1.9.0" 26 | } 27 | }, 28 | "async": { 29 | "version": "3.1.0", 30 | "resolved": "https://registry.npmjs.org/async/-/async-3.1.0.tgz", 31 | "integrity": "sha512-4vx/aaY6j/j3Lw3fbCHNWP0pPaTCew3F6F3hYyl/tHs/ndmV1q7NW9T5yuJ2XAGwdQrP+6Wu20x06U4APo/iQQ==" 32 | }, 33 | "async-limiter": { 34 | "version": "1.0.1", 35 | "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", 36 | "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" 37 | }, 38 | "camelcase": { 39 | "version": "5.3.1", 40 | "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", 41 | "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" 42 | }, 43 | "cliui": { 44 | "version": "5.0.0", 45 | "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", 46 | "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", 47 | "requires": { 48 | "string-width": "^3.1.0", 49 | "strip-ansi": "^5.2.0", 50 | "wrap-ansi": "^5.1.0" 51 | } 52 | }, 53 | "color-convert": { 54 | "version": "1.9.3", 55 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", 56 | "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", 57 | "requires": { 58 | "color-name": "1.1.3" 59 | } 60 | }, 61 | "color-name": { 62 | "version": "1.1.3", 63 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", 64 | "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" 65 | }, 66 | "commander": { 67 | "version": "3.0.1", 68 | "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.1.tgz", 69 | "integrity": "sha512-UNgvDd+csKdc9GD4zjtkHKQbT8Aspt2jCBqNSPp53vAS0L1tS9sXB2TCEOPHJ7kt9bN/niWkYj8T3RQSoMXdSQ==" 70 | }, 71 | "debug": { 72 | "version": "4.1.1", 73 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", 74 | "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", 75 | "requires": { 76 | "ms": "^2.1.1" 77 | } 78 | }, 79 | "decamelize": { 80 | "version": "1.2.0", 81 | "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", 82 | "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" 83 | }, 84 | "dijkstrajs": { 85 | "version": "1.0.1", 86 | "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.1.tgz", 87 | "integrity": "sha1-082BIh4+pAdCz83lVtTpnpjdxxs=" 88 | }, 89 | "emoji-regex": { 90 | "version": "7.0.3", 91 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", 92 | "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" 93 | }, 94 | "eshark": { 95 | "version": "github:hashedhyphen/eshark#73d6e99a324d73222e5f1b1a554bc420c3290715", 96 | "from": "github:hashedhyphen/eshark" 97 | }, 98 | "ether-frame": { 99 | "version": "0.2.0", 100 | "resolved": "https://registry.npmjs.org/ether-frame/-/ether-frame-0.2.0.tgz", 101 | "integrity": "sha1-YkmE1EluwPuDRiORJW5XbIzRI0o=", 102 | "requires": { 103 | "mac-address": "~0.3.0" 104 | } 105 | }, 106 | "find-up": { 107 | "version": "3.0.0", 108 | "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", 109 | "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", 110 | "requires": { 111 | "locate-path": "^3.0.0" 112 | } 113 | }, 114 | "fs-extra": { 115 | "version": "8.1.0", 116 | "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", 117 | "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", 118 | "requires": { 119 | "graceful-fs": "^4.2.0", 120 | "jsonfile": "^4.0.0", 121 | "universalify": "^0.1.0" 122 | } 123 | }, 124 | "get-caller-file": { 125 | "version": "2.0.5", 126 | "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 127 | "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" 128 | }, 129 | "graceful-fs": { 130 | "version": "4.2.2", 131 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.2.tgz", 132 | "integrity": "sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==" 133 | }, 134 | "http-mitm-proxy": { 135 | "version": "0.8.1", 136 | "resolved": "https://registry.npmjs.org/http-mitm-proxy/-/http-mitm-proxy-0.8.1.tgz", 137 | "integrity": "sha512-Rpx/ueMdOV4guj5qBxhyBJR/6+mi90TukQbMl1JmprHkDzY3DQd/ivRwejIej/GWIO+RtBMgIrT7gB0HeKdhQg==", 138 | "requires": { 139 | "async": "^2.6.2", 140 | "debug": "^4.1.0", 141 | "mkdirp": "^0.5.1", 142 | "node-forge": "^0.8.4", 143 | "optimist": "^0.6.1", 144 | "semaphore": "^1.1.0", 145 | "ws": "^3.2.0" 146 | }, 147 | "dependencies": { 148 | "async": { 149 | "version": "2.6.3", 150 | "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", 151 | "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", 152 | "requires": { 153 | "lodash": "^4.17.14" 154 | } 155 | } 156 | } 157 | }, 158 | "is-fullwidth-code-point": { 159 | "version": "2.0.0", 160 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", 161 | "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" 162 | }, 163 | "isarray": { 164 | "version": "2.0.5", 165 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", 166 | "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" 167 | }, 168 | "json5": { 169 | "version": "2.1.0", 170 | "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.0.tgz", 171 | "integrity": "sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ==", 172 | "requires": { 173 | "minimist": "^1.2.0" 174 | }, 175 | "dependencies": { 176 | "minimist": { 177 | "version": "1.2.0", 178 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", 179 | "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" 180 | } 181 | } 182 | }, 183 | "jsonfile": { 184 | "version": "4.0.0", 185 | "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", 186 | "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", 187 | "requires": { 188 | "graceful-fs": "^4.1.6" 189 | } 190 | }, 191 | "locate-path": { 192 | "version": "3.0.0", 193 | "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", 194 | "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", 195 | "requires": { 196 | "p-locate": "^3.0.0", 197 | "path-exists": "^3.0.0" 198 | } 199 | }, 200 | "lodash": { 201 | "version": "4.17.15", 202 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", 203 | "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" 204 | }, 205 | "mac-address": { 206 | "version": "0.3.0", 207 | "resolved": "https://registry.npmjs.org/mac-address/-/mac-address-0.3.0.tgz", 208 | "integrity": "sha1-SMUEnH1CGENh3meQVAAU6oG8LFE=" 209 | }, 210 | "minimist": { 211 | "version": "0.0.8", 212 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 213 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" 214 | }, 215 | "mkdirp": { 216 | "version": "0.5.1", 217 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 218 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 219 | "requires": { 220 | "minimist": "0.0.8" 221 | } 222 | }, 223 | "ms": { 224 | "version": "2.1.2", 225 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", 226 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" 227 | }, 228 | "node-forge": { 229 | "version": "0.8.5", 230 | "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.8.5.tgz", 231 | "integrity": "sha512-vFMQIWt+J/7FLNyKouZ9TazT74PRV3wgv9UT4cRjC8BffxFbKXkgIWR42URCPSnHm/QDz6BOlb2Q0U4+VQT67Q==" 232 | }, 233 | "optimist": { 234 | "version": "0.6.1", 235 | "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", 236 | "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", 237 | "requires": { 238 | "minimist": "~0.0.1", 239 | "wordwrap": "~0.0.2" 240 | } 241 | }, 242 | "p-limit": { 243 | "version": "2.2.1", 244 | "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", 245 | "integrity": "sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg==", 246 | "requires": { 247 | "p-try": "^2.0.0" 248 | } 249 | }, 250 | "p-locate": { 251 | "version": "3.0.0", 252 | "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", 253 | "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", 254 | "requires": { 255 | "p-limit": "^2.0.0" 256 | } 257 | }, 258 | "p-try": { 259 | "version": "2.2.0", 260 | "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", 261 | "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" 262 | }, 263 | "path-exists": { 264 | "version": "3.0.0", 265 | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", 266 | "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" 267 | }, 268 | "pcap-ng-parser": { 269 | "version": "1.0.0", 270 | "resolved": "https://registry.npmjs.org/pcap-ng-parser/-/pcap-ng-parser-1.0.0.tgz", 271 | "integrity": "sha512-ySJiSEHAaFZXkwTVj2vSTFQSfwAqg9+uiCdasz2UbIaJRH9xq/MRJwzvWWZxH0ByxJhNB8wDRdrSWrKgfI7Dwg==", 272 | "requires": { 273 | "ether-frame": "^0.2.0" 274 | } 275 | }, 276 | "pcap-parser": { 277 | "version": "0.2.1", 278 | "resolved": "https://registry.npmjs.org/pcap-parser/-/pcap-parser-0.2.1.tgz", 279 | "integrity": "sha1-Cnl66BDCj/CM6RCLxQnTI7/mqm8=" 280 | }, 281 | "pngjs": { 282 | "version": "3.4.0", 283 | "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", 284 | "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==" 285 | }, 286 | "qrcode": { 287 | "version": "1.4.1", 288 | "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.4.1.tgz", 289 | "integrity": "sha512-3JhHQJkKqJL4PfoM6t+B40f0GWv9eNJAJmuNx2X/sHEOLvMyvEPN8GfbdN1qmr19O8N2nLraOzeWjXocHz1S4w==", 290 | "requires": { 291 | "dijkstrajs": "^1.0.1", 292 | "isarray": "^2.0.1", 293 | "pngjs": "^3.3.0", 294 | "yargs": "^13.2.4" 295 | } 296 | }, 297 | "regenerator-runtime": { 298 | "version": "0.13.3", 299 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz", 300 | "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==" 301 | }, 302 | "require-directory": { 303 | "version": "2.1.1", 304 | "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 305 | "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" 306 | }, 307 | "require-main-filename": { 308 | "version": "2.0.0", 309 | "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", 310 | "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" 311 | }, 312 | "safe-buffer": { 313 | "version": "5.1.2", 314 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", 315 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" 316 | }, 317 | "semaphore": { 318 | "version": "1.1.0", 319 | "resolved": "https://registry.npmjs.org/semaphore/-/semaphore-1.1.0.tgz", 320 | "integrity": "sha512-O4OZEaNtkMd/K0i6js9SL+gqy0ZCBMgUvlSqHKi4IBdjhe7wB8pwztUk1BbZ1fmrvpwFrPbHzqd2w5pTcJH6LA==" 321 | }, 322 | "set-blocking": { 323 | "version": "2.0.0", 324 | "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", 325 | "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" 326 | }, 327 | "string-width": { 328 | "version": "3.1.0", 329 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", 330 | "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", 331 | "requires": { 332 | "emoji-regex": "^7.0.1", 333 | "is-fullwidth-code-point": "^2.0.0", 334 | "strip-ansi": "^5.1.0" 335 | } 336 | }, 337 | "strip-ansi": { 338 | "version": "5.2.0", 339 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", 340 | "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", 341 | "requires": { 342 | "ansi-regex": "^4.1.0" 343 | } 344 | }, 345 | "ultron": { 346 | "version": "1.1.1", 347 | "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", 348 | "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==" 349 | }, 350 | "universalify": { 351 | "version": "0.1.2", 352 | "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", 353 | "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" 354 | }, 355 | "which-module": { 356 | "version": "2.0.0", 357 | "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", 358 | "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" 359 | }, 360 | "wordwrap": { 361 | "version": "0.0.3", 362 | "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", 363 | "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" 364 | }, 365 | "wrap-ansi": { 366 | "version": "5.1.0", 367 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", 368 | "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", 369 | "requires": { 370 | "ansi-styles": "^3.2.0", 371 | "string-width": "^3.0.0", 372 | "strip-ansi": "^5.0.0" 373 | } 374 | }, 375 | "ws": { 376 | "version": "3.3.3", 377 | "resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz", 378 | "integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==", 379 | "requires": { 380 | "async-limiter": "~1.0.0", 381 | "safe-buffer": "~5.1.0", 382 | "ultron": "~1.1.0" 383 | } 384 | }, 385 | "y18n": { 386 | "version": "4.0.0", 387 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", 388 | "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==" 389 | }, 390 | "yaml": { 391 | "version": "1.6.0", 392 | "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.6.0.tgz", 393 | "integrity": "sha512-iZfse3lwrJRoSlfs/9KQ9iIXxs9++RvBFVzAqbbBiFT+giYtyanevreF9r61ZTbGMgWQBxAua3FzJiniiJXWWw==", 394 | "requires": { 395 | "@babel/runtime": "^7.4.5" 396 | } 397 | }, 398 | "yargs": { 399 | "version": "13.3.0", 400 | "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.0.tgz", 401 | "integrity": "sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA==", 402 | "requires": { 403 | "cliui": "^5.0.0", 404 | "find-up": "^3.0.0", 405 | "get-caller-file": "^2.0.1", 406 | "require-directory": "^2.1.1", 407 | "require-main-filename": "^2.0.0", 408 | "set-blocking": "^2.0.0", 409 | "string-width": "^3.0.0", 410 | "which-module": "^2.0.0", 411 | "y18n": "^4.0.0", 412 | "yargs-parser": "^13.1.1" 413 | } 414 | }, 415 | "yargs-parser": { 416 | "version": "13.1.1", 417 | "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz", 418 | "integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==", 419 | "requires": { 420 | "camelcase": "^5.0.0", 421 | "decamelize": "^1.2.0" 422 | } 423 | } 424 | } 425 | } 426 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-tuya-lan", 3 | "version": "1.5.0-rc.12", 4 | "description": "Homebridge plugin for IoT devices that use Tuya Smart's platform", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "bin": { 10 | "tuya-lan": "./bin/cli.js", 11 | "tuya-lan-find": "./bin/cli-find.js", 12 | "tuya-lan-decode": "./bin/cli-decode.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/AMoo-Miki/homebridge-tuya-lan.git" 17 | }, 18 | "author": "AMoo-Miki", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/AMoo-Miki/homebridge-tuya-lan/issues" 22 | }, 23 | "homepage": "https://github.com/AMoo-Miki/homebridge-tuya-lan#readme", 24 | "dependencies": { 25 | "async": "^3.1.0", 26 | "commander": "^3.0.1", 27 | "fs-extra": "^8.1.0", 28 | "http-mitm-proxy": "^0.8.1", 29 | "json5": "^2.1.0", 30 | "qrcode": "^1.4.1", 31 | "yaml": "^1.6.0" 32 | }, 33 | "keywords": [ 34 | "homebridge-plugin", 35 | "homebridge", 36 | "lohas", 37 | "tuya" 38 | ], 39 | "engines": { 40 | "homebridge": ">=0.4.0", 41 | "node": ">=8.6.0" 42 | } 43 | } 44 | --------------------------------------------------------------------------------