From 11c832d19659886c99abeab91524e6c9060878d0 Mon Sep 17 00:00:00 2001 From: Nathaniel Wesley Filardo Date: Mon, 10 Apr 2017 17:06:30 +0000 Subject: [PATCH 1/1] Initial commit --- bin/monitor-graph-cgi.sh | 52 +++++++++++++++ bin/monitor-log-cgi.sh | 5 ++ bin/monitor.py | 91 +++++++++++++++++++++++++ bin/pidloop.py | 129 ++++++++++++++++++++++++++++++++++++ bin/pidmonitor.py | 74 +++++++++++++++++++++ bin/rpb.expect | 32 +++++++++ bin/rrdtool-creates.sh | 41 ++++++++++++ bin/serial.wrap | 8 +++ data/.gitignore | 2 + remind | 12 ++++ runit/pi-sc-monitor/log/run | 2 + runit/pi-sc-monitor/run | 2 + runit/pi-sc-remind/run | 3 + 13 files changed, 453 insertions(+) create mode 100755 bin/monitor-graph-cgi.sh create mode 100755 bin/monitor-log-cgi.sh create mode 100755 bin/monitor.py create mode 100644 bin/pidloop.py create mode 100755 bin/pidmonitor.py create mode 100755 bin/rpb.expect create mode 100755 bin/rrdtool-creates.sh create mode 100755 bin/serial.wrap create mode 100644 data/.gitignore create mode 100644 remind create mode 100755 runit/pi-sc-monitor/log/run create mode 100755 runit/pi-sc-monitor/run create mode 100755 runit/pi-sc-remind/run diff --git a/bin/monitor-graph-cgi.sh b/bin/monitor-graph-cgi.sh new file mode 100755 index 0000000..fcb2fc6 --- /dev/null +++ b/bin/monitor-graph-cgi.sh @@ -0,0 +1,52 @@ +#!/bin/zsh + +DMX_SCALE=5 +DMX_SHIFT=20 + +COMMON_ARGS=( + --end now + --width=960 --height=480 + --lazy + --step=60 -v "degrees C" --upper-limit 32 --lower-limit 19 --rigid + --right-axis-label "DMX" + --right-axis ${DMX_SCALE}:-${DMX_SHIFT} + DEF:tempH=/home/pi/sc/data/heater-temp.rrd:temp:AVERAGE + DEF:tempHN=/home/pi/sc/data/hide-near-temp.rrd:temp:AVERAGE + DEF:dmxHN=/home/pi/sc/data/hide-near-dmx.rrd:dmx:AVERAGE + DEF:tempHF=/home/pi/sc/data/hide-far-temp.rrd:temp:AVERAGE + DEF:tempTN=/home/pi/sc/data/tank-near-temp.rrd:temp:AVERAGE + DEF:tempTF=/home/pi/sc/data/tank-far-temp.rrd:temp:AVERAGE + CDEF:scaled_dmxHN=dmxHN,${DMX_SHIFT},+,${DMX_SCALE},/ + LINE:scaled_dmxHN\#000000:"dmx near" + LINE2:tempH\#FF0000:"heater" + LINE2:tempHN\#808000:"hide near" + LINE2:tempHF\#FF8000:"hide far" + LINE2:tempTN\#00FF00:"tank near" + LINE2:tempTF\#00FFFF:"tank far" + HRULE:30#800000 + HRULE:25#8000FF + HRULE:21#0000FF +) + +case "$QUERY_STRING" in + 4h) OUTFILE="4h.png"; PARAMS=(--start now-4h --title "Melman Vivarium Sensor Data 4h" ) ;; + 24h) OUTFILE="24h.png"; PARAMS=(--start now-24h --title "Melman Vivarium Sensor Data 24h") ;; + 7d) OUTFILE="7d.png"; PARAMS=(--start now-7d --title "Melman Vivarium Sensor Data 7d" ) ;; + 30d) OUTFILE="30d.png"; PARAMS=(--start now-30d --title "Melman Vivarium Sensor Data 30d") ;; + *) + cat <Invalid query parameter '${QUERY_STRING}' +HERE + exit 1 +esac + +rrdtool graph /home/pi/public_html/${OUTFILE} ${PARAMS[@]} ${COMMON_ARGS[@]} >&2 + +cat < 0: + return sk(devfn,float((devlines[1].split(" ")[9])[2:]) / 1000, *arg, **kwarg) + return fk(devfn, *arg, **kwarg) + + +def check_temps(sc): + print ("check temps init") + def check(devfn, temp, threshs, desire): + # Anything too hot: turn off the heater + if (temp > threshs[devfn][1]): desire = "OFF" + # No stated preference and something too cold, turn on the heater + elif (temp <= threshs[devfn][0] and desire == None): desire = "ON" + return desire + + did_interact = False + desire = None + for devfn in owdev_thresholds_heater1: + desire = with_ow_temp(devfn, check, with_ow_temp_fk_id, owdev_thresholds_heater1, desire) + if desire is not None: + did_interact = True + print("Set heater 1 %s" % desire) + os.system("/home/pi/sc/bin/rpb.expect 4 %s | grep -A 5 -e 'Plug ' | tr -d '\015'" % desire) + + # desire = None + # for devfn in owdev_thresholds_heater2: + # desire = with_ow_temp(devfn, check, with_ow_temp_fk_id, owdev_thresholds_heater2, desire) + # if desire is not None: + # did_interact = True + # print("Set heater 2 %s" % desire) + # os.system("/home/pi/sc/bin/rpb.expect 5 %s | grep -A 5 -e 'Plug ' | tr -d '\015'" % desire) + + if not did_interact: + os.system("/home/pi/sc/bin/rpb.expect | grep -A 5 -e 'Plug ' | tr -d '\015'") + + sc.enter(300, 1, check_temps, (sc,)) + print ("check temps fini") + +def do_log_temp(devfn, temp): + print("temp log: %s => %f" % (devfn, temp)) + rrdtool.update(owdev_logname[devfn], "N:" + ("%f" % temp)) + +def read_temps(sc,itime): + for devfn in owdev_logname: with_ow_temp(devfn, do_log_temp, nop) + itime = itime + 60 + sc.enterabs(itime, 2, read_temps, (sc,itime)) + +itime = time.time() +s = sched.scheduler(time.time, time.sleep) +s.enterabs(itime, 1, check_temps, (s,)) +s.enterabs(itime, 2, read_temps, (s,itime)) + +print("Monitor starting...") +s.run() diff --git a/bin/pidloop.py b/bin/pidloop.py new file mode 100644 index 0000000..6dd8317 --- /dev/null +++ b/bin/pidloop.py @@ -0,0 +1,129 @@ +# Based on https://github.com/ivmech/ivPID/blob/master/PID.py +# used under GPLv3 (or later) + +class PIDLoop(object): + + def __init__ (self) : + self.kP = 0.0 + self.kI = 0.0 + self.kD = 0.0 + self.kDDecay = 0.0 + self.smooth_error_denom = 1.0 + + self.hard_max = None + self.hard_min = None + + self.clear() + + pass + + def clear (self) : + self.last_upd_time = None + self.last_error = 0.0 + self.sum_error = 0.0 + self.smooth_error = 0.0 + + def setKP (self, kP) : + self.kP = kP + + def setKD (self, kD, kDDecay) : + self.kD = kD + self.kDDecay = kDDecay + self.smooth_error_denom = 1 / (1 - kDDecay) + + def setKI (self, kI) : + self.kI = kI + + def setPoint (self, v) : + self.setpoint = v + + def setHardMax (self, v) : + self.hard_max = v + + def setHardMin (self, v) : + self.hard_min = v + + def _value (self, error, edeltasmooth) : + + print ("PID LOOP CONTRIBUTIONS: p=%r d=%r i=%r" % (self.kP * error, self.kD * edeltasmooth, self.kI * self.sum_error)) + + return (self.kP * error) + (self.kD * edeltasmooth) + (self.kI * self.sum_error) + + def update (self, value, when) : + if self.setpoint is None : + raise ValueError("Setpoint not set") + + # P + error = self.setpoint - value + + # Time + tdelta = 0.0 + if self.last_upd_time is not None : + tdelta = when - self.last_upd_time + if tdelta < 0 : + tdelta = 0.0 + + # D + edeltasmooth = 0.0 + if tdelta > 0: + edeltasmooth = (error - (self.smooth_error/self.smooth_error_denom))/tdelta + + # I (trapezoidal integration) with optional tests against a hard stop + ov = self._value(error, edeltasmooth) + sum_error_delta = tdelta*(self.last_error + error)/2.0 + + if self.hard_max is not None and ov >= self.hard_max and sum_error_delta > 0: + # Do not increment error; we're already slammed up against the hard limit + pass + elif self.hard_min is not None and ov <= self.hard_min and sum_error_delta < 0: + # Do not decrement error; we're already slammed up against the hard limit + pass + else if sum_error_delta != 0: + # Update sum_error and recompute the output value + self.sum_error += sum_error_delta + ov = self._value(error, edeltasmooth) + + # Advance time + self.last_error = error + self.smooth_error *= self.kDDecay + self.smooth_error += error + self.last_upd_time = when + + return ov + + def __str__ (self) : + return "laste=%r smoothe=%r sume=%r" % (self.last_error, self.smooth_error/self.smooth_error_denom, self.sum_error) + + +# A PID loop with hard limits on its behavior. Designed as a kind of fail-safe should +# oscillations get out of hand or such. +class PIDThresh(PIDLoop): + + def __init__ (self, lowO, lowH, lowS, midO, highS, highH, highO) : + self.low_out = lowO + self.low_hard = lowH + self.low_soft = lowS + self.mid_out = midO + self.high_soft = highS + self.high_hard = highH + self.high_out = highO + self.override = None + super(PIDThresh,self).__init__() + + def clear (self) : + super(PIDThresh,self).clear() + + def update (self, value, when) : + if value > self.high_hard : + self.override = self.high_out + elif value < self.low_hard : + self.override = self.low_out + elif self.override is not None and self.low_soft <= value <= self.high_soft : + self.override = None + super(PIDThresh,self).clear() + + if self.override : return self.override + else : return super(PIDThresh, self).update(value,when) + + def __str__ (self) : + return super(PIDThresh,self).__str__() diff --git a/bin/pidmonitor.py b/bin/pidmonitor.py new file mode 100755 index 0000000..d15ca74 --- /dev/null +++ b/bin/pidmonitor.py @@ -0,0 +1,74 @@ +#!/usr/bin/python + +# Inspired by code found at http://wannabe.guru.org/scott/hobbies/temperature/ + +import os +import sched +import time +import serial +import pidloop +import rrdtool + +dmxdev = serial.Serial("/dev/serial/by-id/usb-DMXking.com_DMX_USB_PRO_6A0SVM7J-if00-port0", 57600); + +loop_hidenear = pidloop.PIDThresh(128,26,28,0,31,33,-128) +loop_hidenear.setPoint(30) +loop_hidenear.setHardMax(128) +loop_hidenear.setHardMin(-128) +loop_hidenear.setKP(60.0) +loop_hidenear.setKI(0.004) +loop_hidenear.setKD(1000.0,0.95) +loop_hidenear.sum_error = -3000.0 # XXX Initialize offset point a bit + +def with_ow_temp_fk_id(devfn, loop, s, *arg, **kwarg): + print("WARNING: failed to read %s" % devfn) + return s # an ugly default + +def with_ow_temp(devfn, sk, fk, *arg, **kwarg): + with open(devfn) as devf: + devstr = devf.read() + devlines = devstr.split("\n") + if devlines[0].find("YES") > 0: + return sk(devfn,float((devlines[1].split(" ")[9])[2:]) / 1000, *arg, **kwarg) + return fk(devfn, *arg, **kwarg) + +def check_temps(sc): + sc.enter(10, 1, check_temps, (sc,)) + + def check(devfn, temp, loop, s, offset, rrd): + desire = 128.0 + loop.update(temp, time.time()) + + if desire < 0 : + desire = 0 + loop.output = 0 + elif desire > 255 : + desire = 255 + loop.output = 255 + + rrdtool.update(rrd, "N:" + ("%f" % desire)) + + return s[:offset] + chr(int(desire+0.5)) + s[offset+1:] + + # DMX conttrol string; initialize to all channels full off + # 7E -- header + # 06 -- type + # 03 00 -- payload length + # 00 -- channel 0 value (ignored by hardware) + # 00 -- channel 1 value + # 00 -- channel 2 value + # E7 -- footer + s = "\x7E\x06\x03\x00\x00\x00\x00\xE7" + + # Drive loop + s = with_ow_temp("/sys/bus/w1/devices/28-011620f10dee/w1_slave", + check, with_ow_temp_fk_id, loop_hidenear, s, 5, "/home/pi/sc/data/hide-near-dmx.rrd") + + print ("check temps fini: out=%r lhn=(%s)" % (s, loop_hidenear)) + assert(dmxdev.write(s) == 8) + +itime = time.time() +s = sched.scheduler(time.time, time.sleep) +s.enterabs(itime, 1, check_temps, (s,)) + +print("Monitor starting...") +s.run() diff --git a/bin/rpb.expect b/bin/rpb.expect new file mode 100755 index 0000000..01c0c9a --- /dev/null +++ b/bin/rpb.expect @@ -0,0 +1,32 @@ +#!/usr/bin/expect + +proc help {} { + global argv0 + global actionnames + send_user "usage: $argv0 outlet {on|off|boot}\n" + exit 1 +} + +proc waitprompt {} { + expect { + "RPB+> " { } + timeout { + send_user " ===> TIMEOUT <===\n" + exit 1 + } + } +} + +spawn /home/pi/sc/bin/serial.wrap [ exec readlink -f /dev/serial/by-path/platform-20980000.usb-usb-0:1.2:1.0-port0 ] +set timeout 70 +send "\n" +waitprompt + +if {$argc == 2} { + set outlet [lindex $argv 0] + set cmd [string toupper [lindex $argv 1]] + + send "/$outlet $cmd\n" + set timeout 30 + waitprompt +} diff --git a/bin/rrdtool-creates.sh b/bin/rrdtool-creates.sh new file mode 100755 index 0000000..78bc5fb --- /dev/null +++ b/bin/rrdtool-creates.sh @@ -0,0 +1,41 @@ +#!/bin/zsh + +RRDS=( + heater-temp + hide-near-temp + hide-far-temp + tank-near-temp + tank-far-temp +) + +ARGS=( + --no-overwrite + --step 60 + DS:temp:GAUGE:900:-10:50 + RRA:AVERAGE:0.5:1:525600 + RRA:AVERAGE:0.25:60:87600 + RRA:MIN:0.025:60:87600 + RRA:MAX:0.025:60:87600 +) + +for rrd in ${=RRDS[@]}; do + rrdtool create /home/pi/sc/data/${rrd}.rrd ${=ARGS[@]} +done + +RRDS=( + hide-near-dmx +) + +ARGS=( + --no-overwrite + --step 10 + DS:dmx:GAUGE:900:0:255 + RRA:AVERAGE:0.5:6:525600 + RRA:AVERAGE:0.25:360:87600 + RRA:MIN:0.025:360:87600 + RRA:MAX:0.025:360:87600 +) + +for rrd in ${=RRDS[@]}; do + rrdtool create /home/pi/sc/data/${rrd}.rrd ${=ARGS[@]} +done diff --git a/bin/serial.wrap b/bin/serial.wrap new file mode 100755 index 0000000..4b347d7 --- /dev/null +++ b/bin/serial.wrap @@ -0,0 +1,8 @@ +#!/bin/sh + +set -e -u + +stty -F $1 sane -echo clocal crtscts hupcl -icanon icrnl inlcr 9600 +stty -F $1 0 2>/dev/null || true +stty -F $1 sane -echo clocal crtscts hupcl -icanon icrnl inlcr 9600 +exec flock -e -w 60 /var/lock/LCK..`basename $1` socat $1 STDIO diff --git a/data/.gitignore b/data/.gitignore new file mode 100644 index 0000000..a46a4f0 --- /dev/null +++ b/data/.gitignore @@ -0,0 +1,2 @@ +*.rrd +monitor.log diff --git a/remind b/remind new file mode 100644 index 0000000..c4337b8 --- /dev/null +++ b/remind @@ -0,0 +1,12 @@ +SET $LongDeg 76 +SET $LongMin 36 +SET $LongSec 33 +SET $LatDeg 39 +SET $LatMin 17 +SET $LatSec 33 + +REM AT [sunrise()] MSG Lights on... +REM AT [sunrise()] RUN /home/pi/sc/bin/rpb.expect 1 on > /dev/null + +REM AT [sunset()] MSG Lights off... +REM AT [sunset()] RUN /home/pi/sc/bin/rpb.expect 1 off > /dev/null diff --git a/runit/pi-sc-monitor/log/run b/runit/pi-sc-monitor/log/run new file mode 100755 index 0000000..e6b7e17 --- /dev/null +++ b/runit/pi-sc-monitor/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec chpst -u pi svlogd -tt /home/pi/sc/data/monitor.log diff --git a/runit/pi-sc-monitor/run b/runit/pi-sc-monitor/run new file mode 100755 index 0000000..10a6247 --- /dev/null +++ b/runit/pi-sc-monitor/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec chpst -u pi:pi:dialout python -u /home/pi/sc/bin/monitor.py diff --git a/runit/pi-sc-remind/run b/runit/pi-sc-remind/run new file mode 100755 index 0000000..01f2bf1 --- /dev/null +++ b/runit/pi-sc-remind/run @@ -0,0 +1,3 @@ +#!/bin/sh + +exec chpst -u pi:pi:dialout remind -z /home/pi/sc/remind -- 2.50.1