diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ccbe46 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d58d574 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +## smal lib for kasa veho (and tiktek?) smart bluetooth lightblibs + +small library for kasa veho lightbulbs and others like it. + +could not have finished this if it were not for +[https://mjg59.dreamwidth.org/43722.html](this blog post) and +[https://github.com/mjg59/python-tikteck](this python library) by +[https://twitter.com/mjg59](@mjg59). so thank you a billion times for writing +that and digging into that disassembled .so while i dug into nothing but shallow +graves + +you'll note that this is nearly a carbon copy of their library + +i couldn't run that because i wasn't on a loonix machine, so now this has +happened and should work on lanux and macos maybe even windows if the stars +align. relies on the wnoderful [noble](https://github.com/sandeepmistry/noble) + +## use: + +* with a bulb you know + +```js +const {pair, setColor} = require('kasa') +pair({ + name: 'Smart Light', + password: 'password obtained from adb lolcat', + address: 'ca:fe:0f:be:ef' // address of light +}, dispatch => { + // red, green, blue, brightness + dispatch(setColor(0xff, 0x2a, 0x50, 0xff)) +}) +``` + +* with a bulb you've never met: + +```js +const {discover, pair, setColor} = require('kasa') +discover({ + name: 'Smart Light', // defaults to Smart Light + password: 'get this from adb lolcat' +}, blub => { + console.log('a real lootblub!', blub.address) + pair(blub, dispatch => { + dispatch(setColor(0x33, 0xcc, 0xff, 0xff)) + }) +}) +``` + +* there is also a promise api (note that you will only get the first bulb + discovered this way, but the callback will keep calling you up with every + light it finds) + +```js +const {discover, pair, setColor} = require('kasa') + +discover() + .then(pair) + .then(dispatch => dispatch(setColor(0xff, 0xff, 0xff, 0xff))) +``` + +there are some silly examples in the directory called `play/` + +## todo: + +* clean up the code (vague!) +* fix vague todo items (which?) +* find out why my tongue has lumps on it +* revise the api when i have had literally any sleep in the past 3 years +* get rich, die old +* get to the top of Tom's top 8 +* wear sunscreen +* learn the xaphoon +* see how lb is doin +* switch to crypto-js and pull parts out and see if this can run in web browsers diff --git a/index.js b/index.js new file mode 100644 index 0000000..6825db9 --- /dev/null +++ b/index.js @@ -0,0 +1,173 @@ +const crypto = require('crypto') +const noble = require('noble') + +const nobleReady = new Promise(resolve => + noble.on('stateChange', state => { + state === 'poweredOn' && resolve() + }) +) + +const range = to => Array(to).fill().map((_, i) => i) + +function encrypt (key, data) { + key = Buffer.from(key) + key.reverse() + data = Buffer.from(data) + data.reverse() + const cipher = crypto.createCipheriv('aes-128-ecb', key, Buffer.from([])) + const encryptedData = cipher.update(data).reverse() + return encryptedData +} + +function generateSk (name, password, data1, data2) { + name = Buffer.from(name.padEnd(16, '\u0000')) + password = Buffer.from(password.padEnd(16, '\u0000')) + const key = [] + name.forEach((byte, index) => { + key.push(byte ^ password[index]) + }) + const data = [...data1.slice(0, 8), ...data2.slice(0, 8)] + return encrypt(key, data) +} + +function encryptKey (name, password, data) { + name = Buffer.from(name.padEnd(16, '\u0000')) + password = Buffer.from(password.padEnd(16, '\u0000')) + const key = [] + key.forEach.call(name, (byte, index) => { + key.push(byte ^ password[index]) + }) + return encrypt(data, key) +} + +// mutate me mor +function encryptPacket (sk, mac, packet) { + let tmp = [...mac.slice(0, 4), 0x01, ...packet.slice(0, 3), 15, 0, 0, 0, 0, 0, 0, 0] + tmp = encrypt(sk, tmp) + + range(15).forEach(i => { + tmp[i] = tmp[i] ^ packet[i + 5] + }) + + tmp = encrypt(sk, tmp) + + range(2).forEach(i => { + packet[i + 3] = tmp[i] + }) + + tmp = [0, ...mac.slice(0, 4), 0x01, ...packet.slice(0, 3), 0, 0, 0, 0, 0, 0, 0] + + tmp2 = [] + + range(15).forEach(i => { + if (i === 0) { + tmp2 = encrypt(sk, tmp) + tmp[0] = tmp[0] + 1 + } + packet[i + 5] ^= tmp2[i] + }) + + return Buffer.from(packet) +} + +function connect (light, callback) { + return light.connect(() => callback(light)) +} + +function discover (options = {}, callback) { + if (arguments.length === 1) { + if (typeof arguments[0] === 'function') { + callback = arguments[0] + options = {} + } + } + const {name = 'Smart Light', password = '234', address} = options + const discovery = new Promise(resolve => { + noble.on('discover', thing => { + if (thing.advertisement.localName !== name) return + thing.password = password + if (address) { + address === thing.address && connect(thing, (...args) => { + callback && callback(...args) + resolve(...args) + }) + } else { + connect(thing, (...args) => { + callback && callback(...args) + resolve(...args) + }) + } + }) + }) + noble.startScanning() + return discovery +} + +function pair (light, callback) { + let packetCount = Math.random() * 0xffff | 0 + const name = light.name || light.advertisement.localName + const password = light.password + const mac = light.address + return new Promise(resolve => { + light.discoverAllServicesAndCharacteristics(() => { + const commandChar = light.services[1].characteristics[1] + const pairChar = light.services[1].characteristics[3] + const data = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0, 0, 0, 0, 0, 0, 0, 0] + const encryptedKey = encryptKey(name, password, data) + const packet = [0x0c] + .concat(data.slice(0, 8)) + .concat([...encryptedKey].slice(0, 8)) + + pairChar.write(new Buffer(packet), true, () => { + pairChar.read((error, received) => { + const sk = generateSk(name, password, data.slice(0, 8), received.slice(1, 9)) + function dispatch ([id, command, data], callback) { + const packet = Array(20).fill(0) + packet[0] = packetCount & 0xff + packet[1] = packetCount >> 8 & 0xff + packet[5] = id & 0xff + packet[6] = id & 0xff | 0x80 + packet[7] = command + packet[8] = 0x69 + packet[9] = 0x69 + packet[10] = data[0] + packet[11] = data[1] + packet[12] = data[2] + packet[13] = data[3] + const macKey = Buffer.from(mac.split(':').slice(0, 6).reverse().map(n => parseInt(n, 16))) + const encryptedPacket = encryptPacket(sk, macKey, [...packet]) + packetCount = packetCount > 0xffff ? 1 : packetCount + 1 + return new Promise(resolve => { + commandChar.write(encryptedPacket, false, (...args) => { + callback && callback(...args) + resolve(...args) + }) + }) + } + callback && callback(dispatch) + resolve(dispatch) + }) + }) + }) + }) +} + +function sendCommand (command, ...args) { + return [0xffff, command, args] +} + +// red, green, blue, brightness +function setColor (...values) { + return sendCommand(0xc1, ...values) +} + +function setDefaultColor (...values) { + return sendCommand(0xc4, ...values) +} + +module.exports = { + discover: (...args) => nobleReady.then(() => discover(...args)), + pair, + setColor, + setDefaultColor +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d9f9179 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,427 @@ +{ + "name": "kasa", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "accepts": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz", + "integrity": "sha1-w8p0NJOGSMPg2cHjKN1otiLChMo=", + "optional": true, + "requires": { + "mime-types": "2.1.15", + "negotiator": "0.6.1" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=", + "optional": true + }, + "body-parser": { + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.17.2.tgz", + "integrity": "sha1-+IkqvI+eYn1Crtr7yma/WrmRBO4=", + "optional": true, + "requires": { + "bytes": "2.4.0", + "content-type": "1.0.2", + "debug": "2.6.7", + "depd": "1.1.0", + "http-errors": "1.6.1", + "iconv-lite": "0.4.15", + "on-finished": "2.3.0", + "qs": "6.4.0", + "raw-body": "2.2.0", + "type-is": "1.6.15" + } + }, + "bplist-parser": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.0.6.tgz", + "integrity": "sha1-ONo0cYF9+dRKs4kuJ3B7u9daEbk=", + "optional": true + }, + "bytes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.4.0.tgz", + "integrity": "sha1-fZcZb51br39pNeJZhVSe3SpsIzk=" + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=", + "optional": true + }, + "content-type": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz", + "integrity": "sha1-t9ETrueo3Se9IRM8TcJSnfFyHu0=", + "optional": true + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=", + "optional": true + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", + "optional": true + }, + "debug": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.7.tgz", + "integrity": "sha1-krrR9tBbu2u6Isyoi80OyJTChh4=", + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz", + "integrity": "sha1-4b2Cxqq2ztlluXuIsX7T5SjKGMM=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "encodeurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz", + "integrity": "sha1-eePVhlU0aQn+bw9Fpd5oEDspTSA=" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "etag": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.0.tgz", + "integrity": "sha1-b2Ma7zNtbEY2K1F2QETOIWvjwFE=" + }, + "express": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.15.3.tgz", + "integrity": "sha1-urZdDwOqgMNYQIly/HAPkWlEtmI=", + "optional": true, + "requires": { + "accepts": "1.3.3", + "array-flatten": "1.1.1", + "content-disposition": "0.5.2", + "content-type": "1.0.2", + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "debug": "2.6.7", + "depd": "1.1.0", + "encodeurl": "1.0.1", + "escape-html": "1.0.3", + "etag": "1.8.0", + "finalhandler": "1.0.3", + "fresh": "0.5.0", + "merge-descriptors": "1.0.1", + "methods": "1.1.2", + "on-finished": "2.3.0", + "parseurl": "1.3.1", + "path-to-regexp": "0.1.7", + "proxy-addr": "1.1.4", + "qs": "6.4.0", + "range-parser": "1.2.0", + "send": "0.15.3", + "serve-static": "1.12.3", + "setprototypeof": "1.0.3", + "statuses": "1.3.1", + "type-is": "1.6.15", + "utils-merge": "1.0.0", + "vary": "1.1.1" + } + }, + "finalhandler": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.0.3.tgz", + "integrity": "sha1-70fneVDpmXgOhgIqVg4yF+DQzIk=", + "optional": true, + "requires": { + "debug": "2.6.7", + "encodeurl": "1.0.1", + "escape-html": "1.0.3", + "on-finished": "2.3.0", + "parseurl": "1.3.1", + "statuses": "1.3.1", + "unpipe": "1.0.0" + } + }, + "forwarded": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.0.tgz", + "integrity": "sha1-Ge+YdMSuHCl7zweP3mOgm2aoQ2M=", + "optional": true + }, + "fresh": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.0.tgz", + "integrity": "sha1-9HTKXmqSRtb9jglTz6m5yAWvp44=" + }, + "http-errors": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.1.tgz", + "integrity": "sha1-X4uO2YrKVFZWv1cplzh/kEpyIlc=", + "requires": { + "depd": "1.1.0", + "inherits": "2.0.3", + "setprototypeof": "1.0.3", + "statuses": "1.3.1" + } + }, + "iconv-lite": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.15.tgz", + "integrity": "sha1-/iZaIYrGpXz+hUkn6dBMGYJe3es=" + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ipaddr.js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.3.0.tgz", + "integrity": "sha1-HgOlL9rYOou7KyXL9JmLTP/NPew=", + "optional": true + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "optional": true + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", + "optional": true + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "optional": true + }, + "mime": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz", + "integrity": "sha1-EV+eO2s9rylZmDyzjxSaLUDrXVM=" + }, + "mime-db": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.27.0.tgz", + "integrity": "sha1-gg9XIpa70g7CXtVeW13oaeVDbrE=" + }, + "mime-types": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz", + "integrity": "sha1-pOv1BkCUVpI3uM9wBGd20J/JKu0=", + "requires": { + "mime-db": "1.27.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "nan": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.6.2.tgz", + "integrity": "sha1-5P805slf37WuzAjeZZb0NgWn20U=", + "optional": true + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=", + "optional": true + }, + "noble": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/noble/-/noble-1.8.1.tgz", + "integrity": "sha1-7+iAgStyUa+qrbT+g3U8EwKmc/Y=", + "requires": { + "bplist-parser": "0.0.6", + "debug": "2.2.0", + "xpc-connection": "0.1.4" + }, + "dependencies": { + "debug": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", + "requires": { + "ms": "0.7.1" + } + }, + "ms": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=" + } + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "parseurl": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz", + "integrity": "sha1-yKuMkiO6NIiKpkopeyiFO+wY2lY=" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=", + "optional": true + }, + "proxy-addr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-1.1.4.tgz", + "integrity": "sha1-J+VF9pYKRKYn2bREZ+NcG2tM4vM=", + "optional": true, + "requires": { + "forwarded": "0.1.0", + "ipaddr.js": "1.3.0" + } + }, + "qs": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz", + "integrity": "sha1-E+JtKK1rD/qpExLNO/cI7TUecjM=", + "optional": true + }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" + }, + "raw-body": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.2.0.tgz", + "integrity": "sha1-mUl2z2pQlqQRYoQEkvC9xdbn+5Y=", + "optional": true, + "requires": { + "bytes": "2.4.0", + "iconv-lite": "0.4.15", + "unpipe": "1.0.0" + } + }, + "send": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/send/-/send-0.15.3.tgz", + "integrity": "sha1-UBP5+ZAj31DRvZiSwZ4979HVMwk=", + "requires": { + "debug": "2.6.7", + "depd": "1.1.0", + "destroy": "1.0.4", + "encodeurl": "1.0.1", + "escape-html": "1.0.3", + "etag": "1.8.0", + "fresh": "0.5.0", + "http-errors": "1.6.1", + "mime": "1.3.4", + "ms": "2.0.0", + "on-finished": "2.3.0", + "range-parser": "1.2.0", + "statuses": "1.3.1" + } + }, + "serve-static": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.12.3.tgz", + "integrity": "sha1-n0uhni8wMMVH+K+ZEHg47DjVseI=", + "optional": true, + "requires": { + "encodeurl": "1.0.1", + "escape-html": "1.0.3", + "parseurl": "1.3.1", + "send": "0.15.3" + } + }, + "setprototypeof": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", + "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=" + }, + "statuses": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", + "integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4=" + }, + "tinycolor2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz", + "integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=", + "optional": true + }, + "tinygradient": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-0.3.1.tgz", + "integrity": "sha1-LZ5PvjSMSX7RHih6QzaDWz/RECI=", + "optional": true, + "requires": { + "tinycolor2": "1.4.1" + } + }, + "type-is": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.15.tgz", + "integrity": "sha1-yrEPtJCeRByChC6v4a1kbIGARBA=", + "optional": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "2.1.15" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "optional": true + }, + "utils-merge": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz", + "integrity": "sha1-ApT7kiu5N1FTVBxPcJYjHyh8ivg=", + "optional": true + }, + "vary": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.1.tgz", + "integrity": "sha1-Z1Neu2lMHVIldFeYRmUyP1h+jTc=", + "optional": true + }, + "xpc-connection": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/xpc-connection/-/xpc-connection-0.1.4.tgz", + "integrity": "sha1-3Nf6oq7Gt6bhjMXdrQQvejTHcVY=", + "optional": true, + "requires": { + "nan": "2.6.2" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bf2a183 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "kasa", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "noble": "^1.8.1" + }, + "optionalDependencies": { + "body-parser": "^1.17.2", + "express": "^4.15.3", + "tinygradient": "^0.3.1" + } +} diff --git a/play/cli.js b/play/cli.js new file mode 100644 index 0000000..a634fe9 --- /dev/null +++ b/play/cli.js @@ -0,0 +1,51 @@ +const {createInterface} = require('readline') +const {discover, pair, setColor} = require('..') + +const blubs = [] +const paired = [] + +const prompt = 'hex color: ' +const firstPrompt = `pls give me a ${prompt.slice(0, -2)} (like #ff2a50): ` +const readline = createInterface({ + input: process.stdin, + output: process.stdout, + prompt: firstPrompt +}) + +const ready = new Promise(resolve => { + discover(blub => { + const {address} = blub + if (!paired.includes(address)) { + pair(blub, dispatch => { + resolve() + blubs.push(dispatch) + paired.push(address) + }) + } + }) +}) + +const chunk = (size, sequence) => { + const chunks = [] + let index = 0 + while (index < sequence.length) { + chunks.push(sequence.slice(index, index += size)) + } + return chunks +} + +const parse = string => + chunk(2, string.replace(/^#/, '')).map(hex => parseInt(hex, 16)) + +console.log('looking for blubs...') + +readline.on('line', line => { + const color = parse(line.trim()) + // as a little bonus, you can pass a color like #ff2a50cc + // where the last byte is the brightness :O + blubs.forEach(dispatch => dispatch(setColor(...color, 0xff))) + readline.setPrompt(prompt) + readline.prompt() +}) + +ready.then(() => readline.prompt()) diff --git a/play/go.js b/play/go.js new file mode 100644 index 0000000..d59281c --- /dev/null +++ b/play/go.js @@ -0,0 +1,19 @@ +const Blub = require('..') +const blubs = [] +const paired = [] + +Blub.discover(blub => { + const {address} = blub + if (!paired.includes(address)) { + Blub.pair(blub, dispatch => { + console.log(`got a ${address}`) + blubs.push(dispatch) + paired.push(address) + }) + } +}) + +const randomByte = () => Math.random() * 0xf00 | 0 + +setInterval(() => + blubs.forEach(dispatch => dispatch(Blub.setColor(randomByte(), randomByte(), randomByte(), 0xff))), 250) diff --git a/play/promise/blue.js b/play/promise/blue.js new file mode 100644 index 0000000..f3339e3 --- /dev/null +++ b/play/promise/blue.js @@ -0,0 +1,7 @@ +const {discover, setColor, pair} = require('../..') + +const setColorWithDispatch = (...color) => dispatch => dispatch(setColor(...color, 0xff)) + +discover() + .then(pair) + .then(setColorWithDispatch(0x33, 0x99, 0xff)) diff --git a/play/socket.js b/play/socket.js new file mode 100644 index 0000000..00c93eb --- /dev/null +++ b/play/socket.js @@ -0,0 +1,38 @@ +const fs = require('fs') +const net = require('net') +const Blub = require('..') +const blubs = [] +const paired = [] + +Blub.discover(blub => { + const {address} = blub + if (!paired.includes(address)) { + Blub.pair(blub, dispatch => { + blubs.push(dispatch) + paired.push(address) + }) + } +}) + +const path = '/tmp/blub' + +fs.unlink(path, () => { + const server = net.createServer(connection => { + connection.on('data', data => { + const hexmatch = data.toString().match(/^#([\da-z]{2})([\da-z]{2})([\da-z]{2})$/) + const rgbmatch = data.toString().match(/^rgb\(\s*(\d+),\s*(\d+),\s*(\d+)\)$/) + const brightness = 0xaa + let red, green, blue + if (hexmatch) { + [red, green, blue] = hexmatch.slice(1).map(n => parseInt(n, 16)) + } + if (rgbmatch) { + [red, green, blue] = rgbmatch.slice(1) + } + blubs.forEach(dispatch => dispatch(Blub.setColor(red, green, blue, brightness))) + }) + }).listen(path, () => { + console.log('server bound on %s', path) + }) +}) + diff --git a/play/socket/? b/play/socket/? new file mode 100644 index 0000000..db2b2df --- /dev/null +++ b/play/socket/? @@ -0,0 +1 @@ +these files require '../socket' to be running diff --git a/play/socket/http.js b/play/socket/http.js new file mode 100644 index 0000000..c253b8d --- /dev/null +++ b/play/socket/http.js @@ -0,0 +1,17 @@ +// note: this one requires ./socket to be running +const net = require('net') +const express = require('express') +const bodyParser = require('body-parser') +const app = express() +const socket = net.connect('/tmp/blub') + +app.use(express.static('public')) +app.use(bodyParser.json()) +app.use(bodyParser.urlencoded({extended: true})) +app.post('/colors', function (request, response) { + socket.write(request.body.color) + response.send('thanks!') +}) +console.log('check http://localhost:8018/') +app.listen(8018) + diff --git a/play/socket/public/index.html b/play/socket/public/index.html new file mode 100644 index 0000000..25bcf03 --- /dev/null +++ b/play/socket/public/index.html @@ -0,0 +1,48 @@ + +did you know that to be a valid html5 document you only need a doctype and a title + + +

