# HG changeset patch # User darius # Date 1191076799 0 # Node ID 483375ca5d10334c5af25589f8588a625706d59a # Parent 860936fab75f943b4d01448604d798e719d3408b Split into seperate files. diff -r 860936fab75f -r 483375ca5d10 Control.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Control.py Sat Sep 29 14:39:59 2007 +0000 @@ -0,0 +1,121 @@ +#!/usr/bin/env python + +############################################################################ +# Control class for beermon +# +# $Id: Control.py,v 1.1 2007/09/29 14:39:59 darius Exp $ +# +# Depends on: Python 2.3 (I think) +# +############################################################################ +# +# Copyright (C) 2007 Daniel O'Connor. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +############################################################################ + +import threading, ConfigParser, time + +class OWReadError(Exception): + """Raised when we failed to read from a 1-wire device, could be a timeout +or the device is non-existent, etc""" + pass + +class ThreadDied(Exception): + """Raised when the monitor thread has died for some reason""" + pass + +class Control(): + """This class is responsible for controlling the temperature of +the fermenter by switching the heater & cooler.""" + staleDataTime = 30 + + def __init__(self, _log, m, conf): + """m is a MonitorDev object, conf is a ConfigParser object""" + global log + log = _log + + self.m = m + + self.targetTemp = conf.getfloat('control', 'targetTemp') + self.hysteresis = conf.getfloat('control', 'hysteresis') + self.pollInterval = conf.getfloat('control', 'pollInterval') + + log.debug("target temperature - %3.2f" % (self.targetTemp)) + log.debug("hysteresis - %3.2f" % (self.hysteresis)) + log.debug("pollInterval - %d" % (self.pollInterval)) + + self.cv = threading.Condition() + self.cv.acquire() + + def doit(self): + """Runs forever controlling the temperature until something breaks or interrupted""" + log.debug("=== Starting ===") + log.debug("Fermenter Fridge Ambient State New State") + while True: + # Check if our monitor thread has died + if (not self.m.isAlive()): + raise ThreadDied, "Monitor thread has died" + + # Check for stale data + if (self.m.lastUpdate[self.m.fermenterId] + self.staleDataTime < time.time()): + log.debug("Stale data") + self.cv.wait(self.pollInterval) + self.m.setState('idle') + continue + + # Work out what state we should go into + nextState = "-" + # Temperature diff, -ve => too cold, +ve => too warm + diff = self.m.temps[self.m.fermenterId] - self.targetTemp + if (self.m.currState == 'idle'): + # If we're idle then only heat or cool if the temperate difference is out of the + # hysteresis band + if (abs(diff) > self.hysteresis): + if (diff < 0 and self.m.minHeatOffTime + self.m.lastHeatOff < time.time()): + nextState = 'heat' + elif (diff > 0 and self.m.minHeatOffTime + self.m.lastHeatOff < time.time()): + nextState = 'cool' + elif (self.m.currState == 'cool'): + # Work out if we should go idle (based on min on time & overshoot) + if (diff + self.m.minCoolOvershoot < 0 and self.m.minCoolOnTime + self.m.lastCoolOn < time.time()): + nextState = 'idle' + elif (self.m.currState == 'heat'): + # Ditto + if (diff - self.m.minHeatOvershoot > 0 and self.m.minHeatOnTime + self.m.lastHeatOn < time.time()): + nextState = 'idle' + else: + # Not possible.. + raise KeyError + + log.debug("%3.2f %3.2f %3.2f %s %s" % + (self.m.temps[self.m.fermenterId], + self.m.temps[self.m.fridgeId], self.m.temps[self.m.ambientId], + self.m.currState, nextState)) + + if (nextState != "-"): + self.m.setState(nextState) + + self.cv.wait(self.pollInterval) + + diff -r 860936fab75f -r 483375ca5d10 MonitorDev.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/MonitorDev.py Sat Sep 29 14:39:59 2007 +0000 @@ -0,0 +1,238 @@ +#!/usr/bin/env python + +############################################################################ +# Monitoring/control interface to hardware for beermon +# +# $Id: MonitorDev.py,v 1.1 2007/09/29 14:39:59 darius Exp $ +# +# Depends on: Python 2.3 (I think) +# +############################################################################ +# +# Copyright (C) 2007 Daniel O'Connor. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +############################################################################ + +import threading, re, time, pexpect + +class MonitorDev(threading.Thread): + """This class continually polls the hardware for temperature +readings and accepts state changes to heat & cool""" + + # Match a ROM ID (eg 00:11:22:33:44:55:66:77) + romre = re.compile('([0-9a-f]{2}:){7}[0-9a-f]{2}') + # Match the prompt + promptre = re.compile('> ') + + # Dictionary of sensor IDs & temperatures + temps = {} + # Dictionary of sensor IDs & epoch times + lastUpdate = {} + + # List of all device IDs + devs = [] + # List of temperature sensor IDs + tempdevs = [] + + # Lock to gate access to the comms + commsLock = None + + currState = 'idle' + + lastHeatOn = 0 + lastHeatOff = 0 + lastCoolOn = 0 + lastCoolOff = 0 + + def __init__(self, _log, conf): + global log + log = _log + threading.Thread.__init__(self) + + # Collect parameters + self.coolRelay = conf.getint('hardware', 'coolRelay') + self.heatRelay = conf.getint('hardware', 'heatRelay') + self.fermenterId = conf.get('hardware', 'fermenterId') + self.fridgeId = conf.get('hardware', 'fridgeId') + self.ambientId = conf.get('hardware', 'ambientId') + self.minCoolOnTime = conf.getfloat('hardware', 'minCoolOnTime') + self.minCoolOffTime = conf.getfloat('hardware', 'minCoolOffTime') + self.minHeatOnTime = conf.getfloat('hardware', 'minHeatOnTime') + self.minHeatOffTime = conf.getfloat('hardware', 'minHeatOffTime') + self.minHeatOvershoot = conf.getfloat('hardware', 'minHeatOvershoot') + self.minCoolOvershoot = conf.getfloat('hardware', 'minCoolOvershoot') + + # Setup locking & spawn SSH + self.commsLock = threading.Lock() + self.p = pexpect.spawn('/usr/bin/ssh', ['-xt', '-enone', '-i', '/home/darius/.ssh/id_wrt', 'root@wrt', '(echo logged in; microcom -D/dev/cua/1)']) + assert(self.p.expect('logged in') == 0) + self.p.timeout = 3 + self.setspeed() + + # Search for 1-wire modules + self.devs = self.find1wire() + self.tempdevs = filter(self.istemp, self.devs) + + log.debug("fermenterId - %s" % (self.fermenterId)) + log.debug("fridgeId - %s" % (self.fridgeId)) + log.debug("ambientId - %s" % (self.ambientId)) + log.debug("minCoolOnTime - %d, minCoolOffTime - %d" % + (self.minCoolOnTime, self.minCoolOffTime)) + log.debug("minHeatOnTime - %d, minHeatOffTime - %d" % + (self.minHeatOnTime, self.minHeatOffTime)) + log.debug("minHeatOvershoot - %3.2f, minCoolOvershoot - %3.2f" % + (self.minHeatOvershoot, self.minCoolOvershoot)) + self.start() + + def setspeed(self): + self.commsLock.acquire() + self.p.send('~') + assert(self.p.expect('t - set terminal') == 0) + self.p.send('t') + assert(self.p.expect('p - set speed') == 0) + self.p.send('p') + assert(self.p.expect('f - 38400') == 0) + self.p.send('f') + assert(self.p.expect('done!') == 0) + self.commsLock.release() + + def find1wire(self): + self.commsLock.acquire() + self.p.sendline('') + assert(self.p.expect('> ') == 0) + self.p.sendline('sr') + # Echo + assert(self.p.expect('sr') == 0) + + # Send a new line which will give us a command prompt to stop on + # later. We could use read() but that would make the code a lot + # uglier + self.p.sendline('') + + devlist = [] + + # Loop until we get the command prompt (> ) collecting ROM IDs + while True: + idx = self.p.expect([self.romre, self.promptre]) + if (idx == 0): + # Matched a ROM + #print "Found ROM " + self.p.match.group() + devlist.append(self.p.match.group(0)) + elif (idx == 1): + # Matched prompt, exit + break + else: + # Unpossible! + self.commsLock.release() + raise SystemError() + + self.commsLock.release() + + return(devlist) + + def istemp(self, id): + [family, a, b, c, d, e, f, g] = id.split(':') + if (family == '10'): + return True + else: + return False + + def updateTemps(self): + for i in self.tempdevs: + try: + self.temps[i] = float(self.readTemp(i)) + self.lastUpdate[i] = time.time() + except OWReadError: + # Ignore this - just results in no update reflected by lastUpdate + pass + + return(self.temps) + + def readTemp(self, id): + self.commsLock.acquire() + cmd = 'te ' + id + self.p.sendline(cmd) + # Echo + assert(self.p.expect(cmd) == 0) + # Eat EOL left from expect + self.p.readline() + + line = self.p.readline().strip() + self.commsLock.release() + # 'CRC mismatch' can mean that we picked the wrong ROM.. + if (re.match('CRC mismatch', line) != None): + raise OWReadError + + return(line) + + def setState(self, state): + if (state == 'cool'): + relay = 1 << self.coolRelay + elif (state == 'heat'): + relay = 1 << self.heatRelay + elif (state == 'idle'): + relay = 0 + else: + raise(ValueError) + + if (state == self.currState): + return + + # Keep track of when we last turned off or on + if (state == 'cool'): + if (self.currState == 'heat'): + self.lastHeatOff = time.time() + self.lastCoolOn = time.time() + elif (state == 'heat'): + if (self.currState == 'cool'): + self.lastCoolOff = time.time() + self.lastHeatOn = time.time() + else: + if (self.currState == 'cool'): + self.lastCoolOff = time.time() + if (self.currState == 'heat'): + self.lastHeatOff = time.time() + + self.currState = state + + self.commsLock.acquire() + # Need the extra spaces cause the parser in the micro is busted + cmd = 'out c %02x' % relay + self.p.sendline(cmd) + # Echo + assert(self.p.expect(cmd) == 0) + self.commsLock.release() + + def polltemps(self, temps): + while True: + for d in temps: + #print d + t = gettemp(p, d) + print "%s -> %s" % (d, t) + print + + def run(self): + while True: + self.updateTemps() + diff -r 860936fab75f -r 483375ca5d10 beermon.py --- a/beermon.py Sat Sep 29 02:23:24 2007 +0000 +++ b/beermon.py Sat Sep 29 14:39:59 2007 +0000 @@ -4,7 +4,7 @@ # Monitor & control fermenter temperature # v1.0 # -# $Id: beermon.py,v 1.6 2007/09/29 02:23:24 darius Exp $ +# $Id: beermon.py,v 1.7 2007/09/29 14:39:59 darius Exp $ # # Depends on: Python 2.3 (I think) # @@ -36,274 +36,9 @@ ############################################################################ -import pexpect, re, threading, time, logging, sys, traceback +import time, logging, sys, traceback, ConfigParser, MonitorDev, Control from logging.handlers import RotatingFileHandler -class ROMReadError(Exception): - pass - -class ThreadDied(Exception): - pass - -class Control(): - targetTemp = 18 - hysteresis = 0.5 - pollInterval = 30 - staleDataTime = 30 - - def __init__(self, m, _log): - self.m = m - global log - log = _log - self.cv = threading.Condition() - self.cv.acquire() - - def doit(self): - log.debug("target temperature - %3.2f" % (self.targetTemp)) - log.debug("fermenterId - %s" % (self.m.fermenterId)) - log.debug("fridgeId - %s" % (self.m.fridgeId)) - log.debug("ambientId - %s" % (self.m.ambientId)) - log.debug("minCoolOnTime - %d, minCoolOffTime - %d" % (self.m.minCoolOnTime, self.m.minCoolOffTime)) - log.debug("minHeatOnTime - %d, minHeatOffTime - %d" % (self.m.minHeatOnTime, self.m.minHeatOffTime)) - log.debug("pollInterval - %d" % (self.pollInterval)) - - log.debug("=== Starting ===") - log.debug("Fermenter Fridge Ambient State New State") - while True: - # Check if our monitor thread has died - if (not self.m.isAlive()): - raise ThreadDied, "Monitor thread has died" - - # Check for stale data - if (self.m.lastUpdate[self.m.fermenterId] + self.staleDataTime < time.time()): - log.debug("Stale data") - self.cv.wait(self.pollInterval) - self.m.setState('idle') - continue - - # Work out what state we should go into - nextState = "-" - # Temperature diff, -ve => too cold, +ve => too warm - diff = self.m.temps[self.m.fermenterId] - self.targetTemp - if (self.m.currState == 'idle'): - # If we're idle then only heat or cool if the temperate difference is out of the - # hysteresis band - if (abs(diff) > self.hysteresis): - if (diff < 0 and self.m.minHeatOffTime + self.m.lastHeatOff < time.time()): - nextState = 'heat' - elif (diff > 0 and self.m.minHeatOffTime + self.m.lastHeatOff < time.time()): - nextState = 'cool' - elif (self.m.currState == 'cool'): - # Work out if we should go idle (based on min on time & overshoot) - if (diff + self.m.minCoolOvershoot < 0 and self.m.minCoolOnTime + self.m.lastCoolOn < time.time()): - nextState = 'idle' - elif (self.m.currState == 'heat'): - # Ditto - if (diff - self.m.minHeatOvershoot > 0 and self.m.minHeatOnTime + self.m.lastHeatOn < time.time()): - nextState = 'idle' - else: - # Not possible.. - raise KeyError - - log.debug("%3.2f %3.2f %3.2f %s %s" % - (self.m.temps[self.m.fermenterId], - self.m.temps[self.m.fridgeId], self.m.temps[self.m.ambientId], - self.m.currState, nextState)) - - if (nextState != "-"): - self.m.setState(nextState) - - self.cv.wait(self.pollInterval) - - -class MonitorDev(threading.Thread): - # Match a ROM ID (eg 00:11:22:33:44:55:66:77) - romre = re.compile('([0-9a-f]{2}:){7}[0-9a-f]{2}') - # Match the prompt - promptre = re.compile('> ') - - coolRelay = 7 - heatRelay = 6 - - fermenterId = '10:eb:48:21:01:08:00:df' - fridgeId = '10:a6:2a:c4:00:08:00:11' - ambientId = '10:97:1b:fe:00:08:00:d1' - - # minimum time the cooler must spend on/off - minCoolOnTime = 10 * 60 - minCoolOffTime = 10 * 60 - - # minimum time the heater must spend on/off - minHeatOnTime = 60 - minHeatOffTime = 60 - - # minimum to overshoot on heating/cooling - minHeatOvershoot = 1 - minCoolOvershoot = 0 - - # Dictionary of sensor IDs & temperatures - temps = {} - # Dictionary of sensor IDs & epoch times - lastUpdate = {} - - # List of all device IDs - devs = [] - # List of temperature sensor IDs - tempdevs = [] - - # Lock to gate access to the comms - commsLock = None - - currState = 'idle' - - lastHeatOn = 0 - lastHeatOff = 0 - lastCoolOn = 0 - lastCoolOff = 0 - - def __init__(self): - threading.Thread.__init__(self) - self.commsLock = threading.Lock() - self.p = pexpect.spawn('/usr/bin/ssh', ['-xt', '-enone', '-i', '/home/darius/.ssh/id_wrt', 'root@wrt', '(echo logged in; microcom -D/dev/cua/1)']) - assert(self.p.expect('logged in') == 0) - self.p.timeout = 3 - self.setspeed() - self.devs = self.find1wire() - self.tempdevs = filter(self.istemp, self.devs) - - self.start() - - def setspeed(self): - self.commsLock.acquire() - self.p.send('~') - assert(self.p.expect('t - set terminal') == 0) - self.p.send('t') - assert(self.p.expect('p - set speed') == 0) - self.p.send('p') - assert(self.p.expect('f - 38400') == 0) - self.p.send('f') - assert(self.p.expect('done!') == 0) - self.commsLock.release() - - def find1wire(self): - self.commsLock.acquire() - self.p.sendline('') - assert(self.p.expect('> ') == 0) - self.p.sendline('sr') - # Echo - assert(self.p.expect('sr') == 0) - - # Send a new line which will give us a command prompt to stop on - # later. We could use read() but that would make the code a lot - # uglier - self.p.sendline('') - - devlist = [] - - # Loop until we get the command prompt (> ) collecting ROM IDs - while True: - idx = self.p.expect([self.romre, self.promptre]) - if (idx == 0): - # Matched a ROM - #print "Found ROM " + self.p.match.group() - devlist.append(self.p.match.group(0)) - elif (idx == 1): - # Matched prompt, exit - break - else: - # Unpossible! - self.commsLock.release() - raise SystemError() - - self.commsLock.release() - - return(devlist) - - def istemp(self, id): - [family, a, b, c, d, e, f, g] = id.split(':') - if (family == '10'): - return True - else: - return False - - def updateTemps(self): - for i in self.tempdevs: - try: - self.temps[i] = float(self.readTemp(i)) - self.lastUpdate[i] = time.time() - except ROMReadError: - # Ignore this - just results in no update reflected by lastUpdate - pass - - return(self.temps) - - def readTemp(self, id): - self.commsLock.acquire() - cmd = 'te ' + id - self.p.sendline(cmd) - # Echo - assert(self.p.expect(cmd) == 0) - # Eat EOL left from expect - self.p.readline() - - line = self.p.readline().strip() - self.commsLock.release() - # 'CRC mismatch' can mean that we picked the wrong ROM.. - if (re.match('CRC mismatch', line) != None): - raise ROMReadError - - return(line) - - def setState(self, state): - if (state == 'cool'): - relay = 1 << self.coolRelay - elif (state == 'heat'): - relay = 1 << self.heatRelay - elif (state == 'idle'): - relay = 0 - else: - raise(ValueError) - - if (state == self.currState): - return - - # Keep track of when we last turned off or on - if (state == 'cool'): - if (self.currState == 'heat'): - self.lastHeatOff = time.time() - self.lastCoolOn = time.time() - elif (state == 'heat'): - if (self.currState == 'cool'): - self.lastCoolOff = time.time() - self.lastHeatOn = time.time() - else: - if (self.currState == 'cool'): - self.lastCoolOff = time.time() - if (self.currState == 'heat'): - self.lastHeatOff = time.time() - - self.currState = state - - self.commsLock.acquire() - # Need the extra spaces cause the parser in the micro is busted - cmd = 'out c %02x' % relay - self.p.sendline(cmd) - # Echo - assert(self.p.expect(cmd) == 0) - self.commsLock.release() - - def polltemps(self, temps): - while True: - for d in temps: - #print d - t = gettemp(p, d) - print "%s -> %s" % (d, t) - print - - def run(self): - while True: - self.updateTemps() - def initLog(): # Init our logging log = logging.getLogger("monitor") @@ -329,22 +64,34 @@ return(log) def main(): - import beermon - global log log = initLog() + conf = ConfigParser.ConfigParser() + conf.read('beermon.ini') + + for s in ['control', 'hardware']: + if (not conf.has_section(s)): + log.debug("Mandatory '%s' section missing from config file, exiting" % (s)) + sys.exit(1) + log.debug("=== Initing ===") - log.debug("$Id: beermon.py,v 1.6 2007/09/29 02:23:24 darius Exp $") + log.debug("$Id: beermon.py,v 1.7 2007/09/29 14:39:59 darius Exp $") - m = None + try: + m = MonitorDev.MonitorDev(log, conf) + c = Control.Control(log, m, conf) + except ConfigParser.NoOptionError, e: + log.debug("Mandatory option '%s' missing from section '%s'" % (e.option, e.section)) + sys.exit(1) + except ValueError, e: + log.debug("Unable to parse option - " + str(e)) + exitCode = 0 try: - m = beermon.MonitorDev() - - c = beermon.Control(m, log) # Wait for the first temperature readings to come through, saves # getting an 'invalid data' message + # XXX: sleep on condvar holding data? time.sleep(3) c.doit() log.debug("doit exited") @@ -358,7 +105,7 @@ log.debug(reduce(lambda x, y: x + y, traceback.format_exception( sys.exc_type, sys.exc_value, sys.exc_traceback))) exitCode = 1 - + finally: # Make sure we try and turn it off if something goes wrong if (m != None):