0.0.3 iniital commit - working API with authentifcation, events and service call

master
David Kebler 2019-08-08 07:07:28 -07:00
commit 224fb07f10
12 changed files with 624 additions and 0 deletions

33
.eslintrc.js Normal file
View File

@ -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"
]
}
}

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/node_modules/
/src/archive/

8
.npmignore Normal file
View File

@ -0,0 +1,8 @@
tests/
test/
*.test.js
testing/
examples/
*.lock
.eslintrc.js
.travis.yml

11
examples/opts.yaml Normal file
View File

@ -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

19
examples/ping-pong.js Normal file
View File

@ -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')
})

63
examples/scheduler.js Normal file
View File

@ -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')
})

37
examples/watch-set.js Normal file
View File

@ -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')
})

49
examples/watch-set.md Normal file
View File

@ -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
```

26
package.json Normal file
View File

@ -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"
}
}

41
readme.md Normal file
View File

@ -0,0 +1,41 @@
# uCOMmandIt (UCI) - A Home Assistant Class and API
<!-- find and replace the package name to match -->
<!-- [![Build Status](https://img.shields.io/travis/uCOMmandIt/uci-base.svg?branch=master)](https://travis-ci.org/uCOMmandIt/uci-base)
[![Inline docs](http://inch-ci.org/github/uCOMmandIt/uci-base.svg?branch=master)](http://inch-ci.org/github/uCOMmandIt/uci-base)
[![Dependencies](https://img.shields.io/david/uCOMmandIt/uci-base.svg)](https://david-dm.org/uCOMmandIt/uci-base)
[![devDependencies](https://img.shields.io/david/dev/uCOMmandIt/uci-base.svg)](https://david-dm.org/uCOMmandIt/uci-base?type=dev)
[![codecov](https://img.shields.io/codecov/c/github/uCOMmandIt/uci-base/master.svg)](https://codecov.io/gh/uCOMmandIt/uci-base) -->
## 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

103
src/createSocket.js Normal file
View File

@ -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

232
src/homeassistant.js Normal file
View File

@ -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