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 schedulermaster
parent
477c7d8611
commit
5576ddea54
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
/node_modules/
|
||||
/.yalc/
|
||||
/irrigation.yml
|
|
@ -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.
|
|
@ -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 "
|
|
@ -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 "
|
|
@ -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 "
|
|
@ -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 "
|
|
@ -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 "
|
|
@ -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
|
|
@ -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')
|
||||
})
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"ignoreRoot": [".git"],
|
||||
"watch": ["node_modules/@uci/","node_modules/@uci-utils/","./*"],
|
||||
"ignore":["irrigation-settings.json"],
|
||||
"ext":"js,json,yaml,yml"
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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 }
|
|
@ -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}
|
|
@ -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 }
|
|
@ -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)
|
||||
}
|
|
@ -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)]
|
||||
// )
|
||||
// )
|
Loading…
Reference in New Issue