changeset 255:8ef52f27cf95

merge
author Matt Johnston <matt@ucc.asn.au>
date Mon, 08 Jun 2015 22:32:34 +0800
parents 0a1b642e3086 (diff) 3e6f82347eab (current diff)
children 6d06795aefbb
files web/templog.py
diffstat 12 files changed, 248 insertions(+), 69 deletions(-) [+]
line wrap: on
line diff
--- a/py/config.py	Sat Jun 06 15:34:03 2015 +0800
+++ b/py/config.py	Mon Jun 08 22:32:34 2015 +0800
@@ -20,8 +20,9 @@
 INTERNAL_TEMPERATURE = '/sys/class/thermal/thermal_zone0/temp'
 
 HMAC_KEY = "a key"
-#UPDATE_URL = 'https://matt.ucc.asn.au/test/templog/update'
-UPDATE_URL = 'https://evil.ucc.asn.au/~matt/templog/update'
+SERVER_URL = 'https://evil.ucc.asn.au/~matt/templog/update'
+UPDATE_URL = "%s/update" % SERVER_URL
+SETTINGS_URL = "%s/update" % SERVER_URL
 
 # site-local values overridden in localconfig, eg WORT_NAME, HMAC_KEY
 try:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/py/configwaiter.py	Mon Jun 08 22:32:34 2015 +0800
@@ -0,0 +1,43 @@
+class ConfigWaiter(object):
+	""" Waits for config updates from the server. http long polling """
+
+	def __init__(self, server):
+		self.server = server
+		self.epoch_tag = None
+		self.http_session = aiohttp.ClientSession()
+
+	@asyncio.coroutine
+	def run(self):
+		# wait until someting has been uploaded (the uploader itself waits 5 seconds)
+		yield from asyncio.sleep(10)
+		while True:
+			yield from self.do()
+
+			# avoid spinning too fast
+			yield from server.sleep(1)
+
+	@asyncio.coroutine
+	def do(self):
+		try:
+			if self.epoch_tag:
+				headers = {'etag': self.epoch_tag}
+			else:
+				headers = None
+
+	        r = yield from asyncio.wait_for(
+	        	self.http_session.get(config.SETTINGS_URL, headers=headers), 
+	        	300)
+	        if r.status == 200:
+		        resp = yield from asyncio.wait_for(r.json(), 300)
+
+		        self.epoch_tag = resp['epoch_tag']
+		        epoch = self.epoch_tag.split('-')[0]
+		        if self.server.params.receive(resp['params'], epoch):
+		        	self.server.reload_signal(True)
+
+		 except Exception as e:
+		 	E("Error watching config: %s" % str(e))
+
+
+
+
--- a/py/gpio_rpi.py	Sat Jun 06 15:34:03 2015 +0800
+++ b/py/gpio_rpi.py	Mon Jun 08 22:32:34 2015 +0800
@@ -7,15 +7,43 @@
 __all__ = ["Gpio"]
 
 class Gpio(object):
-	def __init__(self, pin, name):
-		self.pin = pin
-		self.name = name
-		GPIO.setmode(GPIO.BOARD)
-		GPIO.setup(self.pin, GPIO.OUT)
+    SYS_GPIO_BASE = '/sys/class/gpio/gpio'
+    def __init__(self, pin, name):
+        self.pin = pin
+        self.name = name
+
+        dir_fn = '%s%d/direction' % (self.SYS_GPIO_BASE, pin)
+        with open(dir_fn, 'w') as f:
+            # make sure it doesn't start "on"
+            f.write('low')
+        val_fn = '%s%d/value' % (self.SYS_GPIO_BASE, pin)
+        self.value_file = open(val_fn, 'r+')
+
+    def turn(self, value):
+        self.value_file.seek(0)
+        self.value_file.write('1' if value else '0')
+        self.value_file.flush()
 
-	def turn(self, value):
-		self.state = bool(value)
-		GPIO.output(self.pin, self.state)
+    def get_state(self):
+        self.value_file.seek(0)
+        buf = self.value_file.read().strip()
+        if buf == '0':
+            return False
+        if buf != '1':
+            E("Bad value read from gpio '%s': '%s'" 
+                % (self.value_file.name, buf))
+        return True
+
 
-	def get_state(self):
-		return GPIO.input(self.pin)
+def main():
+    g = Gpio(17, 'f')
+    g.turn(1)
+
+    print(g.get_state())
+
+    g.turn(0)
+
+    print(g.get_state())
+
+if __name__ == '__main__':
+    main()
--- a/py/params.py	Sat Jun 06 15:34:03 2015 +0800
+++ b/py/params.py	Mon Jun 08 22:32:34 2015 +0800
@@ -3,9 +3,11 @@
 import json
 import signal
 import io
+import tempfile
 
 import config
 from utils import W,L,E,EX
+import utils
 
 _FIELD_DEFAULTS = {
     'fridge_setpoint': 16,
@@ -24,6 +26,7 @@
 
     def __init__(self):
         self.update(_FIELD_DEFAULTS)
+        self._epoch = None
 
     def __getattr__(self, k):
         return self[k]
@@ -45,6 +48,7 @@
             if k not in self:
                 raise self.Error("Unknown parameter %s=%s in file '%s'" % (str(k), str(u[k]), getattr(f, 'name', '???')))
         self.update(u)
+        self._epoch = utils.hexnonce()
 
         L("Loaded parameters")
         L(self.save_string())
@@ -60,19 +64,54 @@
                     W("Missing parameter file, using defaults. %s" % str(e))
                     return
 
-    def _do_save(self, f):
-        json.dump(self, f, sort_keys=True, indent=4)
-        f.write('\n')
-        f.flush()
+    def get_epoch(self):
+        return self._epoch
+
+    def receive(self, params, epoch):
+        """ updates parameters from the server. does some validation,
+        writes config file to disk.
+        Returns True on success, False failure 
+        """
+
+        if epoch != self._epoch:
+            return
+
+        def same_type(a, b):
+            ta = type(a)
+            tb = type(b)
+
+            if ta == int:
+                ta = float
+            if tb == int:
+                tb = float
+
+            return ta == tb
 
-    def save(self, f = None):
-        if f:
-            return self._do_save(f)
-        else:
-            with file(config.PARAMS_FILE, 'w') as f:
-                return self._do_save(f)
+        if self.keys() != new_params.keys():
+            diff = self.keys() ^ new_params.keys()
+            E("Mismatching params, %s" % str(diff))
+            return False
+
+        for k, v in new_params.items():
+            if not same_type(v, self[k]):
+                E("Bad type for %s" % k)
+                return False
+
+        dir = os.path.dirname(config.PARAMS_FILE)
+        try:
+            t = tempfile.NamedTemporaryFile(prefix='config',
+                dir = dir,
+                delete = False)
+
+            t.write(json.dumps(new_params, sort_keys=True, indent=4)+'\n')
+            name = t.name
+            t.close()
+
+            os.rename(name, config.PARAMS_FILE)
+            return True
+        except Exception as e:
+            E("Problem: %s" % e)
+            return False
 
     def save_string(self):
-        s = io.StringIO()
-        self.save(s)
-        return s.getvalue()
+        return json.dumps(self, sort_keys=True, indent=4)
--- a/py/sensor_ds18b20.py	Sat Jun 06 15:34:03 2015 +0800
+++ b/py/sensor_ds18b20.py	Mon Jun 08 22:32:34 2015 +0800
@@ -8,7 +8,7 @@
 import config
 from utils import D,L,W,E,EX
 
-class DS18B20s(object):
+class SensorDS18B20(object):
 
     THERM_RE = re.compile('.* YES\n.*t=(.*)\n', re.MULTILINE)
 
@@ -37,6 +37,7 @@
             yield from self.do()
             yield from self.server.sleep(config.SENSOR_SLEEP)
 
+
     @asyncio.coroutine
     def read_wait(self, f):
         # handles a blocking file read with a threadpool. A
@@ -45,7 +46,7 @@
         # the ds18b20 takes ~750ms to read, which is noticable
         # interactively.
         loop = asyncio.get_event_loop()
-        yield from loop.run_in_executor(self.readthread, f.read)
+        return loop.run_in_executor(None, f.read)
 
     @asyncio.coroutine
     def do_sensor(self, s, contents = None):
@@ -53,8 +54,8 @@
         try:
             if contents is None:
                 fn = os.path.join(self.master_dir, s, 'w1_slave')
-                f = open(fn, 'r')
-                contents = yield from self.read_wait(f)
+                with open(fn, 'r') as f:
+                    contents = yield from self.read_wait(f)
 
             match = self.THERM_RE.match(contents)
             if match is None:
@@ -71,7 +72,8 @@
 
     def do_internal(self):
         try:
-            return int(open(config.INTERNAL_TEMPERATURE, 'r').read()) / 1000.0
+            with open(config.INTERNAL_TEMPERATURE, 'r') as f:
+                return int(f.read()) / 1000.0
         except Exception as e:
             EX("Problem reading internal sensor: %s" % str(e))
             return None
@@ -80,7 +82,8 @@
     def sensor_names(self):
         """ Returns a sequence of sensorname """
         slaves_path = os.path.join(self.master_dir, "w1_master_slaves")
-        contents = open(slaves_path, 'r').read()
+        with open(slaves_path, 'r') as f:
+            contents = f.read()
         if 'not found' in contents:
             E("No W1 sensors found")
             return []
--- a/py/setup_gpio.sh	Sat Jun 06 15:34:03 2015 +0800
+++ b/py/setup_gpio.sh	Mon Jun 08 22:32:34 2015 +0800
@@ -2,15 +2,8 @@
 
 # this must run as root
 
-PINS="17 7 24 25"
-GROUP=fridgeio
+PINS="17"
 
 for PIN in $PINS; do
     echo $PIN > /sys/class/gpio/export
-
-    for f in direction value; do
-        fn=/sys/devices/virtual/gpio/gpio$PIN/$f
-        chgrp $GROUP $fn
-        chmod g+rw $fn
-    done
 done
--- a/py/tempserver.py	Sat Jun 06 15:34:03 2015 +0800
+++ b/py/tempserver.py	Mon Jun 08 22:32:34 2015 +0800
@@ -32,9 +32,10 @@
         self.params = params.Params()
         self.fridge = fridge.Fridge(self)
         self.uploader = uploader.Uploader(self)
+        self.configwaiter = configwaiter.ConfigWaiter(self)
         self.params.load()
         self.set_sensors(sensor.make_sensor(self))
-        asyncio.get_event_loop().add_signal_handler(signal.SIGHUP, self._reload_signal)
+        asyncio.get_event_loop().add_signal_handler(signal.SIGHUP, self.reload_signal)
         return self
 
     def __exit__(self, exc_type, exc_value, traceback):
@@ -52,6 +53,7 @@
             self.fridge.run(),
             self.sensors.run(),
             self.uploader.run(),
+            self.configwaiter.run(),
         ]
 
         loop = asyncio.get_event_loop()
@@ -109,10 +111,11 @@
         except asyncio.TimeoutError:
             pass
 
-    def _reload_signal(self):
+    def reload_signal(self, no_file = False):
         try:
-            self.params.load()
-            L("Reloaded.")
+            if not no_file:
+                self.params.load()
+                L("Reloaded.")
             self._wakeup.set()
             self._wakeup.clear()
         except Error as e:
--- a/py/uploader.py	Sat Jun 06 15:34:03 2015 +0800
+++ b/py/uploader.py	Mon Jun 08 22:32:34 2015 +0800
@@ -36,6 +36,7 @@
         tosend['fridge_name'] = self.server.wort_name
 
         tosend['current_params'] = dict(self.server.params)
+        tosend['current_params_epoch'] = self.server.params.get_epoch()
 
         tosend['start_time'] = self.server.start_time
         tosend['uptime'] = utils.uptime()
@@ -48,12 +49,12 @@
         if self.server.test_mode():
             D("Would upload %s to %s" % (js, config.UPDATE_URL))
             return
-        js_enc = binascii.b2a_base64(zlib.compress(js.encode()))
-        mac = hmac.new(config.HMAC_KEY.encode(), js_enc, hashlib.sha1).hexdigest()
-        send_data = {'data': js_enc, 'hmac': mac}
+        js_enc = binascii.b2a_base64(zlib.compress(js.encode())).strip()
+        mac = hmac.new(config.HMAC_KEY.encode(), js_enc, hashlib.sha256).hexdigest()
+        send_data = {'data': js_enc.decode(), 'hmac': mac}
         r = yield from asyncio.wait_for(aiohttp.request('post', config.UPDATE_URL, data=send_data), 60)
         result = yield from asyncio.wait_for(r.text(), 60)
-        if result != 'OK':
+        if r.status == 200 and result != 'OK':
             raise Exception("Server returned %s" % result)
 
     @asyncio.coroutine
--- a/py/utils.py	Sat Jun 06 15:34:03 2015 +0800
+++ b/py/utils.py	Mon Jun 08 22:32:34 2015 +0800
@@ -4,6 +4,7 @@
 import time
 import select
 import logging
+import binascii
 
 D = logging.debug
 L = logging.info
@@ -133,3 +134,5 @@
     except Exception as e:
         return -1
 
+def hexnonce():
+    return binascii.hexlify(os.urandom(120))
--- a/web/log.py	Sat Jun 06 15:34:03 2015 +0800
+++ b/web/log.py	Mon Jun 08 22:32:34 2015 +0800
@@ -20,6 +20,9 @@
 
 import config
 import atomicfile
+import settings
+
+fridge_settings = settings.Settings()
 
 def sensor_rrd_path(s):
     return '%s/sensor_%s.rrd' % (config.DATA_PATH, str(s))
@@ -245,16 +248,11 @@
     return val_ticks + float(val_rem) * tick_secs / tick_wake
 
 def write_current_params(current_params):
-    out = {}
-    out['params'] = current_params
-    out['time'] = time.time()
-    atomicfile.AtomicFile("%s/current_params.txt" % config.DATA_PATH).write(
-        json.dumps(out, sort_keys=True, indent=4)+'\n')
+    fridge_settings.update(current_params)
 
 def read_current_params():
-    p = atomicfile.AtomicFile("%s/current_params.txt" % config.DATA_PATH).read()
-    dat = json.loads(p)
-    return dat['params']
+    params, epochtag = fridge_settings.get()
+    return params
 
 def parse(params):
 
@@ -278,10 +276,11 @@
 
     # one-off measurements here
     current_params = params['current_params']
+    current_epoch = params['current_params_epoch']
     measurements['fridge_on'] = [ (time.time(), params['fridge_on']) ]
     measurements['fridge_setpoint'] = [ (time.time(), current_params['fridge_setpoint']) ]
 
-    write_current_params(current_params)
+    write_current_params(current_params, current_epoch)
 
     for s, vs in measurements.iteritems():
         sensor_update(s, vs)
@@ -371,10 +370,7 @@
         if not same_type(v, _FIELD_DEFAULTS[k]):
             return "Bad type for %s, %s vs %s" % (k , type(v), type(_FIELD_DEFAULTS[k]))
 
