From 224fb07f10b34a60235190d1a98266d74a74573a Mon Sep 17 00:00:00 2001 From: David Kebler Date: Thu, 8 Aug 2019 07:07:28 -0700 Subject: [PATCH] 0.0.3 iniital commit - working API with authentifcation, events and service call --- .eslintrc.js | 33 ++++++ .gitignore | 2 + .npmignore | 8 ++ examples/opts.yaml | 11 ++ examples/ping-pong.js | 19 ++++ examples/scheduler.js | 63 ++++++++++++ examples/watch-set.js | 37 +++++++ examples/watch-set.md | 49 +++++++++ package.json | 26 +++++ readme.md | 41 ++++++++ src/createSocket.js | 103 +++++++++++++++++++ src/homeassistant.js | 232 ++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 624 insertions(+) create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 examples/opts.yaml create mode 100644 examples/ping-pong.js create mode 100644 examples/scheduler.js create mode 100644 examples/watch-set.js create mode 100644 examples/watch-set.md create mode 100644 package.json create mode 100644 readme.md create mode 100644 src/createSocket.js create mode 100644 src/homeassistant.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..bb23489 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,33 @@ +module.exports = { + "ecmaFeatures": { + "modules": true, + "spread" : true, + "restParams" : true + }, + "env": { + "es6": true, + "node": true, + "mocha": true + }, + "parserOptions": { + "ecmaVersion": 2017 + ,"sourceType": "module" + }, + "extends": "eslint:recommended", + "rules": { + "indent": [ + "error", + 2 + ], + "no-console": 0, + "semi": ["error", "never"], + "linebreak-style": [ + "error", + "unix" + ], + "quotes": [ + "error", + "single" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23cb832 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules/ +/src/archive/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..d2cf0b0 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +tests/ +test/ +*.test.js +testing/ +examples/ +*.lock +.eslintrc.js +.travis.yml diff --git a/examples/opts.yaml b/examples/opts.yaml new file mode 100644 index 0000000..849b70e --- /dev/null +++ b/examples/opts.yaml @@ -0,0 +1,11 @@ +access_token: 'your long lived token generated in HA' # REQUIRED! +# default options below +# host: 'localhost', +# serverPath: 'api/websocket', +# protocol: 'ws', +# retryTimeout: 5000, +# timeout: 5000, +# retryCount: -1, +# port: 8123, +# ppmonitor: true # by default the server is ping/pong monitored for active connection +# url: ' ' # you can opt to provide the url (less server path) instead of have it created by above opts diff --git a/examples/ping-pong.js b/examples/ping-pong.js new file mode 100644 index 0000000..47b1f82 --- /dev/null +++ b/examples/ping-pong.js @@ -0,0 +1,19 @@ +import Hass from '../src/homeassistant' +import readYaml from 'load-yaml-file' + + ; +(async () => { + let opts = await readYaml(process.env.HA_OPTS_PATH || './examples/opts.yaml') + opts.ppmonitor = false // turn of automated ping/pong connection monitor for this example - on by default + console.log(opts) + const hass = new Hass(opts) + hass.on('connection', msg => console.log(`connection: ${msg}`)) + await hass.connect() + console.log('sending a ping to server') + console.log('pong returned from server', await hass.send('ping')) + hass.exit() + +})().catch(err => { + console.error('FATAL: UNABLE TO START SYSTEM!\n',err) + process.kill(process.pid, 'SIGTERM') +}) diff --git a/examples/scheduler.js b/examples/scheduler.js new file mode 100644 index 0000000..116d627 --- /dev/null +++ b/examples/scheduler.js @@ -0,0 +1,63 @@ +import to from 'await-to-js' +// import Hass from '../src/homeassistant' +import Hass from 'ha' +import moment from 'moment' + +let opts = { + pingpong:false, + // url: 'ws://10.0.0.3:8123', + host: 'nas.kebler.net', + access_token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiI5M2Q0YjdjNDg2MTc0MzY4YWE2MTE5NTU5ZDdkYjhjYyIsImlhdCI6MTU2NDMyNTkwNCwiZXhwIjoxODc5Njg1OTA0fQ.UBQkyqS88YbtcO90t1bSom3uYZy-C4vhgkKFCIzqbNU' +} + +let hass = new Hass(opts) + +; +(async () => { + hass.on('connection', msg => console.log(`connection: ${msg}`)) + await hass.connect() + if (hass.eventBusId) console.log('listener established for HA event bus') + + hass.on('error', err => console.log(err)) + + // hass.on('event', handleAllEvents) + // function handleAllEvents (ev) { + // console.log ('All HA event\n',ev) + // } + + // hass.stateChange('input_select.test_schedule_repeatin', handleInput) + // function handleInput (ev) { + // console.log ('test schedule repeat in state', ev) + // } + let name = 'schedule' + let list = ['sensor.test_schedule_delta','input_number.test_schedule_base_hour','input_number.test_schedule_base_minute'] + await hass.makeWatchList(list,name) + hass.on('wl-'+name,(ent) => { + scheduleComputeNext(ent.entity_id) + }) + + async function scheduleComputeNext(ent) { + console.log(`${ent} changed updating computed next time`) + let vars = hass.getWatchList(name,true) + console.log('updated scheuler inputs for processing\n',vars) + let baseTS = vars[list[2]].state*60+vars[list[1]].state*3600 + let dt = new Date() + let intoDayTS = (dt.getSeconds() + (60 * dt.getMinutes()) + (60 * 60 * dt.getHours())) + let nowTS = Math.floor(Date.now()/1000) + let nextTS = nowTS - intoDayTS + baseTS + console.log(baseTS,intoDayTS,nowTS, nowTS-intoDayTS,nextTS) + while (nowTS > nextTS) { + console.log(`now ${nowTS} beyond next ${nextTS} adding delta ${vars[list[0]].state} hours`) + nextTS += vars[list[0]].state*3600 + } + console.log(nextTS) + let nextDateTime = moment(nextTS*1000).format('ddd, MMM Do h:mm A') + console.log(await hass.setVariable('test_schedule_next', nextDateTime)) + } + + + +})().catch(err => { + console.error('FATAL: UNABLE TO START SYSTEM!\n',err) + process.kill(process.pid, 'SIGTERM') +}) diff --git a/examples/watch-set.js b/examples/watch-set.js new file mode 100644 index 0000000..0306bbd --- /dev/null +++ b/examples/watch-set.js @@ -0,0 +1,37 @@ +import Hass from '../src/homeassistant' +import readYaml from 'load-yaml-file' + + ; +(async () => { + let opts = await readYaml(process.env.HA_OPTS_PATH || './examples/opts.yaml') + console.log(opts) + const hass = new Hass(opts) + hass.on('connection', msg => console.log(`connection: ${msg}`)) + await hass.connect() + if (hass.eventBusId) console.log('listener established for HA event bus') + + hass.on('error', err => console.log(err)) + + let name = 'test' + let list = ['input_number.node_test_first_number','input_number.node_test_second_number'] + await hass.makeWatchList(list,name) + hass.on('wl-'+name,(ent) => { + testAction(ent.entity_id) + }) + + function testAction() { + let vars = hass.getWatchList(name) + // console.log(vars) + let first = vars[list[0]].state + let second = vars[list[1]].state + let sum = +first + +second + console.log(`return sum of ${first} and ${second} which is ${sum}`) + hass.setVariable('node_test_sum', sum) + // sum > 99 ? hass.switch('node_test_switch','on') : hass.switch('node_test_switch','off') + sum > 99 ? hass.setVariable('node_test_switch', 'ON') : hass.setVariable('node_test_switch','OFF') + } + +})().catch(err => { + console.error('FATAL: UNABLE TO START SYSTEM!\n',err) + process.kill(process.pid, 'SIGTERM') +}) diff --git a/examples/watch-set.md b/examples/watch-set.md new file mode 100644 index 0000000..f8eb4f0 --- /dev/null +++ b/examples/watch-set.md @@ -0,0 +1,49 @@ +# Node and Home Assistant Directly +### Nodejs interacts with HomeAssiant via a nodejs class/API + +Example: Watch some entities and change others based on their values + +```yaml +# paste this in as one entry of the views: property in ui-lovelace.yaml +# you must be in lovelace yaml mode. +- title: Node Testing + cards: + - type: vertical-stack + cards: + # - type: horizontal-stack + - type: entities + entities: + - entity: input_number.node_test_first_number + - entity: input_number.node_test_second_number + - entity: variable.node_test_sum + - entity: variable.node_test_switch + name: Dummy Switch (on>100) +``` + + +```yaml +# Save this to node_test.yaml file in your config/packages directory +# change in /packages require a home assistant restart to take effect +variable: + node_test_sum: + value: 0 +# dummy device value to turn on and off + node_test_switch: + value: 'OFF' + attributes: + icon: mdi:flash + +input_number: + node_test_first_number: + name: "First Number" + initial: 25 + min: 0 + max: 100 + step: 1 + node_test_second_number: + name: "Second Number" + initial: 75 + min: 0 + max: 100 + step: 1 +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..ed57c2d --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "@uci/ha", + "version": "0.0.3", + "description": "websocket api access to home assistant", + "main": "./src/homeassistant.js", + "scripts": { + "pp": "node -r esm ./examples/ping-pong.js", + "ppd": "UCI_ENV=dev UCI_LOG_LEVEL=debug nodemon -r esm ./examples/ping-pong.js", + "ws": "node -r esm ./examples/watch-set.js", + "wsd": "UCI_ENV=dev UCI_LOG_LEVEL=debug nodemon -r esm ./examples/watch-set.js" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@uci-utils/logger": "^0.0.14", + "await-to-js": "^2.1.1", + "better-try-catch": "^0.6.2", + "delay": "^4.3.0", + "faye-websocket": "^0.11.3", + "is-plain-object": "^3.0.0" + }, + "devDependencies": { + "esm": "^3.2.25", + "load-yaml-file": "^0.2.0" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..46edcd8 --- /dev/null +++ b/readme.md @@ -0,0 +1,41 @@ +# uCOMmandIt (UCI) - A Home Assistant Class and API + + + + + + +## What is it + +This module allows one to communicate with a Home Assistant instance via their websocket api + +## What's it good for + +Allows the a nodejs coder to easily replace most of the limited yaml script and automation with. + +## Prerequisites + +You'll of course need nodejs running and either npm or yarn. UCI tries to keep node as current as possible during development so use the latest version 10.x for best results or at + +## OS and Node Support + + +## Terminology + + +## The HA Websocket Packet 'Protocol' + + +## Getting Started + +## Creating an Instance + +## Extending This Class + +## Options + +## API diff --git a/src/createSocket.js b/src/createSocket.js new file mode 100644 index 0000000..9b79b80 --- /dev/null +++ b/src/createSocket.js @@ -0,0 +1,103 @@ +import { Client as WebSocket } from 'faye-websocket' +import delay from 'delay' +import to from 'await-to-js' + +import logger from '@uci-utils/logger' +let log = logger({ name: 'HomeAssistant:createSocket'}) + + + +const MSG_TYPE_AUTH_REQUIRED = 'auth_required' +const MSG_TYPE_AUTH_INVALID = 'auth_invalid' +const MSG_TYPE_AUTH_OK = 'auth_ok' + +function createSocket (url,opts) { // includes authentification + + return new Promise(async (resolve, reject) => { + let [errws,socket] = await to(connectAndAuthenticate(url,opts)) + if (errws) { + log.debug({msg:'error in establishing connection to socket', error:errws, retries:opts.retriesLeft}) + if (opts.retriesLeft === 0) throw reject(errws) + log.debug(`retrying initial connection in ${opts.retryTimeout/1000} secs`) + opts.retriesLeft-- + if (opts.retriesLeft >-1) log.debug(`${opts.retriesLeft} connection attemps remaining before aborting`) + else log.debug(`${-(opts.retriesLeft+1)} connection attempts`) + + await delay(opts.retryTimeout) + resolve(await createSocket(url,opts)) + } + resolve(socket) + }) // end promise +} // end createSocket + +export default createSocket + +function connectAndAuthenticate (url,opts) { + return new Promise((resolve, reject) => { + + const auth = JSON.stringify({ + type: 'auth', + access_token: opts.access_token, + api_password: opts.password + }) + + log.debug({msg:'[Auth Phase] Initializing', url:url}) + + let ws = new WebSocket(url,opts.ws) + + const closing = reason => { + log.debug(`[Auth Phase] cleaning up and closing socket: ${reason}`) + cleanup() + ws.close(1000,reason) + reject(`socket closed, ${reason}`) + } + + const cleanup = () => { + log.debug('[Auth Phase] removing authorization listeners') + ws.removeListener('message',authorize) + ws.removeListener('error', error) + clearTimeout(authTimeout) + } + + const authTimeout = setTimeout( handleTimeout.bind(ws,opts.timeout || 5000), opts.timeout || 5000) + + function handleTimeout(timeout) { + closing(`Unable to Authorize in ${timeout} seconds`) + } + + ws.on('error', error) + ws.on('message',authorize) + + function error(err) { + log.debug({msg:'[Auth Phase] socket error', error: err.message}) + closing(`error occured on socket..aborting\n${err.message}`) + } + + function authorize(event) { + let message = JSON.parse(event.data) + log.debug(`[Auth Phase] Message Received ${message}`) + + switch (message.type) { + case MSG_TYPE_AUTH_REQUIRED: + try { + log.debug({msg:'[Auth Phase] sending authorization',auth:auth}) + ws.send(auth) + } catch (err) { + log.debug({msg:'sending auth error', error:err}) + closing(`error when sending authorization: ${err}`) + } + break + case MSG_TYPE_AUTH_OK: + cleanup() + resolve(ws) + break + case MSG_TYPE_AUTH_INVALID: + closing(`bad token or password = ${MSG_TYPE_AUTH_INVALID}`) + break + default: + log.debug({msg:'[Auth Phase] Unhandled message - ignoring', message:message}) + } + } // end authorize + + }) +} //end authenticate diff --git a/src/homeassistant.js b/src/homeassistant.js new file mode 100644 index 0000000..f76a29c --- /dev/null +++ b/src/homeassistant.js @@ -0,0 +1,232 @@ +import createSocket from './createSocket' +import to from 'await-to-js' +import btc from 'better-try-catch' +import isPlainObject from 'is-plain-object' +import { EventEmitter } from 'events' + +import logger from '@uci-utils/logger' +let log = {} // declare module wide log to be set during construction + +const defaultOpts = { + host: 'localhost', + serverPath: 'api/websocket', + protocol: 'ws', + retryTimeout: 5000, + timeout: 5000, + retryCount: -1, + port: 8123, + ppmonitor: true +} + +class HomeAssistant extends EventEmitter { + constructor(opts) { + super() + log = logger({ name: 'HomeAssistant', id: this.id }) + this.opts = Object.assign(defaultOpts, opts) + log.debug({msg:'config to constructor',opts:opts}) + this.url = (this.opts.url ? `${this.opts.url} + /${this.opts.serverPath}` : `${this.opts.protocol}://${this.opts.host}:${this.opts.port}`) + `/${this.opts.serverPath}` + log.debug({msg:'url for websocket', url:this.url}) + this.cmdId = 1 + this.eventBusId = null + this._watchLists = {} + } + + async connect () { + this.opts.retriesLeft = this.opts.retryCount + let [err,socket] = await to(createSocket(this.url,this.opts)) + if (err) { + log.debug({msg:'error in connection, unable to establish socket', error:err}) + throw err + } + this.socket = socket + await this._listen() + // if (this.opts.monitor == null || this.opts.ppmonitor) + this._monitorConnection(this.opts.ppmonitor) + log.info('Successfuly connected to Home Assistant') + return 'success' + + } // end connect + + + async disconnect() { + this.socket.removeAllListeners('message') // cancels _listen + this.socket = {} + } + + async exit() { + await this.disconnect() + // also do any other cleanup and saving + log.debug('exiting per request') + process.exit(0) + } + + + + nextId () { return ++this.cmdId } + + async send (cmd,options={}) { + return new Promise(function(resolve, reject) { + let packet = options + if (isPlainObject(cmd)) { packet = cmd } + else { + packet.type = cmd + } + packet.id = packet.id || this.nextId() + log.debug('message to send', packet) + let [err, message] = btc(JSON.stringify)(packet) // try/catch or btc + if (err) reject ({error:'failed to parse message', packet:packet}) + this.socket.send(message) + let timeout = setTimeout( ()=>{ + reject({error:'failed to get a response in 5 seconds', packet:packet}) + },5000) + this.on(packet.id, (packet) => { + clearTimeout(timeout) + resolve(packet) }) + }.bind(this)) + } + + + async _listen() { + this.socket.on('message', (ev) => { + // log.debug('incoming message packet from server', ev.data) + let [err, packet] = btc(JSON.parse)(ev.data) + if (err) { + this.emit('error',{msg:'failed json parse of event data', event:ev, error:err}) + } else { + // result + if (packet.type === 'result') { + if (!packet.success) { + this.emit('error',{msg:'failed result', packet:packet}) + this.emit(packet.id,{error:packet.error}) + } else this.emit(packet.id,{id:packet.id, result:packet.result || packet.success}) + } + // pong + if (packet.type === 'pong') { this.emit('pong', packet) + this.emit(packet.id, 'pong') + } + // event + if (packet.type === 'event') { + this.emit(packet.id,packet.event) + this.emit('event', packet) + if (packet.event.event_type === 'state_changed') { + this.emit('state_changed',packet.event.data) + this.emit(packet.event.data.entity_id,packet.event.data.new_state) + } + } + } + }) + // subscribe to complete event bus + let [err,res] = await to(this.send('subscribe_events')) + log.debug('return from subscribe events', res) + if (err || res.error) { + log.debug({msg:'subscription to event bus failed!', level:'fatal', error:err || res.error}) + this.emit('error', {msg:'subscription to event bus failed!', level:'fatal', error:err || res.error}) + } + else { + log.debug(res) + this.eventBusId = res.id + } + // resubscribe to any specific events that are in stored on disk or in memeory + } // end listen + + + async getEntities (ents='all',type='obj') { + if (ents =='array') {ents = 'all'; type = 'array'} + let res = (await this.send('get_states')).result + if (ents !== 'all') { + if (typeof list === 'string') ents = [ents] + res = res.filter( item => ents.indexOf(item.entity_id) > -1 ) + } + if (type == 'obj') { + let obj = {} + res.forEach(ent => obj[ent.entity_id]=ent) + return obj + } + return res + } + + async callService(service,data) { + let packet = { + type: 'call_service', + domain: service.split('.')[0], + service:service.split('.')[1], + service_data: data + } + return await this.send(packet) + } + + async setVariable(variable,value) { + return await this.callService('variable.set_variable', {variable:variable, value:value}) + } + + async switch(eid, mode) { + mode.toLowerCase() + let service = (mode === 'on' || mode === 'off') ? `switch.turn_${mode}` : 'switch.toggle' + if (eid.split('.')[0] !== 'switch') eid = 'switch.'+eid + return await this.callService(service, {entity_id:eid}) + } + + async makeWatchList (ents,name='default') { + if (typeof entity === 'string') ents = [ents] + this._watchLists[name] = await this.getEntities(ents) + let list = this._watchLists[name] + ents.forEach(ent => { + this.on(ent, handleUpdate) + }) + + this._handleUpdate = handleUpdate // need to save pointer fo removing listener + + function handleUpdate (changed) { + list[changed.entity_id] = changed // update entity in watch list + // log.debug(changed.state,list[changed.entity_id]) + this.emit(`wl-${name}`, changed) + } + + } + + getWatchList(name) { + return this._watchLists[name] + } + + removeWatchList (name) { + this.getWatchList(name).forEach(ent => this.removeListener(ent.entity_id,this._handleUpdate)) + delete this._watchLists[name] + this.removeAllListeners(`wl-${name}`) + } + + _monitorConnection(enabled=true) { + + let ping = null + + async function queuePing() { + let id = this.nextId() + log.debug(`sending ping id ${id}, setting pong timeout`) + let [err, res] = await to(this.send('ping')) + if (err) { + clearTimeout(ping) + log.debug('no pong received in 5 seconds, notifiy and attempt new connection') + await this.disconnect() // removes socket and clears message listener + this.connect() + } else { + if (res.type !== 'pong' ) { + log.debug({msg:'something major wrong, message was not a pong for this id', response:res}) + } else { + log.debug('pong received, waiting 5 secs before sending next ping') + setTimeout( () => ping = queuePing.call(this),5000) + } + } + } + + if (enabled) { + ping = queuePing.call(this) + log.debug('ping pong monitor enabled') + } + else { + if (ping) clearTimeout(ping) + log.debug('ping pong monitor disabled') + } + } + +} // end class + +export default HomeAssistant