select a colour and then please press please

+
+ + +
diff --git a/play/socket/rainbow-gradient.js b/play/socket/rainbow-gradient.js new file mode 100644 index 0000000..8fdc9af --- /dev/null +++ b/play/socket/rainbow-gradient.js @@ -0,0 +1,26 @@ +const net = require('net') +const socket = net.connect('/tmp/blub') +const tinygradient = require('tinygradient') +const sleep = require('../util/sleep') + +const rainbow = tinygradient([ + '#ff2217', + '#ffaa11', + '#eeee22', + '#33ff66', + '#33ccff', + '#1111ee', + 'purple', + '#9933ec', + '#ff22ee', + '#ff2255' +]).rgb(200) + +;(async () => { + while (true) { + for (const color of rainbow) { + socket.write(color.toHexString()) + await sleep(7 * 7 * 2) + } + } +})() diff --git a/play/socket/rainbow.js b/play/socket/rainbow.js new file mode 100644 index 0000000..b2b643f --- /dev/null +++ b/play/socket/rainbow.js @@ -0,0 +1,20 @@ +const net = require('net') +const sleep = require('../util/sleep') +const socket = net.connect('/tmp/blub') + +const colors = [ + '#ff2217', + '#ff4522', + '#ffff00', + '#33ff66', + '#33ccff', + '#1111ee', + '#9933ec', + '#ff2a50' +] + +;(async function rainbow (index = 0) { + socket.write(colors[index]) + await sleep(500) + rainbow(index === colors.length - 1 ? 0 : index + 1) +})() diff --git a/play/socket/vap.js b/play/socket/vap.js new file mode 100644 index 0000000..0efd0ad --- /dev/null +++ b/play/socket/vap.js @@ -0,0 +1,11 @@ +const net = require('net') +const socket = net.connect('/tmp/blub') +const sleep = require('../util/sleep') +const pink = '#f8caff' +const blue = '#72f0ca' + +;(async function aesthetic (color = pink) { + socket.write(color) + await sleep(3000) + aesthetic(color === pink ? blue : pink) +})() diff --git a/play/util/sleep.js b/play/util/sleep.js new file mode 100644 index 0000000..bfb666f --- /dev/null +++ b/play/util/sleep.js @@ -0,0 +1 @@ +module.exports = (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms))