# HG changeset patch # User Matt Johnston # Date 1433773954 -28800 # Node ID d16afb5b5cd9a30c66a85643a250ab8a1975adf8 # Parent 9499bd2f344b389e7127d3c3dc36f27d4d93132b# Parent a335760ad4471aeb925ec56ba918a9af76c401b9 merge diff -r a335760ad447 -r d16afb5b5cd9 py/config.py --- 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: diff -r a335760ad447 -r d16afb5b5cd9 py/configwaiter.py --- /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)) + + + + diff -r a335760ad447 -r d16afb5b5cd9 py/gpio_rpi.py --- 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() diff -r a335760ad447 -r d16afb5b5cd9 py/params.py --- 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) diff -r a335760ad447 -r d16afb5b5cd9 py/sensor_ds18b20.py --- 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 [] diff -r a335760ad447 -r d16afb5b5cd9 py/setup_gpio.sh --- 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 diff -r a335760ad447 -r d16afb5b5cd9 py/tempserver.py --- 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: diff -r a335760ad447 -r d16afb5b5cd9 py/uploader.py --- 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 diff -r a335760ad447 -r d16afb5b5cd9 py/utils.py --- 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)) diff -r a335760ad447 -r d16afb5b5cd9 web/log.py --- 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 diff -r a335760ad447 -r d16afb5b5cd9 web/settings.py --- /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() + + + + + diff -r a335760ad447 -r d16afb5b5cd9 web/templog.py --- 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('/') 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)