From 5576ddea54eeec22d4e00782fa3de3e37e771347 Mon Sep 17 00:00:00 2001 From: David Kebler Date: Fri, 19 Jun 2020 10:04:11 -0700 Subject: [PATCH] First working version supports using Home Assistant as a UI does not currently support persistence for setting changes (from HA), needs changes to rx-class does not support aborting a run (need changes to scheduler --- .eslintrc.js | 33 +++++ .gitignore | 3 + README.md | 5 + dev/water-rx-class.conf | 26 ++++ dev/water.conf | 25 ++++ dev/watergpio.conf | 26 ++++ dev/waterha.conf | 28 ++++ dev/watersch.conf | 26 ++++ example.irrigation.yml | 96 +++++++++++++ index.js | 17 +++ irrigation.service | 17 +++ nodemon.json | 6 + package.json | 28 ++++ src/defaults.js | 27 ++++ src/ha-constants.js | 67 ++++++++++ src/ha-mqtt.js | 101 ++++++++++++++ src/ha.js | 171 +++++++++++++++++++++++ src/irrigation.js | 290 ++++++++++++++++++++++++++++++++++++++++ 18 files changed, 992 insertions(+) create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 README.md create mode 100644 dev/water-rx-class.conf create mode 100644 dev/water.conf create mode 100644 dev/watergpio.conf create mode 100644 dev/waterha.conf create mode 100644 dev/watersch.conf create mode 100644 example.irrigation.yml create mode 100644 index.js create mode 100644 irrigation.service create mode 100644 nodemon.json create mode 100644 package.json create mode 100644 src/defaults.js create mode 100644 src/ha-constants.js create mode 100644 src/ha-mqtt.js create mode 100644 src/ha.js create mode 100644 src/irrigation.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..2dbbb41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules/ +/.yalc/ +/irrigation.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e43d59 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Irrigation Application + +This nodejs application uses [uci](https://git.kebler.net/UCOMmandIt) libraries to control a bank of 8 gpios/relays on an SBC. It contains a home assistant websocket client module to allows HomeAssitant to act as a front end for the application. + +Configruation is done via a yaml file (irrigation.yml). See src/defaults.js as well. diff --git a/dev/water-rx-class.conf b/dev/water-rx-class.conf new file mode 100644 index 0000000..ec46c29 --- /dev/null +++ b/dev/water-rx-class.conf @@ -0,0 +1,26 @@ +#!/bin/bash +WATCH_DIR='/mnt/AllData/hacking/active-dev-repos/uci/lib/uci-utils/rx-class' + +# Watch-related options +# WATCH_EXCLUDE='(\.git|___jb_|\syncd|\node_modules|.yalc)' +# Whether to enable verbosity. If enabled, change events are output. +WATCH_VERBOSE=0 + +# SSH connection settings +# using configs from .ssh/config file +SSH_USER=sysadmin +SSH_HOST=water + +# Sync options +TARGET_DIR="/opt/irrigation/node_modules/@uci-utils/rx-class" +# space delimited string of exclusions +RSYNC_EXCLUDE=" \ + --exclude node_modules \ + " + # --exclude build/Release/ \ + # --exclude deploy \ + # --exclude archive \ + # --exclude .yalc \ + # --exclude yalc.* \ + +RSYNC_OPTIONS="-Cra --out-format='[%t]--%n' --delete " diff --git a/dev/water.conf b/dev/water.conf new file mode 100644 index 0000000..37bcedd --- /dev/null +++ b/dev/water.conf @@ -0,0 +1,25 @@ +#!/bin/bash +WATCH_DIR='/mnt/AllData/hacking/active-dev-repos/irrigation' + +# Watch-related options +WATCH_EXCLUDE='(\.git|___jb_|\syncd|\node_modules|.yalc)' +# Whether to enable verbosity. If enabled, change events are output. +WATCH_VERBOSE=0 + +# SSH connection settings +# using configs from .ssh/config file +SSH_USER=sysadmin +SSH_HOST=water + +# Sync options +TARGET_DIR="/opt/irrigation" +#space delimited string of exclusions +RSYNC_EXCLUDE=" \ + --exclude node_modules \ + --exclude build/Release/ \ + --exclude deploy \ + --exclude archive \ + --exclude .yalc \ + --exclude yalc.* \ + " +RSYNC_OPTIONS="-Cra --out-format='[%t]--%n' --delete " diff --git a/dev/watergpio.conf b/dev/watergpio.conf new file mode 100644 index 0000000..1a4d10c --- /dev/null +++ b/dev/watergpio.conf @@ -0,0 +1,26 @@ +#!/bin/bash +WATCH_DIR='/mnt/AllData/hacking/active-dev-repos/uci/lib/uci-gpio' + +# Watch-related options +# WATCH_EXCLUDE='(\.git|___jb_|\syncd|\node_modules|.yalc)' +# Whether to enable verbosity. If enabled, change events are output. +WATCH_VERBOSE=0 + +# SSH connection settings +# using configs from .ssh/config file +SSH_USER=sysadmin +SSH_HOST=water + +# Sync options +TARGET_DIR="/opt/irrigation/node_modules/@uci/gpio" +# space delimited string of exclusions +RSYNC_EXCLUDE=" \ + --exclude node_modules \ + " + # --exclude build/Release/ \ + # --exclude deploy \ + # --exclude archive \ + # --exclude .yalc \ + # --exclude yalc.* \ + +RSYNC_OPTIONS="-Cra --out-format='[%t]--%n' --delete " diff --git a/dev/waterha.conf b/dev/waterha.conf new file mode 100644 index 0000000..b664f3d --- /dev/null +++ b/dev/waterha.conf @@ -0,0 +1,28 @@ +#!/bin/bash +WATCH_DIR='/mnt/AllData/hacking/active-dev-repos/uci/lib/uci-ha' + +# Watch-related options +# WATCH_EXCLUDE='(\.git|___jb_|\syncd|\node_modules|.yalc)' +# Whether to enable verbosity. If enabled, change events are output. +WATCH_VERBOSE=0 + +# SSH connection settings +# using configs from .ssh/config file +SSH_USER=sysadmin +SSH_HOST=water + +# Sync options +TARGET_DIR="/opt/irrigation/node_modules/@uci/ha" +# space delimited string of exclusions +RSYNC_EXCLUDE=" \ + --exclude node_modules \ + --exclude archive \ + --exclude examples \ + " + # --exclude build/Release/ \ + # --exclude deploy \ + + # --exclude .yalc \ + # --exclude yalc.* \ + # " +RSYNC_OPTIONS="-Cra --out-format='[%t]--%n' --delete " diff --git a/dev/watersch.conf b/dev/watersch.conf new file mode 100644 index 0000000..4064403 --- /dev/null +++ b/dev/watersch.conf @@ -0,0 +1,26 @@ +#!/bin/bash +WATCH_DIR='/mnt/AllData/hacking/active-dev-repos/scheduler' + +# Watch-related options +# WATCH_EXCLUDE='(\.git|___jb_|\syncd|\node_modules|.yalc)' +# Whether to enable verbosity. If enabled, change events are output. +WATCH_VERBOSE=0 + +# SSH connection settings +# using configs from .ssh/config file +SSH_USER=sysadmin +SSH_HOST=water + +# Sync options +TARGET_DIR="/opt/irrigation/node_modules/@uci-utils/scheduler" +# space delimited string of exclusions +RSYNC_EXCLUDE=" \ + --exclude node_modules \ + " + # --exclude build/Release/ \ + # --exclude deploy \ + # --exclude archive \ + # --exclude .yalc \ + # --exclude yalc.* \ + +RSYNC_OPTIONS="-Cra --out-format='[%t]--%n' --delete " diff --git a/example.irrigation.yml b/example.irrigation.yml new file mode 100644 index 0000000..3368b34 --- /dev/null +++ b/example.irrigation.yml @@ -0,0 +1,96 @@ +name: Irrigation System +enabled: true +# reset_settings: true # uncomment to force zone settings to overwrite those in store +# deployed: false # uncomment for testing +hass: # home assistant websocket (client) access + namespace: irrigation # will be prefix on HA entity id + pingpong: true # must use pingpong disconnect/reconnect monitoring + host: '' # ip or host name + access_token: '' # generate token in HA +pump: + relay: 1 + name: WellPump + gpio_pin_num: 80 + settings: + # prime: 5 # secs pump is primed in drip mode, default: 5 + drip: 30 # drip mode pause in secs, default: 60 +# zone_settings_default: # overrides built in defaults +# zone_settings_common: # applied last will override defaults and zone settings +# schedule: +# timing: +# delta: 24 +# runner: +# enabled: +zones: + zone_1: + relay: 2 + name: Front Yard North + gpio_pin_num: 73 + settings: + schedule: + enabled: false + timing: + delta: 48 + zone_2: + relay: 3 + name: Front Yard South + gpio_pin_num: 69 + settings: + schedule: + enabled: false + timing: + delta: 48 + zone_3: + relay: 4 + name: Front Yard Beds/Back Yard East Spigot + gpio_pin_num: 230 + settings: + duration: 10 + schedule: + timing: + hour: 8 + zone_4: + relay: 5 + name: Back Yard Garage Spigot + gpio_pin_num: 229 + settings: + duration: 5 + schedule: + enabled: false + zone_5: + relay: 6 + name: Garden North & West + gpio_pin_num: 75 + settings: + schedule: + timing: + hour: 7 + enabled: true + drip: true # pauses pump for secs + duration: 10 + zone_6: + relay: 7 + name: Garden South + gpio_pin_num: 74 + settings: + schedule: + timing: + hour: 7 + enabled: true + drip: true # pauses pump true = use default + duration: 10 + zone_7: + relay: 8 + name: Back Yard West Spigot + gpio_pin_num: 70 + settings: + schedule: + enabled: false +deltas: + 3: 3 Hours + 6: 6 Hours + 12: 12 Hours + 24: Day + 48: Every Other Day + 72: Every Third Day + 168: Once a Week diff --git a/index.js b/index.js new file mode 100644 index 0000000..377c340 --- /dev/null +++ b/index.js @@ -0,0 +1,17 @@ +import Irrigation from './src/irrigation.js' + +let opts = 'irrigation.yml' + +console.log('starting irrigation') + +const irrigation = new Irrigation(opts) +; +(async () => { + + await irrigation.init() + irrigation.runner.enabled=true + +})().catch(err => { + console.error('FATAL: UNABLE TO START SYSTEM!\n',err) + // process.kill(process.pid, 'SIGTERM') +}) diff --git a/irrigation.service b/irrigation.service new file mode 100644 index 0000000..3dac4e2 --- /dev/null +++ b/irrigation.service @@ -0,0 +1,17 @@ +# $ loginctl enable-linger USERNAME +# copy this file to /home/USERNAME/.config/USERNAME/user +# then user `systemctl --user` commands (or sdu aliases if set up) +[Unit] +Description=Irrigation Controller Service +After=network-online.target + +[Service] +StandardOutput=file:/opt/irrigation/std.log +StandardError=file:/opt/irrigation/err.log +Environment='UCI_ENV=pro UCI_LOG_PATH=/opt/irrigation/irrigation.json.log UCI_LOG_LEVEL=warn' +WorkingDirectory=/opt/irrigation +ExecStart=/usr/bin/node -r esm /opt/irrigation/index.js +Restart=on-failure +RestartSec=5 +[Install] +WantedBy=default.target diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..caecff4 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,6 @@ +{ + "ignoreRoot": [".git"], + "watch": ["node_modules/@uci/","node_modules/@uci-utils/","./*"], + "ignore":["irrigation-settings.json"], + "ext":"js,json,yaml,yml" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6642505 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "irrigation", + "version": "1.0.0", + "description": "Irrigation System Relays", + "main": "index.js", + "scripts": { + "dev": "UCI_ENV=dev UCI_LOG_LEVEL=error nodemon -r esm index.js", + "devlog": "UCI_ENV=dev UCI_LOG_LEVEL=debug nodemon -r esm index.js", + "start": "node -r esm index.js" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@uci-utils/rx-class": "^0.1.5", + "@uci-utils/scheduler": "0.0.10", + "@uci/gpio": "^0.1.17", + "@uci/ha": "0.0.8", + "clone": "^2.1.2", + "deep-freeze": "0.0.1", + "deepmerge": "^4.2.2", + "delay": "^4.3.0", + "esm": "^3.2.25", + "load-yaml-file": "^0.2.0" + }, + "devDependencies": { + "nodemon": "^2.0.4" + } +} diff --git a/src/defaults.js b/src/defaults.js new file mode 100644 index 0000000..dee9603 --- /dev/null +++ b/src/defaults.js @@ -0,0 +1,27 @@ +const ZONE = { + drip: false, // pauses pump for secs or uses the pump setting or default of 60 + duration: 5, + // pump: true, // if zone is to use the pump, default is true + schedule:{ + enabled: true, + simultaneous: false, + timing: { + hour: 6, + minute: 30, + delta: 12 + } + } +} + +const RUNNER = { + timerInterval:10, // seconds + // enabled: true +} + +const PUMP = { + drip: 60, // pauses pump for 60 secs + prime: 5, // primes pump for drip 5 secs + use: true // use the pump by default (would be false for city water) +} + +export { PUMP,ZONE, RUNNER } diff --git a/src/ha-constants.js b/src/ha-constants.js new file mode 100644 index 0000000..684ca0a --- /dev/null +++ b/src/ha-constants.js @@ -0,0 +1,67 @@ + +const PUMP = {tmpl:'input_boolean.%r%',hass_function:'updateEntity'} + +const RUNNER = { + countdown:'next_trigger', + nextDT:'next_trigger_dt', + nextName:'next_schedule_name', + running:'running_names', + queue:'queue_names' +} + +const ZONE = { + enabled:{ + tmpl: 'input_boolean.%r%_schedule_enabled', + hass_function:'updateEntity', + schedule : true, + path:'settings', + out_value_function:onoff, + in_value_function:tf + }, + delta: { + tmpl:'input_select.%r%_schedule_delta', + schedule : true, + path:'settings.timing', + hass_function:'setSelect', + out_value_function: (value,deltas) => deltas[value.toString()], + in_value_function: (str,deltas) => Object.entries(deltas).find(delta => delta[1] === str)[0] + }, + hour: { + tmpl: 'input_number.%r%_schedule_base_hour', + path:'settings.timing', + schedule : true + }, + minute: { + tmpl:'input_number.%r%_schedule_base_minute', + path:'settings.timing', + schedule : true + }, + nextDT: { + tmpl:'%r%_schedule_next_dt', + hass_function:'setVariable', + schedule : true, + }, + countdown: { + tmpl: '%r%_schedule_countdown', + hass_function:'setVariable', + schedule: true + }, + duration:{ + tmpl: 'input_number.%r%_duration', + path:'settings' + }, + state:{ + tmpl: 'input_boolean.%r%_state', + hass_function:'updateEntity', + in_function: function (zone,value) { + // console.log(zone.id,'ha requested state change', value) + this.zoneOn(zone,value) + }, + in_no_state_change: true + }, +} + +export { ZONE, PUMP, RUNNER} + +function onoff(bol) { return bol ? 'on':'off'} +function tf(onoff) { return (onoff === 'on') ? true:false} diff --git a/src/ha-mqtt.js b/src/ha-mqtt.js new file mode 100644 index 0000000..55f345e --- /dev/null +++ b/src/ha-mqtt.js @@ -0,0 +1,101 @@ + +///***************** HOME ASSISTANT ************************ +// for uci mqtt discovery in HA + + +const STATE_TOPIC = 'state' +const STATE_CMD = 'state' +const ENTITY = 'switch' +const DISCOVERY = 'homeassistant' +const NAMESPACE = 'uci' +const COMMAND_TOPIC = 'set' // will use payload as the command + +function register(name,opts={}) { + + const topic = `${opts.discovery || DISCOVERY}/` + + `${opts.entity || ENTITY}/` + + `${opts.namespace || NAMESPACE ? `${opts.namespace || NAMESPACE }` :''}` + console.log('subscription topic for HA',`${topic}/#`) + this.getSocket(name).subscribe(`${topic}/#`) + + this.beforeProcessHook(async (packet) => { + // console.log('incoming ha mqtt packet to modify') + // console.dir(packet) + // let modified = Object.assign({},packet) + const topic = packet.cmd.split('/') + const cmd = topic.pop() + const id = topic.pop() + // console.log(cmd, id) + if (cmd=== (opts.state || STATE_TOPIC)) return null // ignore state commands (avoid infiite loop) + else if (cmd===(opts.command||COMMAND_TOPIC)) packet.cmd = packet.data.toLowerCase() + else packet.cmd = cmd + delete packet.data + Object.assign(packet,this.entities.find(ent => ent.id === id)) + // console.log('translated to uci packet') + // console.dir(packet) + return packet + }, + {name:name}) + + this.afterProcessHook(async (packet) => { + if (packet.error) { + let npacket = {} + npacket.cmd = 'error' + npacket.payload = JSON.stringify(packet) + return npacket + } + // console.log('after process', packet) + return packet + }, + {name:name}) + + this.beforeSendHook(async (packet) => { + if (packet.cmd === (opts.stateCmd || STATE_CMD)) { + // console.log('preparing state topic/payload') + if (packet.state && packet.gpio) { + let mpacket = { + cmd: `${topic}/${packet.id}/${opts.stateCmd || STATE_CMD}`, + payload: ((packet.state || {})[packet.gpio.toString()] || '').toUpperCase() + } + // console.log('=============modified packet being pushed to broker================') + // console.dir(mpacket) + // console.log('================') + return mpacket + } + // else console.log('bad state packet state/gpio',packet.state,packet.gpio) + } + // console.log('packet ignored, nothing sent to to HA') + return null // only send/publish the required topic + }, + {name:name}) + +} + +export default register + +function init(mqtt,entities,opts={}) { + console.log('initializing ha mqtt discovery') + entities.forEach(ent => { + const topic = `${opts.discovery || DISCOVERY}/` + + `${opts.entity || ENTITY}/` + + `${opts.namespace || NAMESPACE ? `${opts.namespace || NAMESPACE }/` :''}` + + `${ent.id}` + let payload = { + name:`${opts.namespace || NAMESPACE ? `${opts.namespace || NAMESPACE }_` :''}${ent.id}`, + command_topic:`${topic}/${opts.command || COMMAND_TOPIC}`, + state_topic: `${topic}/${opts.state || STATE_TOPIC}`, + json_attributes_topic:`${topic}/${opts.state || STATE_TOPIC}`, + json_attributes_template:'{{ value_json.alias | tojson }}', + } + payload = JSON.stringify(payload) + console.log('------ sending discovery payload to HA for ',ent.alias,':',ent.id,'-----') + console.log('topic=>',`${topic}/config`) + // console.log(payload) + mqtt.publish(`${topic}/info`,null) // clear existing + mqtt.publish(`${topic}/config`,payload,{retain:true}) + }) + +} + + +export { register,init } diff --git a/src/ha.js b/src/ha.js new file mode 100644 index 0000000..5567379 --- /dev/null +++ b/src/ha.js @@ -0,0 +1,171 @@ + +import Hass from '@uci/ha' +import { PUMP, ZONE, RUNNER } from './ha-constants.js' + +let initialized = false + +async function init(opts) { + + console.log('Initializing Home Assistant Integration') + opts = opts || this.config.hass + if (!opts.access_token) throw('no access token to HA supplied, not way to connect') + + this.hass = new Hass(opts) + const hass = this.hass + + hass.on('error',ev => console.log('!!!! HASS socket error !!!!!\n', ev)) + + hass.on('connection',async state => { + + console.log('HA connection event >', state) + // if (hass.eventBusId) console.log('listener established for HA event bus') + if (state==='ready') { + + console.log('HA connection ready, listener established for all of HA event bus') + + for (let prop in RUNNER) { + let ent = `${opts.namespace ? opts.namespace+'_':''}${RUNNER[prop]}` + await hass.setVariable(ent, this.runner[prop]) + } + await hass.updateEntity( + `input_boolean.${opts.namespace ? opts.namespace+'_':''}enabled`, + this.runner.enabled ? 'on':'off') + + const pumpEnt = makeEntityName('pump',PUMP.tmpl,opts.namespace) + hass[PUMP.hass_function].call(hass,pumpEnt,this.pump.state) // reset state + + if (!initialized) { + + for (let prop in RUNNER) { + let ent = `${opts.namespace ? opts.namespace+'_':''}${RUNNER[prop]}` + this.runner.rxSubscribe(prop,'ha',value => { + // console.log('runner', ent, ev) + hass.setVariable(ent, value) + }) + } + + hass.on( + `input_boolean.${opts.namespace ? opts.namespace+'_':''}enabled`, + entity => {this.runner.enabled = entity.state==='on'?true:false + }) + + this.rxSubscribe('pump.state','ha',value => { + // console.log('pump state change sent to HA:',ev.value) //,pumpEnt,PUMP.hass_function) + hass[PUMP.hass_function].call(hass,pumpEnt,value) + }) + hass.on(pumpEnt, + entity => { + // console.log('HASS: pump change request',entity.state) + this.pumpGPIO(entity.state) + }) + console.log('done runner and pump HA setup') + + + // prepare the zones communication with HA + } + for (let id in this.zones) { + let _zone = this.zones[id] + // console.log('HA setup for', _zone.id) + await zone.call(this,_zone,'reset',opts.namespace) + // console.log('done resetting zone',_zone.id) + if (!initialized) { + await zone.call(this,_zone,'subscribe',opts.namespace) + await zone.call(this,_zone,'watch',opts.namespace) + } + } + console.log('done zones HA setup') + + hass.ready = true // TODO but this in ha code + setTimeout(()=> { // wait for any incoming bogus stuff from ha + console.log('HA Initialization Complete') + initialized = true},1000) + } // end hass ready + else { + hass.eventBusID == null + hass.ready=false + // cleanup + } + }) // end connection listner + console.log('attempting connection to home assistant') + hass.connect() +} // end initHass + +async function zone(zone,req,namespace) { + const hass = this.hass + + if (typeof zone === 'string') zone = this.getZone(zone) + const sch = this.runner.getSchedule(zone.id) + // console.log('schedule subscriptions',sch.$get(['_rx_.props'])) + if (req ==='reset') await this.hass.setSelect(makeEntityName(zone.id,ZONE['delta'].tmpl,namespace), Object.values(this.deltas)) + Object.keys(ZONE).forEach(ent =>{ + let path;let inst + if (ZONE[ent].schedule) { + inst = sch + path = [ZONE[ent].path,ent] + } else { + inst = this + path = [ZONE[ent].path,'zones',zone.id,ent] + } + // console.log(zone.id, ZONE[ent].schedule,ent,path) + let alterValue = (val) => { + return ZONE[ent].out_value_function ? ZONE[ent].out_value_function(val,this.deltas) : val + } + const entName = makeEntityName(zone.id,ZONE[ent].tmpl,namespace) + const func = this.hass[ZONE[ent].hass_function] || this.hass['setNumber'] + // console.log(req,ent) + switch (req) { + case 'reset': + // console.log('to value:',alterValue(get(path))) + func.call(this.hass,entName,alterValue(inst.get(path))) + break + case 'subscribe': + // console.log(ent,entName,path,inst.id) + inst.rxSubscribe(path,'ha',async function (value) { + // console.log('subscribe event',ev) + value = alterValue(value) + // console.log(inst.id, entName, await hass.getEntities(entName)) + let hassState = (await hass.getEntities(entName)).state + // console.log(hassState,value,ev.value) + if (typeof(value) === 'number') hassState = Number(hassState) + // console.log('hass value',hassState,'new value',value) + if (hassState !== value ) { + // console.log(ent,'prop',this.zones[zone.id].settings[ent]) + // if (ent!=='countdown') console.log(zone.id,': new state sent to HA (old,new):',hassState,':',value ) // entName,value,func) + func.call(hass,entName,value) + } + }) // end subscribe + break + case 'unsubscribe': + break + case 'watch': + hass.on(entName, + function (zone,path,oent,entity) { + let value = oent.in_value_function ? oent.in_value_function(entity.state,this.deltas) : entity.state + // console.log('converted value', value,'access to this',!!this.set) + if (oent.in_function) oent.in_function.call(this,zone,value,path) + if (!oent.in_no_state_change) { + const curValue = inst.get(path) + if (typeof(curValue) === 'number') value = Number(value) + // console.log(curValue,value, initialized) + if (curValue !== value && initialized) { + // console.log(zone.id,ent,entName,'Updating with new value from HA (old,new)',curValue,value) + // console.log(`setting value from HA=> ${zone.id} : ${ent} : ${value}`) + inst.set(path,value) + } + } + }.bind(this,zone,path,ZONE[ent]) + ) + break + default: + } + }) // next zone entity +} // end zone + + +export { init, zone } + +// create zone HA entity names +function makeEntityName(name,tmpl,namespace) { + const replaceWith = `${namespace ? namespace+'_':''}${name}` + return tmpl.replace('%r%',replaceWith) +} diff --git a/src/irrigation.js b/src/irrigation.js new file mode 100644 index 0000000..9516802 --- /dev/null +++ b/src/irrigation.js @@ -0,0 +1,290 @@ +// native +import { sync as getConfig } from 'load-yaml-file' +import delay from 'delay' +import deepFreeze from 'deep-freeze' +import clone from 'clone' +import { all as merge } from 'deepmerge' +// import to from 'await-to-js' +// UCI +import RxClass from '@uci-utils/rx-class' +import { Gpio } from '@uci/gpio' +import { Runner, Schedule } from '@uci-utils/scheduler' +import { init as initHass } from './ha.js' +import { PUMP, ZONE, RUNNER } from './defaults.js' + +// TODO change to dynamic imports +// import Base from '@uci/base' // TODO add Hass websocket client to Base +// TODO extend the class conditionally with uci base + + +class Irrigation extends RxClass { + constructor(opts) { + super() + this.config = deepFreeze((typeof opts ==='string') ? getConfig(opts) : opts) + // console.log(this.config) + this.deployed = this.config.deployed==null ? 'true' : this.config.deployed // assume deployed by default + + this.settings = { + zones:{}, + pump: clone(this.config.pump.settings) + } + this.zones = clone(this.config.zones) + Object.keys(this.zones).forEach(id => { + const zone = this.zones[id] + zone.id = id + zone.name = zone.name || zone.id + this.zoneResetSettings(zone) + delete zone.settings + this.set(['zones',zone.id,'state'],'off') + }) + console.log('done setting up zones') + this.pump = clone(this.config.pump) + this.settings.pump = merge([{},PUMP,this.pump.settings]) + this.set('pump.state','off') + this.deltas = this.config.deltas + console.log('done setting up pump') + const rOpts = { + name: this.config.name ||'irrigation', + schedules: Object.values(this.zones).map(zone => { + const zsets = this.get(['settings.zones',zone.id,'schedule']) + const sch = new Schedule ({ name: zone.name, id:zone.id, settings:zsets}) + this.$del(['settings.zones',zone.id,'schedule'],true) + return sch + }) + } + + this.runner = new Runner (Object.assign(rOpts,RUNNER)) + + this.rxAdd('settings',{traverse:true}) // TODO add store subscription here + // console.log('runner', this.runner) + console.log('schedules and runner created') + console.log('enabled schedules', this.runner.schedules.map(sch=>sch.id)) + + if (this.deployed) { + console.log('deployed, setting up gpios') + this.pump.gpio = new Gpio(this.pump.gpio_pin_num,{activeLow:true}) + Object.values(this.zones).forEach(zone =>{ + zone.gpio = new Gpio(zone.gpio_pin_num,{activeLow:true}) + }) + } + } + + async init() { + + let init = false + + await this.pumpGPIO('off') + + if (this.config.hass) await initHass.call(this) + + // set up zone schedules and init + Object.values(this.zones).forEach(zone => { + const sch = this.runner.getSchedule(zone.id) + // console.log(this.runner.getSchedule(zone.id).get('settings')) + // this.rxSubscribe(['settings.zones',zone.id,'schedule.enabled'],'abort',value => { + // console.log(zone.id,'zone schedule enabled change',value) + // if (init) { + // if (value==='off') { + // console.log('removing any active schedules', sch.id) + // this.runner.removeActive(sch.id) + // } + // } + // }) + // this.zoneSchCountdown(zone.id) + sch.registerStartAction(start, + this.get(['settings.zones',zone.id,'duration']), + this.zoneOn.bind(this,zone,'on','auto'), + this.zoneOn.bind(this,zone,'off','auto') + ) // passing zone here make it available in action + sch.registerStopAction(stop,this.zoneOn.bind(this,zone,'off','auto')) + this.zoneOff(zone) + }) + + // this.runner.rxSubscribe('countdown','runner',value => { console.log('trigger #', this.runner._toID,'in', value)}) + // this.runner.rxSubscribe('running','runner',list => { console.log('running schedules:',list)}) + // this.runner.rxSubscribe('queue','runner',list => { console.log('queued schedules:',list)}) + // this.runner.rxSubscribe('queueCount','runner',value => { console.log('queue schedule count', value)}) + // this.runner.rxSubscribe('runningCount','runner',value => { console.log('running schedule count', value)}) + + console.log('Irrigation Initialization Done') + + init = true + + } // end init + + getZone (id) { + return this.zones[id] + } + + zoneResetSettings(zone) { + if (typeof zone ==='string') zone = this.getZone(zone) + this.settings.zones[zone.id] = merge([{},ZONE, + this.config.zone_settings_default || {}, + zone.settings, + this.config.zone_settings_common || {} + ]) + // console.log(zone.id,this.getZoneProp(zone,'settings')) + } + + setZoneProp(zone,path,value) { + if (typeof zone !=='string') zone=zone.id + return this.set(['zones',zone,path],value) + } + + getZoneProp(zone,path) { + if (typeof zone !=='string') zone=zone.id + return this.get(['zones',zone,path]) + } + + async pumpPrime (prime=3) { + // console.log('priming pump') + // await this.pump.gpio.on() + await this.pumpGPIO('on') + await delay(prime*1000) // let pump get to pressure + // console.log('waited for prime',prime,'secs') + // await this.pump.gpio.off() + await this.pumpGPIO('off') + } + + async pumpDripMode (drip) { + console.log('-----using drip pump cycling----') + this.pump.dripMode = true + await this.pumpPrime(this.pump.prime) + const pause = drip === true ? (this.pump.settings.drip || 60) : drip + // console.log('pump is primed pausing for',pause,'secs') + this.pump.dripInt = setInterval(async ()=>{ + await this.pumpPrime(this.pump.prime) + // console.log('pump is primed again pausing for',pause,'secs') + },pause*1000) + } + + async pumpToggle() { + return await this.pumpGPIO('toggle') + } + + async pumpOff () { + return await this.pumpGPIO('off') + } + + async pumpOn () { + return await this.pumpGPIO('on') + } + + async pumpGPIO(state) { + state == null ? 'on' : state + if (state === true) state='on' + if (state === false) state='off' + if (this.deployed) { + // await this.pump.gpio.read() + // if (this.pump.gpio.state !== state ) { + let res = await this.pump.gpio[state]() + // console.log('pump state change to',res) + this.pump.state = this.pump.gpio.state + this.pump.active = this.pump.gpio.value + // console.log('change of pump state',this.pump.state) + return res + // } + } + else console.log('mock pump state change', state) + } + + // Todo think of better way to handle automatic zone change vs manual + async zoneOn (zone,state,auto) { + const zset = this.settings.zones[zone.id] + const pset = this.settings.pump + // console.log(zone.id,'auto,zone.auto,state',auto,zone.auto,state) + if (typeof zone === 'string') zone = this.zones[zone] + state = state == null ? 'on' : state + if (state === true) state='on' + if (state === false ) state='off' + if (!zone.auto) zone.auto = state == 'on' && auto==='auto' + // console.log('zone.auto',zone.auto) + if (this.deployed) { + await zone.gpio.read() + if (zone.gpio.state !== state ) { + const cstate = await zone.gpio[state]()?'on':'off' + if(cstate !== state) { + console.log('ERROR: zone state request was not realized, aborting!!') + return {error:'ERROR: zone state request was not realized, aborting!!'} + } + // console.log(zone.id, 'new zone state',state) + zone.state = state + zone.active = zone.gpio.value + } + } + if (pset.use && !(zset.pump === 'off')) { // unless specifically turned off use pump + // console.log('handling pump with zone state') + if (zset.drip) { + if (!this.pump.dripMode && state==='on') this.pumpDripMode(zset.drip) + if (state==='off') { + // console.log(zone.id,'pump off: clearing drip mode') + clearInterval(this.pump.dripInt) + this.pump.dripMode = false + // console.log('--------clearing drip cycling mode------------') + await this.pumpGPIO('off') + } + } else await this.pumpGPIO(state) + } + // console.log(zone.name,zone.id,'is', state) + + if (!zone.auto && state==='on') { + const duration = zset.duration || 10 + console.log('MANUAL - setting timeout to turn off in',duration,'minutes') + zone._offTO = setTimeout( () => { + console.log(zone.name, zone.id, 'run of ', duration,' is over') + this.zoneOn(zone,'off') + }, + duration*60000) + } else clearTimeout(zone._offTO) + + if (state ==='off' && auto==='auto') zone.auto = false + + } + + zoneOff(zone) { + this.zoneOn(zone,'off') + } + +} // end class + +export default Irrigation +export { Irrigation } + +// zone schedule start action +function start (duration,on,off,activeSch) { + console.log('start',duration,on,off,activeSch.name) + return new Promise(resolve => { + console.log('starting',activeSch.runID) + console.log('---watering for', duration,'minutes') + on() + const tick = setInterval(()=>{ + console.log(activeSch.runID,'duration tick') + }, 1000) + this.once('abort:'+activeSch.runID,()=> { + clearTimeout(run) + clearInterval(tick) + off() + console.log('aborting run>>>', activeSch.runID) + }) + const run = setTimeout(()=>{ + console.log('stopping>>>',activeSch.runID) + clearInterval(tick) + off() + this.removeAllListeners('abort:'+activeSch.runID) + resolve() + }, duration*1000 * 60) + }) +} + +// zone stop action +function stop (stop,activeSch) { + console.log('aborting run for action id',activeSch.runID, stop) + this.emit('abort:'+activeSch.runID) +} + +// const objectMap = (obj, fn) => +// Object.fromEntries( +// Object.entries(obj).map( +// ([k, v], i) => [k, fn(v, k, i)] +// ) +// )