-    ret = send_params(params) 
-    if ret is not True:
-        return "Failed sending params: %s" % ret
-
+    fridge_settings.update(params)
     return True
 
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/settings.py	Mon Jun 08 22:32:34 2015 +0800
@@ -0,0 +1,61 @@
+import gevent
+import fcntl
+import hashlib
+
+class Settings(object):
+    RAND_SIZE = 15 # 120 bits
+
+    """ Handles state updates from both the web UI and from the fridge client.
+    The fridge client is canonical. It provides the epoch (apart from 'startepoch'), that
+    is changed any time the fridge reloads its local config. The fridge only accepts
+    updates that have the same epoch.
+
+    When the web UI changes it keeps the same epoch but generates a new tag. The fridge sends
+    its current known tag and waits for it to change.
+
+    content is opaque, presently a dictionary of decoded json 
+    """
+
+    def __init__(self):
+        self.event = gevent.event.Event()
+        self.contents = None
+        self.epoch = None
+        self.tag = None
+
+        self.update(self, None, 'startepoch')
+
+    def wait(self, epoch_tag = None, timeout = None):
+        """ returns false if the timeout was hit """
+        if self.epoch_tag() != epoch_tag:
+            # has alredy changed
+            return True
+        return self.event.wait(timeout)
+
+    def epoch_tag(self):
+        return '%s-%s' % (self.epoch, self.tag)
+
+    def random(self):
+        return binascii.hexlify(os.urandom(self.RAND_SIZE))
+
+    def update(self, contents, epoch = None):
+        """ replaces settings contents and updates waiters if changed """
+        if epoch:
+            if self.epoch == epoch:
+                return
+            else:
+                self.epoch = epoch
+
+        self.tag = self.random()
+        self.contents = contents
+
+        self.event.set()
+        self.event.clear()
+
+    def get(self):
+        """ Returns (contents, epoch-tag) """
+        return self.contents, self.epoch_tag()
+
+
+
+
+
--- a/web/templog.py	Sat Jun 06 15:34:03 2015 +0800
+++ b/web/templog.py	Mon Jun 08 22:32:34 2015 +0800
@@ -28,9 +28,8 @@
     def run(*args, **argm):
         argm['server'] = 'gevent'
         super(TemplogBottle, self).run(*args, **argm)
-        print "ran custom bottle"
 
-#bottle.default_app.push(TemplogBottle())
+bottle.default_app.push(TemplogBottle())
 
 secure.setup_csrf()
 
@@ -62,7 +61,6 @@
 def encode_data(data, mimetype):
     return 'data:%s;base64,%s' % (mimetype, binascii.b2a_base64(data).rstrip())
 
-
 @route('/graph.png')
 def graph():
     response.set_header('Content-Type', 'image/png')
@@ -76,12 +74,12 @@
     csrf_blob = post_json['csrf_blob']
 
     if not secure.check_csrf_blob(csrf_blob):
-        bottle.response.status = 403
+        response.status = 403
         return "Bad csrf"
 
     ret = log.update_params(post_json['params'])
     if not ret is True:
-        bottle.response.status = 403
+        response.status = 403
         return ret
         
     return "Good"
@@ -159,16 +157,26 @@
     #var_lookup = environ['mod_ssl.var_lookup']
     #return var_lookup("SSL_SERVER_I_DN_O")
 
-@route('/wait')
-def wait():
-    response.set_header('Content-Type', 'text/plain')
-    yield 'done'
+@route('/get_settings')
+def get_settings():
+    response.set_header('Cache-Control', 'no-cache')
+    req_etag = request.headers.get('etag', None)
+    if req_etag:
+        # wait for it to change
+        if not log.fridge_settings.wait(req_etag, timeout=LONG_POLL_TIMEOUT):
+            response.status = 304
+            return "Nothing happened"
+
+    response.set_header('Content-Type', 'application/json')
+    contents, epoch_tag = client_settings.get()
+    return json.dumps({'params': contents, 'epoch_tag': epoch_tag})
 
 @bottle.get('/<filename:re:.*\.js>')
 def javascripts(filename):
     response.set_header('Cache-Control', "public, max-age=1296000")
     return bottle.static_file(filename, root='static')
 
+
 def main():
     """ for standalone testing """
     #bottle.debug(True)