├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── bin ├── downsample ├── oled-display ├── sensors-src ├── sqlite-sink └── thingspeak ├── config.json ├── git-hooks └── pre-commit ├── lib └── rpjios │ ├── AnalogBase.py │ ├── Command.py │ ├── Discovery.py │ ├── RedisBase.py │ ├── SensorBase.py │ ├── Sensors.py │ ├── SubscriberBase.py │ ├── Types.py │ ├── Util.py │ ├── __init__.py │ ├── __main__.py │ ├── devices │ ├── .gitignore │ ├── 74HC595.py │ ├── SPS30.py │ └── __init__.py │ └── sensors │ ├── BME280.py │ ├── BME680.py │ ├── DHTXX.py │ ├── DS18S20.py │ ├── LM335.py │ ├── NetInfo.py │ ├── SPS30.py │ ├── Soil.py │ ├── SysInfo.py │ ├── TEPT5700.py │ └── __init__.py ├── mapping.json ├── requirements-nonRPi.txt ├── requirements.txt ├── setup.sh └── version.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | env 3 | *.stdout 4 | *.stderr 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "embedded-sps"] 2 | path = embedded-sps 3 | url = https://github.com/rpj/embedded-sps.git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ryan Joseph 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RPJiOS 2 | 3 | [![Raspberry Pi Zero W](https://www.vectorlogo.zone/logos/raspberrypi/raspberrypi-ar21.svg)](https://www.raspberrypi.org/) 4 | [![Python](https://www.vectorlogo.zone/logos/python/python-ar21.svg)](https://www.python.org/) 5 | [![Redis](https://www.vectorlogo.zone/logos/redis/redis-ar21.svg)](https://redis.io/) 6 | [![Debian](https://www.vectorlogo.zone/logos/debian/debian-ar21.svg)](https://www.raspbian.org/) 7 | 8 | A [pub/sub](https://en.wikipedia.org/wiki/Publish–subscribe_pattern)-based implementation of a 9 | Raspberry Pi data pipeline built on [redis](https://redis.io) and centered around sensors. 10 | 11 | The general philosophy is that a "sensor" is any entity (physical or not) that operates primarily 12 | in an output-only mode (configuration doesn't necessarily count as an "input" so is allowable). 13 | 14 | These outputs are treated ephemerally, in a "fire and forget" manner, creating a data stream. 15 | The intent is for interested entities to subscribe to the data stream and transform, interpret 16 | and/or persist it according to their requirements. 17 | 18 | ## Caveat emptor 19 | 20 | This is still very much an active work-in-progress! However, as it is functional and actively deployed I figured it was worth making public in the event it might help others in their projects. 21 | 22 | Those current deployments consist of my [atmospheric particulate matter sensor](https://www.hackster.io/rpj/atmospheric-particulate-matter-environmental-sensing-fb31a1) and my garden monitoring bots, with the entire system handling (on average) about a half-million units of sensor data per day. 23 | 24 | ## Requirements 25 | 26 | * Hardware: 27 | * a Raspberry Pi running a recent Raspbian build (for sensor nodes) 28 | * any Debian-like system running apt (for managment/non-sensor nodes) 29 | * some [sensors](#sensors), configured appropriately (most easily done with `raspi-config`): 30 | * depending on your chosen sensors: I2C enabled, SPI enabled, 1-wire enabled 31 | * other sensors (LM335, Soil, TEPT5700) require an [external ADC](#devices) which itself will require SPI 32 | 33 | ## Setup 34 | 35 | * Clone the repo 36 | * `cd` into repo dir 37 | * `./setup.sh` (you might need to enter your `sudo` password to install requirements) 38 | * `source env/bin/activate` and go! 39 | 40 | ## Tools 41 | 42 | * [sensors-src](bin/sensors-src): source daemon that manages all specified sensors and publishes their data as configured 43 | * [downsample](bin/downsample): a very flexible data stream downsampler/forwarder/transformer. example uses: 44 | * an at-frequency forwarder (set `-r 1`) 45 | * a loopback downsampler (set `-o` to the same as `-i`) 46 | * a many-to-one reducer (set `-t` to `key`) 47 | * a one-to-many exploder (set `-m` to `flatten:[options]`) 48 | * a bounded ephemeral cache (set `-t` to `list:[options]`, where the `limit=X` option sets the bound) 49 | * ... 50 | * profit! 51 | * [sqlite-sink](bin/sqlite-sink): an [SQLite](https://www.sqlite.org) sink for data streams. examples: 52 | * sink to an SQLite database on a different host a downsampled data stream: 53 | 1. on the source device: 54 | * `downsample -i redis://localhost -o redis://sql-db-host -r ... -p ...` 55 | 2. on the sink host "`sql-db-host`": 56 | * `sqlite-sink path-to-db.sqlite3` 57 | * (lots of "TODOs" here, obviously) 58 | * [oled-display](bin/oled-display): an [OLED display](https://www.adafruit.com/product/661) driver for consuming & display some sensor data, among other things 59 | * [thingspeak](bin/thingspeak): a simple example of a [ThingSpeak](http://thingspeak.com) data forwarder for the SPS30 particulate matter sensor data. [Example resulting data set](https://thingspeak.com/channels/655525). 60 | 61 | ## Library 62 | 63 | ### Sensors 64 | 65 | The following are currently supported (with the required drivers / interfaces setup of course): 66 | 67 | * [SPS30](https://www.sensirion.com/en/environmental-sensors/particulate-matter-sensors-pm25/) I2C particulate matter air quality sensor. [Source.](https://github.com/rpj/rpi/blob/master/lib/rpjios/sensors/SPS30.py) 68 | * [BME680](https://cdn-shop.adafruit.com/product-files/3660/BME680.pdf) temperature / humidity / barometeric pressure / volatile organic compound I2C sensor. My setup uses [AdaFruit's awesome breakout board](https://www.adafruit.com/product/3660) for ease-of-integration. [Source.](https://github.com/rpj/rpi/blob/master/lib/rpjios/sensors/BME680.py) 69 | * [BME280](https://www.bosch-sensortec.com/bst/products/all_products/bme280) temperature / humidity / barometeric pressure I2C sensor. [Source.](https://github.com/rpj/rpi/blob/master/lib/rpjios/sensors/BME280.py) 70 | * [DHTXX](https://www.mouser.com/ds/2/737/dht-932870.pdf)(DHT11/DHT22) temperature / humidity sensors. I use [DFRobot's](https://www.dfrobot.com/product-1102.html) [breakouts](https://www.dfrobot.com/product-174.html) but you can find similar breakout's from many (re)sellers online. [Source.](https://github.com/rpj/rpi/blob/master/lib/rpjios/sensors/DHTXX.py) 71 | * [DS18S20](https://datasheets.maximintegrated.com/en/ds/DS18S20.pdf) high-precision 1-wire temperature sensor. [Source.](https://github.com/rpj/rpi/blob/master/lib/rpjios/sensors/DS18S20.py) 72 | * [LM335](http://www.ti.com/lit/ds/symlink/lm335.pdf) precision analog temperature sensor. [Source.](https://github.com/rpj/rpi/blob/master/lib/rpjios/sensors/LM335.py) 73 | * Capacative soil moisture sensors such as the DFRobot [SEN0114](https://www.dfrobot.com/product-599.html) or any [simple-to-build capactive analog moisture sensor](http://gardenbot.org/howTo/soilMoisture/). [Source.](https://github.com/rpj/rpi/blob/master/lib/rpjios/sensors/Soil.py) 74 | * [TEPT5700](https://www.vishay.com/docs/81321/tept5700.pdf) ambient light sensor. [Source.](https://github.com/rpj/rpi/blob/master/lib/rpjios/sensors/TEPT5700.py) 75 | * "Virtual" sensors such as [SysInfo](https://github.com/rpj/rpi/blob/master/lib/rpjios/sensors/SysInfo.py) and [NetInfo](https://github.com/rpj/rpi/blob/master/lib/rpjios/sensors/NetInfo.py) that do not require any additional hardware. 76 | 77 | Variants of these sensors could likely be made to work with this system via simple modifications if any are required at all. 78 | 79 | ### Devices 80 | 81 | * A simple Python wrapper driver for the SPS30 sensor is included [here](https://github.com/rpj/rpi/blob/master/lib/rpjios/devices/SPS30.py), wrapping the included [embedded-sps](https://github.com/rpj/embedded-sps/tree/1aabaead20059262d66e113d511157c6fda4133a) I2C driver from Sensiron implemented in C (forked to include a shared object build step for Python consumption). 82 | * A simple Python driver for the [74HC595](http://www.ti.com/lit/ds/symlink/sn74hc595.pdf) 8-bit shift register is included [here](https://github.com/rpj/rpi/blob/master/lib/rpjios/devices/74HC595.py). 83 | * The venerable [MCP3008](http://ww1.microchip.com/downloads/en/devicedoc/21295c.pdf) 10-bit 8-channel analog-to-digital converter is directly supported by the [base analog sensor implementation](https://github.com/rpj/rpi/blob/master/lib/rpjios/AnalogBase.py). Other ADCs would be quite simple to adapt (even more so if I implemented a HAL... #TODO). 84 | * channel (zero-indexed) is specified in [`config.json`](https://github.com/rpj/rpi/blob/master/config.json#L62) 85 | 86 | ## Related 87 | 88 | * [The old now-archived repository](https://github.com/rpj/rpi.archive) from whence this all came 89 | * might still contain some useful stuff: D3-based "live" analog data plotting code in `rpjctrl`, and I'm still using `ledCount.py` on some of my units because I'm too lazy to re-write it properly 90 | -------------------------------------------------------------------------------- /bin/downsample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import json 6 | import time 7 | import redis 8 | import Queue 9 | import getopt 10 | import threading 11 | from monotonic import time as mttime 12 | from rpjios.Util import parse_redis_url, hostname 13 | from rpjios.Types import Constants 14 | 15 | def inject_custom_opts(r_opts): 16 | r_opts['socket_connect_timeout'] = 10.0 17 | return r_opts 18 | 19 | def parseOptOpts(optstr): 20 | optName = None 21 | opts = None 22 | if optstr != None: 23 | optSplit = optstr.split(":") 24 | optName = optSplit[0] 25 | 26 | if len(optSplit) == 2: 27 | opts = {k: val for k, val in map(lambda x: x.split('='), optSplit[1].split(','))} 28 | 29 | for k in opts: 30 | if os.path.exists(opts[k]): 31 | opts[k] = json.load(open(opts[k], 'r')) 32 | 33 | return (optName, opts) 34 | 35 | def printOptOpts(opts): 36 | if opts: 37 | print " Options:" 38 | for (k, v) in opts.items(): 39 | print " '{}' = {} ({})".format(k, v, type(v)) 40 | 41 | if __name__ == "__main__": 42 | ops, args = getopt.getopt(sys.argv[1:], 'di:o:m:r:p:t:') 43 | 44 | o_d = {} 45 | for e in ops: 46 | o_d[e[0].replace('-','')] = e[1] 47 | 48 | if not ('i' in o_d and 'o' in o_d and 'p' in o_d): 49 | print "Usage: {} -i input_redis_url -o output_redis_url -p subscription_pattern -m mode -r rate -t passthru_mode\n".format(sys.argv[0]) 50 | sys.exit(0) 51 | 52 | i_urip = parse_redis_url(o_d['i']) 53 | o_urip = parse_redis_url(o_d['o']) 54 | 55 | if 'host' not in i_urip or 'host' not in o_urip: 56 | raise Exception("Bad host spec(s)!") 57 | 58 | i_r = redis.StrictRedis(**inject_custom_opts(i_urip)) 59 | o_r = redis.StrictRedis(**inject_custom_opts(o_urip)) 60 | 61 | s_r = int(o_d['r']) if 'r' in o_d else 10 62 | (mode, modeOpts) = parseOptOpts(o_d['m']) if 'm' in o_d else (None, None) 63 | (ptMode, ptOpts) = parseOptOpts(o_d['t']) if 't' in o_d else (None, None) 64 | 65 | if ptMode == 'list': 66 | if not ptOpts: 67 | ptOpts = {} 68 | ptOpts['suffix'] = ptOpts['suffix'] if 'suffix' in ptOpts else ".list" 69 | ptOpts['limit'] = int(ptOpts['limit']) if 'limit' in ptOpts else 1000 70 | 71 | print "Input: {}".format(o_d['i']) 72 | print "Output: {}".format(o_d['o']) 73 | print "Pattern: {}".format(o_d['p']) 74 | print "Rate: {}".format(s_r) 75 | print "P-thru: {}".format(ptMode) 76 | printOptOpts(ptOpts) 77 | print "Mode: {}".format(mode) 78 | printOptOpts(modeOpts) 79 | 80 | _sep = Constants.NAMESPACE_SEP 81 | 82 | s_d = {} 83 | i_ps = i_r.pubsub() 84 | i_ps.psubscribe(o_d['p']) 85 | 86 | sendQueue = Queue.Queue() 87 | def sendThread(): 88 | while 1: 89 | nextMsg = sendQueue.get() 90 | prevFwd = nextMsg['data']['__ds'] if nextMsg['data'].has_key('__ds') else None 91 | nextMsg['data']['__ds'] = {"ts": mttime.time(), "host": hostname(), "prev": prevFwd, "rate": s_r} 92 | nextMsg['send_func'](nextMsg['channel'], json.dumps(nextMsg['data'])) 93 | 94 | sendThreadInst = threading.Thread(target=sendThread) 95 | sendThreadInst.daemon = True 96 | sendThreadInst.start() 97 | 98 | for m in i_ps.listen(): 99 | try: 100 | if 'channel' in m and 'data' in m: 101 | if 'd' in o_d: 102 | print ">>> MSG: {}".format(m) 103 | 104 | if 'subscribe' in m['type']: 105 | continue 106 | 107 | _d_d = json.loads(m['data']) 108 | 109 | if _d_d['type'] == 'AVAILABLE': 110 | continue 111 | 112 | _c = m['channel'] 113 | chanElements = _c.split(_sep) 114 | sourceName = chanElements[-1] 115 | forwardMsgs = {_c: _d_d} 116 | 117 | if mode == "flatten" and type(_d_d["value"]) is dict: 118 | forwardMsgs = {} 119 | sufMap = modeOpts["mapping"][sourceName] if modeOpts.has_key("mapping") and modeOpts["mapping"].has_key(sourceName) else {} 120 | 121 | for (key, val) in _d_d["value"].iteritems(): 122 | chanSuffix = sufMap[key] if sufMap.has_key(key) else key 123 | 124 | if type(chanSuffix) is bool and chanSuffix == False: 125 | continue 126 | 127 | newChan = _sep.join(list(chanElements) + [chanSuffix]) 128 | newMsg = dict(_d_d) 129 | newMsg["source"] = newChan 130 | newMsg["value"] = val 131 | forwardMsgs[newChan] = newMsg 132 | 133 | if modeOpts.has_key("include-raw"): 134 | newChan = _sep.join(list(chanElements) + [".raw"]) 135 | _d_d["source"] = newChan 136 | forwardMsgs[newChan] = dict(_d_d) 137 | 138 | for (key, val) in forwardMsgs.iteritems(): 139 | if key not in s_d: 140 | s_d[key] = [] 141 | 142 | s_d[key].append(val) 143 | 144 | if len(s_d[key]) == s_r: 145 | sendFunc = o_r.publish 146 | 147 | if ptMode == 'key': 148 | sendFunc = o_r.set 149 | elif ptMode == 'list': 150 | # this sends outside of the sendThread! that's bad! 151 | def listSendFunc(ch, msg): 152 | omsg = json.loads(msg) 153 | lname = _sep.join([ch, ptOpts['suffix']]) 154 | rpipe = o_r.pipeline(True) 155 | if rpipe.lpush(lname, [omsg['ts'], omsg['value']]) > ptOpts['limit']: 156 | rpipe.ltrim(lname, 0, ptOpts['limit'] - 1) 157 | try: 158 | rpipe.execute() 159 | except e: 160 | print "listSendFunc() pipe execution failure: {}".format(e) 161 | sendFunc = listSendFunc 162 | 163 | sendQueue.put({ 164 | 'send_func': sendFunc, 165 | 'channel': key, 166 | 'data': val 167 | }) 168 | 169 | if 'd' in o_d: 170 | print "After 'put', queue has ~{} elements".format(sendQueue.qsize()) 171 | 172 | del s_d[key] 173 | s_d[key] = [] 174 | 175 | except Exception as e: 176 | print "Error: {}".format(e) 177 | -------------------------------------------------------------------------------- /bin/oled-display: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import time 5 | import json 6 | import threading 7 | from Queue import Queue 8 | from collections import deque 9 | from monotonic import time as mt 10 | from datetime import timedelta 11 | 12 | from rpjios import Util 13 | from rpjios.SubscriberBase import PSubscriber 14 | 15 | import Adafruit_GPIO.SPI as SPI 16 | import Adafruit_SSD1306 17 | 18 | from PIL import Image 19 | from PIL import ImageDraw 20 | from PIL import ImageFont 21 | 22 | DEFAULT_GPIO_RESET_PIN = 24 23 | 24 | class OLEDScreen(object): 25 | def __init__(self, reset_pin=DEFAULT_GPIO_RESET_PIN): 26 | self._disp = Adafruit_SSD1306.SSD1306_128_32(rst=reset_pin) 27 | self._disp.begin() 28 | self.clear() 29 | self._img = Image.new('1', (self._disp.width, self._disp.height)) 30 | self._draw = ImageDraw.Draw(self._img) 31 | self._font = ImageFont.load_default() 32 | self.__ic = 0 33 | 34 | @property 35 | def width(self): 36 | return self._disp.width 37 | 38 | @property 39 | def height(self): 40 | return self._disp.height 41 | 42 | @property 43 | def font(self): 44 | return self._font 45 | 46 | def clear(self): 47 | self._disp.clear() 48 | self._disp.display() 49 | 50 | def draw(self): 51 | return self._draw 52 | 53 | def render(self): 54 | self.__ic += 1 55 | if not (self.__ic % 10): 56 | self._img.save("/tmp/oled-display.bmp") 57 | self.__ic = 0 58 | self._disp.image(self._img) 59 | self._disp.display() 60 | 61 | class OLEDSubscriber(object): 62 | def __init__(self): 63 | self._hn = Util.hostname() 64 | self._q = Queue() 65 | self._counts = {'hosts':{},'total':0} 66 | self._stats = {'mps':deque(maxlen=30)} 67 | self._run = True 68 | self._psub = PSubscriber("*").add_listener(lambda x: self._q.put(x)) 69 | 70 | self._statthread = threading.Thread(target=self._statthreadfunc) 71 | self._statthread.daemon = True 72 | self._subthread = threading.Thread(target=self._subthreadfunc) 73 | self._subthread.daemon = True 74 | 75 | self._statthread.start() 76 | self._subthread.start() 77 | 78 | def __del__(self): 79 | self._run = False 80 | self._subthread.join() 81 | self._statthreadfunc.join() 82 | 83 | @property 84 | def statistics(self): 85 | mpsl = list(self._stats['mps']) 86 | return {'per_second': (float(sum(mpsl)) / float(len(mpsl)) if len(mpsl) > 0 else 0), 87 | 'counts': self._counts } 88 | 89 | @property 90 | def local_interest(self): 91 | return {k: 0.0 if not len(v) else (float(sum(v)) / float(len(v))) for (k, v) in self._local_interest.items()} 92 | 93 | @local_interest.setter 94 | def local_interest(self, li_list): 95 | self._local_interest = {k: deque(maxlen=30) for k in li_list} 96 | 97 | def _statthreadfunc(self): 98 | _lc = 0 99 | _lb = 0 100 | while self._run: 101 | _s = mt.time() 102 | _cc = self._counts['total'] 103 | if _cc > 0: 104 | self._stats['mps'].append(_cc - _lc) 105 | _lc = _cc 106 | time.sleep(1.0 - (mt.time() - _s)) 107 | 108 | def _subthreadfunc(self): 109 | def _tvik(tree, key): 110 | chk_f = (lambda l_key, l_tree: reduce(lambda b, kx: b + (kx if kx.startswith(l_key) else ""), l_tree.keys(), "")) 111 | 112 | if type(tree) is dict: 113 | chk_v = chk_f(key, tree) 114 | if not bool(chk_v): 115 | for k in tree.iterkeys(): 116 | tmp = _tvik(tree[k], key) 117 | if tmp is not None: 118 | return tmp 119 | else: 120 | return tree[chk_v] 121 | return None 122 | 123 | while self._run: 124 | msg = self._q.get() 125 | (host, mtype, ename) = msg["channel"].split(':') 126 | 127 | self._counts['hosts'][host] = 0 if host not in self._counts['hosts'] else (self._counts['hosts'][host] + 1) 128 | self._counts['total'] += 1 129 | 130 | if 'data' in msg and host == self._hn: 131 | jd = msg["data"] if type(msg["data"]) == dict else json.loads(msg["data"]) 132 | if "value" in jd: 133 | en = None 134 | jd = jd["value"] 135 | if type(jd) == dict: 136 | for (k, v) in {k: _tvik(jd, k) for k in self._local_interest.keys()}.items(): 137 | if v is not None: 138 | self._local_interest[k].append(v) 139 | else: 140 | if ename in self._local_interest: 141 | self._local_interest[ename].append(jd) 142 | 143 | if __name__ == '__main__': 144 | d = {} 145 | os = OLEDScreen() 146 | osd = os.draw() 147 | 148 | x = 0 149 | top = -2 150 | lastMark = mt.time() 151 | tchar = ':' 152 | 153 | ols = OLEDSubscriber() 154 | ols.local_interest = ["temp", "hum", "pres", "Soil"] 155 | 156 | while 1: 157 | _lst = mt.time() 158 | osd.rectangle((0,0,os.width,os.height), outline=0, fill=0) 159 | 160 | _s = ols.statistics 161 | _stc = float(_s["counts"]["total"]) 162 | _dc = 0 163 | while _stc >= 1000.0: 164 | _stc /= 1000.0 165 | _dc += 1 166 | _tcs_fs = "{: >6.0f}{}" if _dc == 0 else ("{: >4.2f}{}" if int(_stc / 10.0) == 0 else "{: >5.1f}{}") 167 | _tcs = _tcs_fs.format(_stc, {0:"",1:"K",2:"M",3:"B"}[_dc]) 168 | L1 = "[MSGS] {: >6s}, {:0.2f}/s".format(_tcs, _s["per_second"]) 169 | 170 | _li = ols.local_interest 171 | L2 = "{:0.1f}F {:0.1f}% {:0.2f}".format(_li["temp"], _li["hum"], _li["pres"]) 172 | 173 | tm = mt.localtime() 174 | if (_lst - lastMark) > 1.0: 175 | tchar = ' ' if tchar is ':' else ':' 176 | lastMark = _lst 177 | _soil = int(((float(_li["Soil"]) / 200.0) * 1.4286) if bool(_li["Soil"]) else 0) 178 | L3 = "<{:02d}{}{:02d}{}{:02d}> {}".format((tm.tm_hour%12), tchar, tm.tm_min, tchar, tm.tm_sec, 179 | "[S] {}{}".format("." * (5 - _soil), "|" * _soil)) 180 | 181 | osd.text((x, top), str(L1), font=os.font, fill=255) 182 | osd.text((x, top+12), str(L2), font=os.font, fill=255) 183 | osd.text((x, top+23), str(L3), font=os.font, fill=255) 184 | 185 | os.render() 186 | 187 | time.sleep(1.0 - (mt.time() - _lst)) 188 | -------------------------------------------------------------------------------- /bin/sensors-src: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | import signal 4 | import time 5 | import json 6 | from rpjios import Sensors 7 | from rpjios.SubscriberBase import Subscriber 8 | from rpjios.Types import Message 9 | 10 | RUNNING = True 11 | 12 | def sh(s, f): 13 | global RUNNING 14 | RUNNING = False 15 | 16 | if __name__ == '__main__': 17 | cfg = None 18 | cfgFname = sys.argv[1] if len(sys.argv) > 1 else 'rpjios-sensors.config.json' 19 | print "* Loading configuration from {}".format(cfgFname) 20 | with open(cfgFname) as f: 21 | try: 22 | cfg = json.load(f) 23 | except Exception as e: 24 | raise BaseException("Bad configuration file! Details:\n\t{}".format(e)) 25 | 26 | map(lambda s: signal.signal(s, sh), [signal.SIGINT, signal.SIGTERM]) 27 | 28 | if 'sensors' not in cfg: 29 | print "No sensors are configured! Exiting." 30 | sys.exit(0) 31 | 32 | redis_cfg = cfg['redis_config'] if 'redis_config' in cfg else None 33 | print "* Using Redis configuration: {}".format(redis_cfg) 34 | sens = Sensors.Sensors() 35 | sl = {} 36 | 37 | print "* Configured sensors: {}".format(", ".join(map(lambda s: "{}{}".format(s['name'], " (disabled)" if 'disabled' in s and s['disabled'] else ""), cfg['sensors']))) 38 | 39 | for s in cfg['sensors']: 40 | if 'disabled' in s and s['disabled']: 41 | continue 42 | if s['name'] in sens.list(): 43 | ns = sens.create(s['name'], config=s['config'] if 'config' in s else None, redis_cfg=redis_cfg) 44 | ns.start() 45 | sl[ns.id()] = ns 46 | print "* Loaded {}:\n{}".format(ns, ns.metadata()) 47 | else: 48 | raise BaseException("Unknown sensor type '{}' configured!".format(s['name'])) 49 | 50 | if len(sl.keys()) == 0: 51 | print "* No sensors enabled, ending immediately." 52 | sh(None, None) 53 | 54 | while RUNNING: 55 | time.sleep(0.25) 56 | 57 | print "* Shutting down all sensors..." 58 | map(lambda s: s.stop(), sl.values()) 59 | print "* Done." 60 | -------------------------------------------------------------------------------- /bin/sqlite-sink: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import time 6 | import json 7 | import Queue 8 | import sqlite3 9 | from rpjios.SubscriberBase import PSubscriber 10 | from rpjios.Types import Message 11 | 12 | SQLITE_DEFAULT_PATH = '/home/pi/rpjios-sensors.sqlite3' 13 | 14 | if __name__ == '__main__': 15 | sql_path = SQLITE_DEFAULT_PATH if len(sys.argv) < 2 else sys.argv[1] 16 | 17 | if os.path.exists(sql_path): 18 | print "* Removing previous DB" 19 | os.remove(sql_path) 20 | 21 | print "* Connecting to '{}'".format(sql_path) 22 | sqconn = sqlite3.connect(sql_path) 23 | 24 | cur = sqconn.cursor() 25 | 26 | cur.execute("CREATE TABLE data (id integer primary key, source text, time real, value text)") 27 | cur.execute("CREATE TABLE status (id integer primary key, source text, time real, status text)") 28 | sqconn.commit() 29 | 30 | msg_q = Queue.Queue() 31 | def msg_cb(val): 32 | if 'data' in val: 33 | msg_q.put(json.loads(val['data'])) 34 | 35 | ps = PSubscriber("*").add_listener(msg_cb) 36 | 37 | added_rows = 0 38 | failed_add = 0 39 | errors = 0 40 | failed_consec = 0 41 | last_failed = False 42 | while failed_consec < 10: 43 | val = msg_q.get() 44 | retries = 3 45 | committed = False 46 | while retries: 47 | try: 48 | c = sqconn.cursor() 49 | if val['type'] == Message.GENERAL: 50 | arg = (val['source'], val['ts'], json.dumps(val['value'])) 51 | c.execute("INSERT INTO data VALUES(NULL, ?, ?, ?)", arg) 52 | else: 53 | arg = (val['source'], val['ts'], val['type']) 54 | c.execute("INSERT INTO status VALUES(NULL, ?, ?, ?)", arg) 55 | 56 | sqconn.commit() 57 | committed = True 58 | retries = 0 59 | break 60 | except Exception as sqle: 61 | print "Error: {}. Will retry {} times".format(sqle, retries) 62 | retries -= 1 63 | errors += 1 64 | time.sleep(0.25) 65 | if committed: 66 | added_rows += 1 67 | last_failed = False 68 | failed_consec = 0 69 | else: 70 | failed_add += 1 71 | failed_consec += 1 if last_failed else 0 72 | last_failed = True 73 | print "Retries failed! Will loop around again {} times...".format(10 - failed_consec - 1) 74 | sys.stdout.write(" Added rows/Failed adds/Total errors: {}/{}/{}\r".format(added_rows, failed_add, errors)) 75 | 76 | if failed_consec: 77 | print "Massive failure!" 78 | 79 | print "* Done!" 80 | -------------------------------------------------------------------------------- /bin/thingspeak: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # To run: set environment variable REDIS_URL to the input instance, 3 | # and pass the ThingSpeak channel ID and API write key as the CLI arguments: 4 | # $ REDIS_URL="redis://input_host" ./thingspeak TS_CHANNEL_ID TS_CHANNEL_API_WRITE_KEY 5 | # set LOC to a (LAT, LNG) tuple if you want to include location information 6 | 7 | import time 8 | import json 9 | import urllib 10 | import urllib2 11 | import sys 12 | from datetime import datetime 13 | from rpjios.SubscriberBase import PSubscriber 14 | 15 | SEND_EVERY = 20 # samples 16 | FIELDS = ['mc_1p0', 'mc_2p5', 'mc_4p0', 'mc_10p0', 'nc_0p5', 'nc_2p5', 'nc_10p0', 'typical_particle_size'] 17 | CHANNEL_ID = int(sys.argv[1]) 18 | API_WRITE_KEY = sys.argv[2] 19 | LOC = None 20 | WRITE_URL = "https://api.thingspeak.com/update.json?api_key={}".format(API_WRITE_KEY) 21 | 22 | msg_c = 1 23 | def msg_rx(msg): 24 | global msg_c 25 | if not msg_c % SEND_EVERY: 26 | _d = json.loads(msg["data"]) 27 | (ts, val) = (_d["ts"], _d["value"]) 28 | _pl = { 29 | "created_at": datetime.fromtimestamp(ts).isoformat(), 30 | "channel_id": CHANNEL_ID 31 | } 32 | if LOC is not None: 33 | _pl["latitude"] = LOC[0] 34 | _pl["longitude"] = LOC[1] 35 | for fi in range(0, len(FIELDS)): 36 | _pl["field{}".format(fi+1)] = val[FIELDS[fi]] 37 | _params = urllib.urlencode(_pl) 38 | _url = "{}&{}".format(WRITE_URL, _params) 39 | _resp = urllib2.urlopen(_url) 40 | _rread = _resp.read() 41 | _rc = _resp.getcode() 42 | try: 43 | if _rc != 200: 44 | print "Request failure ({})! URL: {}".format(_rc, _url) 45 | elif type(_rread) == str: 46 | print "Success: entry #{} added".format(json.loads(_rread)["entry_id"]) 47 | else: 48 | print "Unknown failure: {} {}".format(_rc, _rread) 49 | except Exception as e: 50 | print "Unknown failure: {} ({})".format(e, _rc) 51 | msg_c = 1 52 | msg_c += 1 53 | 54 | p = PSubscriber("*SPS30*").add_listener(msg_rx) 55 | 56 | while 1: 57 | time.sleep(0.5) 58 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "sensors": [ 3 | { 4 | "name": "BME680", 5 | "disabled": true, 6 | "config": { "frequency" : 0.5 } 7 | }, 8 | { 9 | "name": "DS18S20", 10 | "disabled": true, 11 | "config": { 12 | "devpath": "/sys/bus/w1/devices/10-00080347f4aa/w1_slave", 13 | "frequency": 0.5 14 | } 15 | }, 16 | { 17 | "name": "NetInfo", 18 | "disabled": true, 19 | "config": { "frequency": 0.1 } 20 | }, 21 | { 22 | "name": "SysInfo", 23 | "disabled": true, 24 | "config": { 25 | "frequency": 0.2 26 | } 27 | }, 28 | { 29 | "name": "TEPT5700", 30 | "disabled": true, 31 | "config": { 32 | "adc_input": 1, 33 | "frequency": 1 34 | } 35 | }, 36 | { 37 | "name": "LM335", 38 | "disabled": true, 39 | "config": { 40 | "adc_input": 2, 41 | "frequency": 0.2, 42 | "meta": { 43 | "THIS": "is a free-form data structure you can put anything in", 44 | "IfYouDo": "it will be included in the metadata of the sensor with key 'config_meta'" 45 | } 46 | } 47 | }, 48 | { 49 | "name": "LM335", 50 | "disabled": true, 51 | "config": { 52 | "adc_input": 3, 53 | "frequency": 0.2, 54 | "name_append": "A", 55 | "location": "Rows7-9" 56 | } 57 | }, 58 | { 59 | "name": "Soil", 60 | "disabled": true, 61 | "config": { 62 | "adc_input": 7, 63 | "frequency": 0.2, 64 | "location": "Desk plant" 65 | } 66 | }, 67 | { 68 | "name": "DHTXX", 69 | "disabled": true, 70 | "config": { 71 | "data_pin": 23, 72 | "variant": 11, 73 | "frequency": 0.2 74 | } 75 | }, 76 | { 77 | "name": "SPS30", 78 | "disabled": false, 79 | "config": { 80 | "frequency": 0.5, 81 | "shared_object_path": "/home/pi/rpi/env/lib/python2.7/site-packages/rpjios/devices/libsps30.so" 82 | } 83 | } 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /git-hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo `git describe --tags --abbrev=0 2> /dev/null`-`git log --oneline | wc -l`" ("`git rev-parse --abbrev-ref HEAD`"@"`git log --pretty=format:'%h' -n 1``[[ -z $(git status -s) ]] || echo '+'`")" > version.txt 3 | git add version.txt 4 | -------------------------------------------------------------------------------- /lib/rpjios/AnalogBase.py: -------------------------------------------------------------------------------- 1 | from rpjios.SensorBase import Sensor 2 | import Adafruit_GPIO.SPI as SPI 3 | import Adafruit_MCP3008 as MCP 4 | 5 | class Analog(Sensor): 6 | def __init__(self, port=0, device=0, bitwidth=10, adc_input=None, debounce=3, *args, **kwargs): 7 | if adc_input is None or adc_input < 0 or adc_input > 7: 8 | raise SensorException("Unspecified ADC input number for {}".format(self)) 9 | super(Analog, self).__init__(*args, **kwargs) 10 | self.bitwidth = bitwidth 11 | self._db = debounce 12 | self._meta['adc_cfg'] = {'port':port,'dev':device,'bw':bitwidth,'chan':adc_input,'debounce':self._db} 13 | self._mcp = MCP.MCP3008(spi=SPI.SpiDev(port, device)) 14 | self._read = lambda: self._mcp.read_adc(adc_input) 15 | 16 | @property 17 | def raw_value(self): 18 | if self._db > 1: 19 | db = 0.0 20 | for _ in range(self._db): 21 | db += float(self._read()) 22 | return db / float(self._db) 23 | else: 24 | return float(self._read()) 25 | -------------------------------------------------------------------------------- /lib/rpjios/Command.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import os 3 | import uuid 4 | import json 5 | import redis 6 | import socket 7 | import Util 8 | import Base 9 | 10 | COMMAND_NAMESPACE = "rpjios.cmd" 11 | COMMAND_DEFAULT_TTL_SECS = 60 12 | COMMAND_DEBUG_PRINT_LEVEL = 5 13 | 14 | CMD_REQUEST_METADATA = "REQ_META" 15 | CMD_ECHO = "ECHO" 16 | CMD_DISCOVER_HELLO = "DISC.HELLO" 17 | 18 | _RC_PID = os.getpid() 19 | _RC_HN = socket.gethostname() 20 | 21 | def cmd_debug(*args, **kwargs): 22 | Util.dprint(level=COMMAND_DEBUG_PRINT_LEVEL, *args, **kwargs) 23 | 24 | class Remote(Base.Redis): 25 | def __init__(self, *args, **kwargs): 26 | super(Remote, self).__init__(*args, **kwargs) 27 | self._ttl = COMMAND_DEFAULT_TTL_SECS 28 | 29 | def _genRespChan(self, cmd): 30 | return "{}.CMDRESP:{}:{}:{}:{}".format(COMMAND_NAMESPACE, '_'.join(cmd.split(' ')), 31 | _RC_HN, _RC_PID, uuid.uuid4()) 32 | 33 | def _pubCmdReq(self, cmd, **kwargs): 34 | cmd_debug("_pubCmdReq({}, {})".format(cmd, kwargs)) 35 | # generate a unique response channel name 36 | _grc = self._genRespChan(cmd) 37 | cmd_debug("GRC: {}".format(_grc)) 38 | _cmd = json.dumps({'cmd':cmd, 'resp_chan':_grc}) 39 | _args = json.dumps(kwargs if kwargs else {}) 40 | 41 | _rv = None 42 | 43 | # subscribe to our response channel *before* issuing the command request 44 | self._p.subscribe(_grc) 45 | 46 | # pipeline the request commands for fun and profit 47 | _pipe = self._r.pipeline() 48 | # set a key named the same as our response channel containing our args 49 | _pipe.setex(_grc, self._ttl, _args) 50 | # publish our command request 51 | _pipe.publish(COMMAND_NAMESPACE, _cmd) 52 | if not _pipe.execute()[0]: 53 | raise Exception("EXEC failed for _pubCmdReq({}, **{})".format(cmd, kwargs)) 54 | 55 | for m in self._p.listen(): 56 | if 'subscribe' in m['type']: 57 | continue 58 | try: 59 | _rv = json.loads(m['data']) 60 | # could leave the key there for as long as we want to allow 61 | # other entities to respond to our initial cmd request... 62 | #if not _r.execute_command("DEL", _grc): 63 | # raise "Failed to DEL {}".format(_grc) 64 | except Exception as e: 65 | print("Failed to serialize response: {}".format(e)) 66 | raise e 67 | 68 | break 69 | 70 | self._p.unsubscribe() 71 | return _rv 72 | 73 | def requestMetadata(self, **kwargs): 74 | return self._pubCmdReq(CMD_REQUEST_METADATA, **kwargs) 75 | 76 | def echo(self, **kwargs): 77 | return self._pubCmdReq(CMD_ECHO, **kwargs) 78 | 79 | 80 | class Handler(Base.Redis): 81 | def __init__(self, *args, **kwargs): 82 | super(Handler, self).__init__(*args, **kwargs) 83 | self._handlers = {} 84 | self._thread = threading.Thread(target=self._hthread) 85 | self._thread.daemon = True 86 | self._thread.start() 87 | 88 | def _hthread(self): 89 | # subscribe to the global command namespace to listen for requests 90 | self._p.subscribe(COMMAND_NAMESPACE) 91 | 92 | for m in self._p.listen(): 93 | if 'subscribe' in m['type']: 94 | continue 95 | try: 96 | _d = json.loads(m['data']) 97 | _rs = _d['resp_chan'] 98 | try: 99 | # try to get the arguments at the response channel key 100 | _kv = json.loads(self._r.get(_rs)) 101 | 102 | # run the registered command handler, passing in arguments 103 | # gathered from the above GET 104 | _exrv = self._handlers[_d['cmd']](**_kv) 105 | 106 | # append some metadata to the response 107 | _exrv['hostname'] = _RC_HN 108 | _exrv['pid'] = _RC_PID 109 | _exrv['uuid'] = str(self._uuid) 110 | 111 | # publish the response to the specified response channel 112 | self._r.publish(_rs, json.dumps(_exrv)) 113 | except KeyError: 114 | raise Exception("No command handler registered for '{}'!".format(_d['cmd'])) 115 | except Exception as e: 116 | print("!!!!!\n\tFailure loading data!\n\tERROR: {} {}\n\tDATA:\n\t{}".format(e.__class__.__name__, e, m['data'])) 117 | continue 118 | 119 | self._p.unsubscribe() 120 | 121 | def set_handler(self, cmd, handler_func): 122 | self._handlers[cmd] = handler_func 123 | 124 | if __name__ == "__main__": 125 | import sys 126 | import time 127 | import threading 128 | import collections 129 | 130 | _args = {'host':'localhost', 'password':None} 131 | if len(sys.argv) < 2: 132 | print("Usage: {} [server|client] (hostname='{}') (password='{}')".format(sys.argv[0], _args['host'], _args['password'])) 133 | sys.exit(0) 134 | 135 | _keys = ['mode', 'host', 'password'] 136 | _argvc = collections.deque(sys.argv[1:]) 137 | for i in range(len(_argvc)): 138 | _args[_keys[i]] = _argvc.popleft() 139 | 140 | _mode = _args['mode'] 141 | del _args['mode'] 142 | 143 | print("Starting as a {}, using the following connection parameters:\n\t{}".format(_mode, _args)) 144 | 145 | if _mode == 'server': 146 | def my_REQ_META_handler(*args, **kwargs): 147 | return {"resp": {"msg": "yo whats up homie. here's my metadata", "meta": None}, 148 | "from": "my_REQ_META_handler"} 149 | 150 | def my_ECHO_handler(*args, **kwargs): 151 | return {"resp": kwargs, "from": "my_ECHO_handler"} 152 | 153 | ch = Handler(**_args) 154 | ch.set_handler(CMD_REQUEST_METADATA, my_REQ_META_handler) 155 | ch.set_handler(CMD_ECHO, my_ECHO_handler) 156 | ch._thread.join() 157 | elif _mode == 'client': 158 | rc = Remote(**_args) 159 | arg = {"an_arg":"an_arg_val"}; 160 | print("requestMetadata({}):".format(arg)) 161 | print("RETURN: {}".format(rc.requestMetadata(**arg))) 162 | print("echo({}):".format(arg)) 163 | print("RETURN: {}".format(rc.echo(**arg))) 164 | 165 | for i in range(10): 166 | print("echo({}): {}".format(i, rc.echo(i=i))) 167 | time.sleep(0.1) 168 | else: 169 | raise Exception("Bad mode '{}'!".format(_mode)) 170 | -------------------------------------------------------------------------------- /lib/rpjios/Discovery.py: -------------------------------------------------------------------------------- 1 | import RedisBase 2 | import Util 3 | import time 4 | import json 5 | import urllib2 6 | import socket 7 | from datetime import datetime 8 | 9 | # TODO: define this shit once and for all somewhere... 10 | NAMESPACE = 'rpjios' 11 | 12 | DEFAULT_TTL = 5 # minutes 13 | 14 | class Discovery(RedisBase.Redis): 15 | def __init__(self, *args, **kwargs): 16 | super(Discovery, self).__init__(*args, **kwargs) 17 | 18 | def _checkin_set_host(self, cd): 19 | hname = "{}.checkin.{}".format(NAMESPACE, cd["host"]) 20 | rpipe = self._r.pipeline(True) 21 | map(lambda k: rpipe.hset(hname, k, json.dumps(cd[k])), cd) 22 | rpipe.expire(hname, DEFAULT_TTL * 60) 23 | if "lt" in cd: 24 | rpipe.hset("{}.__meta.checkin".format(NAMESPACE), hname, json.dumps(cd)) 25 | return rpipe.execute() 26 | 27 | def checkin(self): 28 | cd = {'ext_ip':None, 'ver':Util.version(),'up':Util.uptime(),'lt':str(datetime.now()),'host':Util.hostname(),'ifaces':Util.network_ifaces()} 29 | 30 | try: 31 | cd['ext_ip'] = json.loads(urllib2.urlopen("https://api.ipify.org?format=json").read())['ip'] 32 | except: 33 | pass 34 | 35 | return self._checkin_set_host(cd) 36 | 37 | if __name__ == "__main__": 38 | print "{}".format(Discovery().checkin()) 39 | -------------------------------------------------------------------------------- /lib/rpjios/RedisBase.py: -------------------------------------------------------------------------------- 1 | import redis 2 | import uuid 3 | import Util 4 | 5 | #TODO: move sensor base stuff here!! (and have it use Redis base!) 6 | #also TODO: sensor classes should be strictly restricted to only emitting to a local redis (maybe?) 7 | 8 | class Redis(object): 9 | def __init__(self, *args, **kwargs): 10 | if 'host' not in kwargs: 11 | kwargs = Util.cur_redis_dict() 12 | self._r = redis.StrictRedis(**kwargs) 13 | self._p = self._r.pubsub() 14 | self._uuid = uuid.uuid4() 15 | 16 | def __str__(self): 17 | return "".format(self._uuid, self._r) 18 | -------------------------------------------------------------------------------- /lib/rpjios/SensorBase.py: -------------------------------------------------------------------------------- 1 | import redis 2 | import json 3 | import time 4 | import threading 5 | import uuid 6 | import functools 7 | from monotonic import time as mt 8 | from rpjios import Util 9 | from rpjios.Types import Message, Constants 10 | from rpjios.SubscriberBase import Subscriber, PSubscriber 11 | 12 | def SensorName(sname): 13 | def SensorName_decorator(func): 14 | @functools.wraps(func) 15 | def wrapper(*args, **kwargs): 16 | kwargs['name'] = sname 17 | func(*args, **kwargs) 18 | return wrapper 19 | return SensorName_decorator 20 | 21 | def SensorDesc(sdesc): 22 | def SensorDesc_decorator(func): 23 | @functools.wraps(func) 24 | def wrapper(*args, **kwargs): 25 | kwargs['desc'] = sdesc 26 | func(*args, **kwargs) 27 | return wrapper 28 | return SensorDesc_decorator 29 | 30 | def SensorCategory(sCategory): 31 | def SensorCategory_decorator(func): 32 | @functools.wraps(func) 33 | def wrapper(*args, **kwargs): 34 | kwargs['category'] = sCategory 35 | func(*args, **kwargs) 36 | return wrapper 37 | return SensorCategory_decorator 38 | 39 | 40 | class SensorException(BaseException): 41 | pass 42 | 43 | class Sensor(object): 44 | def __init__(self, name=None, frequency=None, desc=None, category='sensor', redis_cfg={}, *args, **kwargs): 45 | self._meta = {} 46 | self._name = name 47 | self._name_add = None if 'name_append' not in kwargs else kwargs['name_append'] 48 | self._meta['location'] = self._location = None if 'location' not in kwargs else kwargs['location'] 49 | self._meta['hostname'] = Util.hostname() 50 | self.channel = Constants.NAMESPACE_SEP.join([self._meta['hostname'], category, self.name]) 51 | self._meta['description'] = self.desc = desc 52 | self.freq = frequency 53 | if self.freq is not None: 54 | self._lasttick = 0 55 | self._sleeptime = 1.0 / float(self.freq) 56 | self._run = True 57 | self._redis = redis.StrictRedis(**redis_cfg) 58 | self._sub = Subscriber(channel=self.channel) 59 | if 'meta' in kwargs: 60 | self._meta['config_meta'] = kwargs['meta'] 61 | 62 | @property 63 | def name(self): 64 | return "{}{}".format(self._name, self._name_add if self._name_add else "") 65 | 66 | @property 67 | def type(self): 68 | return self._name 69 | 70 | def __str__(self): 71 | return "<{} '{}{}' type={} channel={}>".format(__name__, self.name, 72 | " at {}".format(self._location) if self._location else "", 73 | self.type, self.channel) 74 | 75 | def _attrs_to_dict(self, objfrom): 76 | ret = {} 77 | for n in dir(objfrom): 78 | if not n.startswith('_'): 79 | ga = getattr(objfrom, n) 80 | if not callable(ga): 81 | ret[n] = ga 82 | return ret 83 | 84 | def _tickloop(self): 85 | while (self._run): 86 | n = mt.time() 87 | if n - self._lasttick > self._sleeptime: 88 | self._lasttick = n 89 | self._runloop() 90 | time.sleep(0.01) 91 | 92 | def _setup(self): 93 | pass 94 | 95 | def _cleanup(self): 96 | pass 97 | 98 | def start(self): 99 | try: 100 | self._setup() 101 | msg = {'description': self.desc, 'meta': self._meta} 102 | self.publish(msg, Message.SETUP_DONE) 103 | t = self._runloop if self.freq is None else self._tickloop 104 | self._thread = threading.Thread(target=t) 105 | self._thread.daemon = True 106 | self._thread.start() 107 | except SensorException as se: 108 | print "Failed setup: {}".format(se) 109 | 110 | def stop(self): 111 | self._run = False 112 | del self._sub 113 | self._cleanup() 114 | self._thread.join() 115 | self.publish(mtype=Message.SENSOR_GONE) 116 | print "{} stopped".format(self) 117 | 118 | def subscribe_to(self, subfunc): 119 | self._sub.add_listener(subfunc) 120 | 121 | def publish(self, value=None, mtype=Message.GENERAL): 122 | m = { 123 | 'type': mtype, 124 | 'source': self.channel, 125 | 'ts': mt.time() 126 | } 127 | 128 | if self._location: 129 | m['location'] = self._location 130 | if value is not None: 131 | try: 132 | m['value'] = json.loads(value) 133 | except: 134 | m['value'] = value 135 | 136 | self._redis.publish(self.channel, json.dumps(m)) 137 | 138 | def metadata(self): 139 | m = { 140 | 'name': self.name, 141 | 'channel': self.channel, 142 | 'description': self.desc, 143 | } 144 | 145 | if self.freq is not None: 146 | m['frequency'] = "{}Hz".format(self.freq) 147 | 148 | for k in self._meta: 149 | m[k] = self._meta[k] 150 | 151 | return m 152 | 153 | def id(self): 154 | return self.channel 155 | -------------------------------------------------------------------------------- /lib/rpjios/Sensors.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import glob 4 | import sensors 5 | from importlib import import_module 6 | 7 | # TODO: could this all be a static class? 8 | class Sensors(object): 9 | def __init__(self): 10 | self._slist = {} 11 | self._gdir = map(lambda x: re.search('.*\/(rpjios\/.*)', x).group(1), glob.glob(os.path.split(__file__)[0] + '/sensors/*.py')) 12 | for mn in map(lambda x: x.replace('/', '.').replace('.py', ''), self._gdir): 13 | if not ('init' in mn or 'Base' in mn): 14 | self._slist[mn.replace('rpjios.sensors.','')] = import_module(mn) 15 | 16 | def list(self): 17 | return self._slist.keys() 18 | 19 | def create(self, name, config=None, redis_cfg=None): 20 | cf = self._slist[name].Factory 21 | if redis_cfg: 22 | if not config: 23 | config = {} 24 | config['redis_cfg'] = redis_cfg 25 | return cf(**config) if config else cf() 26 | -------------------------------------------------------------------------------- /lib/rpjios/SubscriberBase.py: -------------------------------------------------------------------------------- 1 | import Queue 2 | import redis 3 | import json 4 | import threading 5 | import Types 6 | import RedisBase 7 | 8 | class Subscriber(RedisBase.Redis): 9 | @staticmethod 10 | def channels(): 11 | return redis.StrictRedis().execute_command("PUBSUB", "CHANNELS", "{}*".format(Sensor.CHANNEL)) 12 | 13 | def __init__(self, channel, mtype_filter=None, *args, **kwargs): 14 | if channel is None: 15 | raise BaseException("No channel name given!") 16 | 17 | super(Subscriber, self).__init__(*args, **kwargs) 18 | self._subs = [] 19 | self._q = Queue.Queue() 20 | self._pubsub = self._r.pubsub() 21 | self._pssubfunc = self._pubsub.subscribe 22 | self._psunsubfunc = self._pubsub.unsubscribe 23 | self._chan = channel 24 | self._filter = ['psubscribe', 'subscribe'] 25 | if isinstance(mtype_filter, list): 26 | self._filter.extend(mtype_filter) 27 | self._listen = None 28 | self._run = False 29 | self._issetup = False 30 | 31 | def __str__(self): 32 | return "<{} channel='{}'>".format(self.__class__.__name__, self._chan) 33 | 34 | def __del__(self): 35 | self._run = False 36 | self._psunsubfunc() 37 | if self._listen: 38 | self._listen.join() 39 | 40 | def _setup(self): 41 | self._listen = threading.Thread(target=self._sub_thread) 42 | self._listen.daemon = True 43 | self._pssubfunc(self._chan) 44 | self._run = True 45 | self._listen.start() 46 | 47 | def _sub_thread(self): 48 | while self._run: 49 | for m in self._pubsub.listen(): 50 | if self._filter and m['type'] in self._filter: 51 | continue 52 | 53 | if len(self._subs): 54 | map(lambda sf: sf(m), self._subs) 55 | else: 56 | self._q.put(m) 57 | 58 | # if no listeners have been added, blocks until a message is available and returns it 59 | # otherwise, returns None without blocking 60 | def get_message(self): 61 | if not self._issetup: 62 | self._setup() 63 | return None if len(self._subs) else self._q.get() 64 | 65 | # once a listener has been added, any messages left in the queue will be drained 66 | # to the registered listener and thereafter .get_message() will always return None 67 | def add_listener(self, listener): 68 | if not self._issetup: 69 | self._setup() 70 | 71 | if not self._q.empty(): 72 | try: 73 | for m in self._q.get_nowait(): 74 | listener(m) 75 | except Empty: 76 | pass 77 | 78 | self._subs.append(listener) 79 | return self 80 | 81 | def add_subscriber(self, l): 82 | return self.add_listener(l) 83 | 84 | class PSubscriber(Subscriber): 85 | def __init__(self, *args, **kwargs): 86 | super(PSubscriber, self).__init__(*args, **kwargs) 87 | self._pssubfunc = self._pubsub.psubscribe 88 | self._psunsubfunc = self._pubsub.punsubscribe 89 | -------------------------------------------------------------------------------- /lib/rpjios/Types.py: -------------------------------------------------------------------------------- 1 | class Message(object): 2 | SETUP_DONE = "AVAILABLE" 3 | GENERAL = "VALUE" 4 | SENSOR_GONE = "GONE" 5 | 6 | class Constants(object): 7 | NAMESPACE_SEP = ":" 8 | -------------------------------------------------------------------------------- /lib/rpjios/Util.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import re 3 | import os 4 | import sys 5 | import psutil 6 | import socket 7 | from datetime import timedelta 8 | from subprocess import Popen, PIPE 9 | 10 | DEBUG_LEVEL_LOWEST = 0 11 | DEFAULT_DEBUG_LEVEL = 1 12 | 13 | def eprint(*args, **kwargs): 14 | print(*args, file=sys.stderr, **kwargs) 15 | 16 | def dprint(*args, **kwargs): 17 | _dlvl = os.environ['DEBUG'] if 'DEBUG' in os.environ else 0 18 | _clvl = DEFAULT_DEBUG_LEVEL 19 | 20 | if 'level' in kwargs: 21 | _clvl = kwargs['level'] 22 | del kwargs['level'] 23 | 24 | if int(_dlvl) >= int(_clvl): 25 | eprint(*args, **kwargs) 26 | 27 | def parse_redis_url(url_str): 28 | url_re = re.compile(r"redis\:\/\/(?:(?:(.*?)\:)?(.*?)\@)?([a-zA-Z\d\-_\.]+)(?:\:(\d+))?") 29 | m = url_re.match(url_str) 30 | if m and len(m.groups()) == 4: 31 | g = m.groups() 32 | rvt = { 'password': g[1] if g[0] else None, 'host': g[2], 'port': g[3] } 33 | rv = {} 34 | for k in rvt: 35 | if rvt[k]: 36 | rv[k] = rvt[k] 37 | return rv 38 | 39 | def cur_redis_dict(): 40 | return parse_redis_url(os.environ['REDIS_URL']) 41 | 42 | def uptime(): 43 | with open('/proc/uptime', 'r') as f: 44 | uptime_seconds = int(float(f.readline().split()[0])) 45 | uptime_string = str(timedelta(seconds = uptime_seconds)) 46 | if (uptime_seconds < 10*60*60): 47 | uptime_string = '0'+uptime_string 48 | return uptime_string 49 | 50 | def _gather_wifi(iface): 51 | c = ['/sbin/iwgetid', iface, '--raw'] 52 | g = { 'ssid': None, 'mac': '--ap', 'freq': '--freq', 'chan': '--channel' } 53 | for gk in g: 54 | ta = list(c) 55 | if g[gk] is not None: 56 | ta.append(g[gk]) 57 | g[gk] = Popen(ta, stdout=PIPE).communicate()[0].strip() 58 | if gk == 'freq': 59 | fmtFreq = '??' 60 | try: 61 | fmtFreq = "{0:.2f}".format(float(g[gk]) / 1e9) 62 | except: 63 | pass 64 | g[gk] = "{} GHz".format(fmtFreq) 65 | c2 = ["Bit Rate", "Tx-Power", "Link Quality", "Signal level"] 66 | c2d = {} 67 | for cl in Popen(['/sbin/iwconfig', iface], stdout=PIPE).communicate()[0].split('\n'): 68 | for c2i in c2: 69 | m = re.match(".*{}\=(.*?)(?:\s\s)+".format(c2i), cl) 70 | if m and len(m.groups()): 71 | c2d[c2i] = m.groups()[0] 72 | if len(c2d): 73 | g['extended_info'] = c2d 74 | return g 75 | 76 | def network_ifaces(): 77 | rv = {} 78 | ni = psutil.net_if_addrs() 79 | for n in ni: 80 | if n != 'lo' and len(ni[n]): 81 | rv[n] = {'addr': ni[n][0].address} 82 | if 'wlan' in n: 83 | wf = _gather_wifi(n) 84 | for k in wf: 85 | rv[n][k] = wf[k] 86 | return rv 87 | 88 | HOSTNAME = None 89 | _HOSTNAME_FILE = '/etc/hostname' 90 | def hostname(): 91 | global HOSTNAME 92 | if HOSTNAME == None: 93 | if os.path.exists(_HOSTNAME_FILE): 94 | with open(_HOSTNAME_FILE, 'r') as hnf: 95 | HOSTNAME = hnf.read().strip() 96 | else: 97 | HOSTNAME = socket.gethostname() 98 | return HOSTNAME 99 | 100 | def version(): 101 | epath = os.path.abspath("{}/../../version.txt".format(os.path.split(__file__)[0])) 102 | if os.path.exists(epath): 103 | with open(epath, 'r') as vt: 104 | return vt.read().strip() 105 | return "(no-version)" 106 | -------------------------------------------------------------------------------- /lib/rpjios/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rpj/rpi/bab2f907312805295ce3e16b19ce8338fa9f6a8c/lib/rpjios/__init__.py -------------------------------------------------------------------------------- /lib/rpjios/__main__.py: -------------------------------------------------------------------------------- 1 | import Sensors 2 | import time 3 | 4 | if __name__ == '__main__': 5 | print "RPIOS.Sensors unit test" 6 | s = Sensors.Sensors() 7 | print "LIST: {}".format(s.list()) 8 | b = s.create(s.list()[0]) 9 | print "Starting {}".format(b) 10 | def listen_func(m): 11 | print "listen got message '{}'".format(m) 12 | 13 | b.subscribe_to(listen_func) 14 | b.start() 15 | print "{} metadata:\n{}\n".format(b, b.metadata()) 16 | 17 | time.sleep(10) 18 | print "Done" 19 | b.stop() 20 | print "Really done" 21 | -------------------------------------------------------------------------------- /lib/rpjios/devices/.gitignore: -------------------------------------------------------------------------------- 1 | *.so 2 | -------------------------------------------------------------------------------- /lib/rpjios/devices/74HC595.py: -------------------------------------------------------------------------------- 1 | import RPi.GPIO as gpio 2 | import Queue 3 | import threading 4 | from time import sleep 5 | 6 | class ShiftReg595(object): 7 | DEFAULT_PULSE_DURATION = 0.00001 8 | DEFAULT_MSB_FIRST = False 9 | DEFAULT_PIN_NUMBERING = gpio.BCM 10 | 11 | def __init__(self, SER=None, RCLK=None, SRCLK=None, 12 | msb_first=DEFAULT_MSB_FIRST, pulse_dir=DEFAULT_PULSE_DURATION, 13 | pin_numbering=DEFAULT_PIN_NUMBERING): 14 | if not (SER and RCLK and SRCLK): 15 | raise BaseException("Bad arguments (SER={} RCLK={} SRCLK={}" 16 | .format(SER, RCLK, SRCLK)) 17 | self._msbf = msb_first 18 | self._pd = pulse_dir 19 | self._pinno = pin_numbering 20 | self._pins = [ 21 | [SER, gpio.OUT, gpio.LOW], 22 | [SRCLK, gpio.OUT, gpio.LOW], 23 | [RCLK, gpio.OUT, gpio.LOW] 24 | ] 25 | self._b = 0 26 | self._setup() 27 | 28 | def __del__(self): 29 | self._cleanup() 30 | 31 | def _setup(self): 32 | gpio.setmode(self._pinno) 33 | gpio.setwarnings(True) 34 | for pin in self._pins: 35 | gpio.setup(pin[0], pin[1], initial=pin[2]) 36 | 37 | def _cleanup(self): 38 | gpio.cleanup() 39 | 40 | def _pulse(self, pin): 41 | gpio.output(pin, 1) 42 | sleep(self._pd) 43 | gpio.output(pin, 0) 44 | 45 | def _send(self): 46 | for i in range(8): 47 | val = (self._b >> (i if self._msbf else (7-i))) & 0x1 48 | gpio.output(self._pins[0][0], val) 49 | self._pulse(self._pins[1][0]) 50 | self._pulse(self._pins[2][0]) 51 | 52 | @property 53 | def value(self): 54 | return self._b 55 | 56 | @value.setter 57 | def value(self, b): 58 | self._b = b 59 | self._send() 60 | 61 | if __name__ == "__main__": 62 | sr = ShiftReg595(SER=5, RCLK=6, SRCLK=13) 63 | for i in range(256): 64 | sr.value = i 65 | sleep(abs(0.04-(0.0001*i))) 66 | sr.value = 0 67 | -------------------------------------------------------------------------------- /lib/rpjios/devices/SPS30.py: -------------------------------------------------------------------------------- 1 | import ctypes as ct 2 | 3 | class SPS30(object): 4 | class Measurement(ct.Structure): 5 | _fields_ = [("mc_1p0", ct.c_float), 6 | ("mc_2p5", ct.c_float), 7 | ("mc_4p0", ct.c_float), 8 | ("mc_10p0", ct.c_float), 9 | ("nc_0p5", ct.c_float), 10 | ("nc_1p0", ct.c_float), 11 | ("nc_2p5", ct.c_float), 12 | ("nc_4p0", ct.c_float), 13 | ("nc_10p0", ct.c_float), 14 | ("typical_particle_size", ct.c_float)] 15 | 16 | @property 17 | def driver_version(self): 18 | return self._dver 19 | 20 | @property 21 | def serial_number(self): 22 | return repr(self._devsn.value) 23 | 24 | # an SPS30.Measurement instance 25 | @property 26 | def measurement(self): 27 | return self._get_measurement() 28 | 29 | def __init__(self, shared_object_path="libsps30.so", *args, **kwargs): 30 | self._lib = ct.cdll.LoadLibrary(shared_object_path) 31 | if not self._lib: 32 | raise BaseException("Unable to load shared SPS30 library from '{}'".format(shared_object_path)) 33 | 34 | self._lib.sps_get_driver_version.restype = ct.c_char_p 35 | self._dver = self._lib.sps_get_driver_version() 36 | 37 | if self._lib.sps30_probe() != 0: 38 | raise BaseException("Probing SPS30 failed") 39 | 40 | self._devsn = ct.create_string_buffer('\000' * 32) 41 | if self._lib.sps30_get_serial(self._devsn) != 0: 42 | raise BaseException("Unable to get SPS30 serial number") 43 | 44 | self._measurement_running = False 45 | self._latest_measurement = None 46 | 47 | def __del__(self): 48 | self._stop() 49 | 50 | def _stop(self): 51 | if self._measurement_running: 52 | self._measurement_running = bool(self._lib.sps30_stop_measurement()) 53 | 54 | def _get_measurement(self): 55 | if not self._measurement_running: 56 | if self._lib.sps30_start_measurement() != 0: 57 | raise BaseException("Failed to put SPS30 into measurement mode!") 58 | self._measurement_running = True 59 | data_ready = ct.c_byte() 60 | if self._lib.sps30_read_data_ready(ct.byref(data_ready)) == 0: 61 | if bool(data_ready.value): 62 | self._latest_measurement = SPS30.Measurement() 63 | if self._lib.sps30_read_measurement(ct.byref(self._latest_measurement)) != 0: 64 | raise BaseException("read_measurement") 65 | return self._latest_measurement 66 | 67 | if __name__ == "__main__": 68 | import time 69 | sps30 = SPS30() 70 | print "SPS30 S/N {}, driver '{}'".format(sps30.serial_number, sps30.driver_version) 71 | while 1: 72 | meas = sps30.measurement 73 | if meas is not None: 74 | print "" 75 | print "PM1.0:\t{}".format(meas.mc_1p0) 76 | print "PM2.5:\t{}".format(meas.mc_2p5) 77 | print "PM4.0:\t{}".format(meas.mc_4p0) 78 | print "PM10.0:\t{}".format(meas.mc_10p0) 79 | print "NC0.5:\t{}".format(meas.nc_0p5) 80 | print "NC1.0:\t{}".format(meas.nc_1p0) 81 | print "NC2.5:\t{}".format(meas.nc_2p5) 82 | print "NC4.0:\t{}".format(meas.nc_4p0) 83 | print "NC10.0:\t{}".format(meas.nc_10p0) 84 | print "TypSz:\t{}".format(meas.typical_particle_size) 85 | time.sleep(2) 86 | 87 | -------------------------------------------------------------------------------- /lib/rpjios/devices/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rpj/rpi/bab2f907312805295ce3e16b19ce8338fa9f6a8c/lib/rpjios/devices/__init__.py -------------------------------------------------------------------------------- /lib/rpjios/sensors/BME280.py: -------------------------------------------------------------------------------- 1 | import smbus2 2 | import bme280 3 | import time 4 | from datetime import datetime 5 | from rpjios.SensorBase import Sensor, SensorName, SensorDesc 6 | 7 | I2C_PORT_DEFAULT = 1 8 | I2C_ADDRESS_DEFAULT = 0x76 9 | 10 | class Factory(Sensor): 11 | @SensorName("BME280") 12 | @SensorDesc("Bosch I2C low power pressure, temperature & humidity sensor") 13 | def __init__(self, i2c_port=I2C_PORT_DEFAULT, i2c_address=I2C_ADDRESS_DEFAULT, *args, **kwargs): 14 | if i2c_port is None or i2c_port < 0 or i2c_address is None or i2c_address < 0: 15 | raise BaseException("Bad I2C port or address for BME280 specification") 16 | super(Factory, self).__init__(*args, **kwargs) 17 | self._port = i2c_port 18 | self._addr = i2c_address 19 | 20 | def _setup(self): 21 | self._bus = smbus2.SMBus(self._port) 22 | self._meta['calibration_data'] = self._cbdata = bme280.load_calibration_params(self._bus, self._addr) 23 | 24 | def _runloop(self): 25 | data = self._attrs_to_dict(bme280.sample(self._bus, self._addr, self._cbdata)) 26 | if 'temperature' in data: 27 | data['temperature'] = (data['temperature'] * 1.8) + 32.0 28 | if 'timestamp' in data and type(data['timestamp']) == datetime: 29 | data['timestamp'] = time.mktime(data['timestamp'].timetuple()) 30 | if 'id' in data: 31 | del data['id'] 32 | if 'uncompensated' in data: 33 | del data['uncompensated'] 34 | self.publish(data) 35 | 36 | -------------------------------------------------------------------------------- /lib/rpjios/sensors/BME680.py: -------------------------------------------------------------------------------- 1 | import bme680 2 | import sys 3 | from time import sleep 4 | from rpjios.SensorBase import Sensor, SensorName, SensorDesc 5 | 6 | class Factory(Sensor): 7 | @SensorName("BME680") 8 | @SensorDesc("Bosch I2C low power gas, pressure, temperature & humidity sensor") 9 | def __init__(self, *args, **kwargs): 10 | super(Factory, self).__init__(*args, **kwargs) 11 | 12 | def _setup(self): 13 | try: 14 | self._sensor = bme680.BME680(bme680.I2C_ADDR_PRIMARY) 15 | except IOError: 16 | self._sensor = bme680.BME680(bme680.I2C_ADDR_SECONDARY) 17 | 18 | if self._sensor is None: 19 | raise Base.SensorException("No BME680 I2C sensor found") 20 | 21 | self._meta['calibration_data'] = {} 22 | cd = self._attrs_to_dict(self._sensor.calibration_data) 23 | for k in cd: 24 | if not callable(cd[k]): 25 | self._meta['calibration_data'][k] = cd[k] 26 | 27 | # TODO: MAKE CONFIG'ABLE 28 | self._sensor.set_humidity_oversample(bme680.OS_2X) 29 | self._sensor.set_pressure_oversample(bme680.OS_4X) 30 | self._sensor.set_temperature_oversample(bme680.OS_8X) 31 | self._sensor.set_filter(bme680.FILTER_SIZE_3) 32 | self._sensor.set_gas_status(bme680.ENABLE_GAS_MEAS) 33 | 34 | # TODO: SAME. ALSO MAKE PROFILES SELECTABLE AND WHAT NOT 35 | self._sensor.set_gas_heater_temperature(320) 36 | self._sensor.set_gas_heater_duration(150) 37 | self._sensor.select_gas_heater_profile(0) 38 | 39 | self._meta['initial_reading'] = self._attrs_to_dict(self._sensor.data) 40 | 41 | def _runloop(self): 42 | while (self._run): 43 | if self._sensor.get_sensor_data(): 44 | data = self._attrs_to_dict(self._sensor.data) 45 | if 'temperature' in data: 46 | data['temperature'] = data['temperature'] * 1.8 + 32 47 | self.publish(data) 48 | sleep(1.0) 49 | -------------------------------------------------------------------------------- /lib/rpjios/sensors/DHTXX.py: -------------------------------------------------------------------------------- 1 | from rpjios.SensorBase import Sensor, SensorName, SensorDesc 2 | import Adafruit_DHT as dht 3 | 4 | class Factory(Sensor): 5 | @SensorName("DHTXX") 6 | @SensorDesc("DHTXX (11/22) ambient temperature and humidity 1-wire sensor") 7 | def __init__(self, data_pin=None, variant=None, *args, **kwargs): 8 | if not data_pin: 9 | raise BaseException("Must define a data pin for DHTXX sensor!") 10 | if not variant: 11 | raise BaseException("Must define a variant of DHTXX sensor!") 12 | if not (variant == 11 or variant == 22): 13 | raise BaseException("Unrecognized DHTXX variant '{}'!".format(variant)) 14 | super(Factory, self).__init__(*args, **kwargs) 15 | self._meta['dht_config'] = {'pin': data_pin, 'variant': variant} 16 | self._pin = data_pin 17 | self._var = dht.DHT11 if variant == 11 else dht.DHT22 18 | 19 | def _runloop(self): 20 | h, t = dht.read_retry(self._var, self._pin) 21 | # DHT11 can only detect humidity between 20-90%, so scale appropriately 22 | scl_hum = lambda x: x if self._var != dht.DHT11 else (0.70 * (x - 100.0) + 90.0) 23 | if h and t: 24 | pv = {'tempF': (t*9/5.0+32), 'humidity%': scl_hum(h), 'variant': 'DHT11' if self._var == dht.DHT11 else 'DHT22'} 25 | if self._var == dht.DHT11: 26 | pv['raw_humidity'] = h 27 | self.publish(pv) 28 | -------------------------------------------------------------------------------- /lib/rpjios/sensors/DS18S20.py: -------------------------------------------------------------------------------- 1 | from rpjios.SensorBase import Sensor, SensorName, SensorDesc 2 | 3 | class Factory(Sensor): 4 | @SensorName("DS18S20") 5 | @SensorDesc("Digital 1-wire ambient temperature sensor") 6 | def __init__(self, devpath=None, *args, **kwargs): 7 | if not devpath: 8 | raise BaseException("Bad args for {}".format(name)) 9 | super(Factory, self).__init__(*args, **kwargs) 10 | self._meta['value_unit'] = 'F' 11 | self._meta['devpath'] = self._devpath = devpath 12 | 13 | def _runloop(self): 14 | with open(self._devpath) as f: 15 | lines = f.read().split('\n') 16 | if len(lines) > 1: 17 | v = lines[1].split(' ')[-1].split('=')[1] 18 | self.publish(int((((float(v) / 1000.0) * 1.8) + 32))) 19 | -------------------------------------------------------------------------------- /lib/rpjios/sensors/LM335.py: -------------------------------------------------------------------------------- 1 | from rpjios.SensorBase import SensorName, SensorDesc 2 | from rpjios.AnalogBase import Analog 3 | 4 | class Factory(Analog): 5 | @SensorName("LM335") 6 | @SensorDesc("LM335 precision temperature sensor (analog)") 7 | def __init__(self, voltage=3.3, *args, **kwargs): 8 | super(Factory, self).__init__(*args, **kwargs) 9 | self._vconv = lambda x: (((x / 2 ** self.bitwidth)*(voltage * 10.0)) - 273.15) * 1.8 + 32 10 | if voltage == 3.3: 11 | self._vconv = lambda x: x * 0.580078125 - 459.67 12 | 13 | def _runloop(self): 14 | self.publish(self._vconv(self.raw_value)) 15 | -------------------------------------------------------------------------------- /lib/rpjios/sensors/NetInfo.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import psutil 4 | from rpjios.SensorBase import Sensor, SensorName, SensorDesc, SensorCategory 5 | from subprocess import Popen, PIPE 6 | 7 | class Factory(Sensor): 8 | @SensorName("Net") 9 | @SensorCategory("info") 10 | @SensorDesc("Network information") 11 | def __init__(self, *args, **kwargs): 12 | super(Factory, self).__init__(*args, **kwargs) 13 | self._last = {} 14 | 15 | def _gather_wifi(self, iface): 16 | c = ['/sbin/iwgetid', iface, '--raw'] 17 | g = { 'ssid': None, 'mac': '--ap', 'freq': '--freq', 'chan': '--channel' } 18 | for gk in g: 19 | ta = list(c) 20 | if g[gk] is not None: 21 | ta.append(g[gk]) 22 | g[gk] = Popen(ta, stdout=PIPE).communicate()[0].strip() 23 | if gk == 'freq': 24 | g[gk] = "{0:.2f} GHz".format(float(g[gk]) / 1e9) 25 | c2 = ["Bit Rate", "Tx-Power", "Link Quality", "Signal level"] 26 | c2d = {} 27 | for cl in Popen(['/sbin/iwconfig', iface], stdout=PIPE).communicate()[0].split('\n'): 28 | for c2i in c2: 29 | m = re.match(".*{}\=(.*?)(?:\s\s)+".format(c2i), cl) 30 | if m and len(m.groups()): 31 | c2d[c2i] = m.groups()[0] 32 | if len(c2d): 33 | g['extended_info'] = c2d 34 | return g 35 | 36 | def _runloop(self): 37 | cur = {} 38 | cur['internet_reachable'] = True if os.system("/usr/bin/curl http://google.com > /dev/null 2>&1") == 0 else False 39 | cur['interfaces'] = {} 40 | 41 | ni = psutil.net_if_addrs() 42 | for n in ni: 43 | if n != 'lo' and len(ni[n]): 44 | cur['interfaces'][n] = {'addr': ni[n][0].address} 45 | if 'wlan' in n: 46 | wf = self._gather_wifi(n) 47 | for k in wf: 48 | cur['interfaces'][n][k] = wf[k] 49 | 50 | dirty = False 51 | for k in cur: 52 | if k not in self._last or self._last[k] != cur[k]: 53 | dirty = True 54 | break 55 | 56 | if 1:#dirty: 57 | self.publish(cur) 58 | 59 | self._last = cur 60 | 61 | -------------------------------------------------------------------------------- /lib/rpjios/sensors/SPS30.py: -------------------------------------------------------------------------------- 1 | from rpjios.devices.SPS30 import SPS30 2 | from rpjios.SensorBase import Sensor, SensorName, SensorDesc 3 | 4 | class Factory(Sensor): 5 | @SensorName("SPS30") 6 | @SensorDesc("Sensiron SPS30 particulate matter sensor") 7 | def __init__(self, *args, **kwargs): 8 | if 'frequency' in kwargs and int(kwargs['frequency']) > 1: 9 | raise BaseException("SPS30 only supports frequencies of 1Hz or less") 10 | super(Factory, self).__init__(*args, **kwargs) 11 | self._sps = SPS30(**kwargs) 12 | self._meta['serial_number'] = self._sps.serial_number 13 | self._meta['driver_version'] = self._sps.driver_version 14 | 15 | def _runloop(self): 16 | meas = self._sps.measurement 17 | if meas is not None: 18 | self.publish(self._attrs_to_dict(meas)) 19 | -------------------------------------------------------------------------------- /lib/rpjios/sensors/Soil.py: -------------------------------------------------------------------------------- 1 | from rpjios.SensorBase import SensorName, SensorDesc 2 | from rpjios.AnalogBase import Analog 3 | 4 | class Factory(Analog): 5 | @SensorName("Soil") 6 | @SensorDesc("Soil moisture sensor") 7 | def __init__(self, *args, **kwargs): 8 | super(Factory, self).__init__(*args, **kwargs) 9 | 10 | def _runloop(self): 11 | self.publish(self.raw_value) 12 | -------------------------------------------------------------------------------- /lib/rpjios/sensors/SysInfo.py: -------------------------------------------------------------------------------- 1 | import os 2 | import psutil 3 | from datetime import timedelta 4 | from rpjios.SensorBase import Sensor, SensorName, SensorDesc, SensorCategory 5 | 6 | def cpuTemp(): 7 | with open('/sys/class/thermal/thermal_zone0/temp') as f: 8 | return (float(f.read()) / 1000.0) * 1.8 + 32 9 | 10 | def uptime(): 11 | with open('/proc/uptime', 'r') as f: 12 | uptime_seconds = int(float(f.readline().split()[0])) 13 | uptime_string = str(timedelta(seconds = uptime_seconds)) 14 | if (uptime_seconds < 10*60*60): 15 | uptime_string = '0'+uptime_string 16 | return uptime_string 17 | 18 | class Factory(Sensor): 19 | @SensorName("Sys") 20 | @SensorCategory("info") 21 | @SensorDesc("Local system information") 22 | def __init__(self, *args, **kwargs): 23 | if 'frequency' not in kwargs: 24 | kwargs['frequency'] = 2 25 | super(Factory, self).__init__(*args, **kwargs) 26 | self._hn = None 27 | with open('/etc/hostname') as f: 28 | self._hn = f.read().strip() 29 | 30 | def _runloop(self): 31 | _st = os.statvfs('/') 32 | self.publish({ 33 | 'cpu%': psutil.cpu_percent(), 34 | 'cpuF': cpuTemp(), 35 | 'vm%': psutil.virtual_memory().percent, 36 | 'fsFree%': { 37 | '/': (float(_st.f_bfree) / float(_st.f_blocks)) * 100.0 38 | }, 39 | 'uptime': uptime(), 40 | 'hostname': self._hn 41 | }) 42 | -------------------------------------------------------------------------------- /lib/rpjios/sensors/TEPT5700.py: -------------------------------------------------------------------------------- 1 | from rpjios.SensorBase import SensorName, SensorDesc 2 | from rpjios.AnalogBase import Analog 3 | 4 | class Factory(Analog): 5 | @SensorName("TEPT5700") 6 | @SensorDesc("TEPT5700 ambient light sensor (analog)") 7 | def __init__(self, *args, **kwargs): 8 | super(Factory, self).__init__(*args, **kwargs) 9 | 10 | def _runloop(self): 11 | self.publish(self.raw_value) 12 | -------------------------------------------------------------------------------- /lib/rpjios/sensors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rpj/rpi/bab2f907312805295ce3e16b19ce8338fa9f6a8c/lib/rpjios/sensors/__init__.py -------------------------------------------------------------------------------- /mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "BME680": { 3 | "gas_resistance": "air_quality", 4 | "status": false, 5 | "gas_index": false, 6 | "meas_index": false, 7 | "heat_stable": false 8 | }, 9 | "DHTXX": { 10 | "variant": false, 11 | "humidity%": "relative_humidity", 12 | "tempF": "temperature_fahrenheit" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /requirements-nonRPi.txt: -------------------------------------------------------------------------------- 1 | click==6.7 2 | crc16==0.1.1 3 | enum34==1.1.6 4 | hiredis==0.2.0 5 | monotonic==1.5 6 | psutil==5.6.6 7 | redis==2.10.5 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Adafruit-DHT==1.3.4 2 | Adafruit-GPIO==1.0.3 3 | Adafruit-MCP3008==1.0.2 4 | Adafruit-PureIO==0.2.3 5 | Adafruit-SSD1306==1.6.2 6 | bme680==1.0.5 7 | click==6.7 8 | crc16==0.1.1 9 | enum34==1.1.6 10 | hiredis==0.2.0 11 | monotonic==1.5 12 | Pillow==6.2.0 13 | psutil==5.6.6 14 | redis==2.10.5 15 | RPi.GPIO==0.6.3 16 | RPi.bme280>=0.2.2 17 | smbus2>=0.2.1 18 | spidev>=3.2 19 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | check_and_install() { 4 | HOW=$1 5 | PKGNAME=$2 6 | ALTAPTNAME=$3 7 | 8 | echo -n "* Looking for ${PKGNAME}: " 9 | if [ $HOW == "which" ]; then 10 | which "${PKGNAME}" > /dev/null 11 | elif [[ $HOW == "pkgcfg" || $HOW == "pkg-config" ]]; then 12 | pkg-config --exists "${PKGNAME}" > /dev/null 13 | elif [[ $HOW == "apt" || $HOW == "apt-list" ]]; then 14 | apt list --installed 2> /dev/null | grep "${PKGNAME}" > /dev/null 15 | fi 16 | 17 | if [ $? == 1 ]; then 18 | echo "NOT FOUND" 19 | _N=$ALTAPTNAME 20 | if [ -z "$_N" ]; then 21 | _N=$PKGNAME 22 | fi 23 | echo "* Trying to install ${_N}..." 24 | sudo apt -y install ${_N} > ${_N}_apt-install.stdout 2> ${_N}_apt-install.stderr 25 | else 26 | echo "found" 27 | fi 28 | } 29 | 30 | link_local_py_lib() { 31 | LIBDIR=$1 32 | 33 | echo "* Linking local Python library '${LIBDIR}'" 34 | if [ ! -L "env/lib/python2.7/site-packages/${LIBDIR}" ]; then 35 | pushd "env/lib/python2.7/site-packages/" > /dev/null 36 | ln -s "../../../../${LIBDIR}" 2> /dev/null > /dev/null 37 | popd > /dev/null 38 | fi 39 | } 40 | 41 | build_embedded_sps() { 42 | echo -n "* Fetching embedded-sps submodule: " 43 | git submodule init >> git-setup.stdout 2>> git-setup.stderr 44 | git submodule update --recursive >> git-setup.stdout 2>> git-setup.stderr 45 | pushd embedded-sps > /dev/null 46 | git submodule init >> git-setup.stdout 2>> git-setup.stderr 47 | git submodule update --recursive >> git-setup.stdout 2>> git-setup.stderr 48 | echo "done" 49 | echo -n "* Building embedded-sps submodule: " 50 | make release > make.release.stdout 2> make.release.stderr 51 | pushd release/sps30-i2c > /dev/null 52 | pushd hw_i2c > /dev/null 53 | mv sensirion_hw_i2c_implementation.c sensirion_hw_i2c_implementation.c.orig 54 | ln -s sample-implementations/linux_user_space/sensirion_hw_i2c_implementation.c 55 | popd > /dev/null 56 | make > make.stdout 2> make.stderr 57 | if [ -f libsps30.so ]; then 58 | numSyms=`nm libsps30.so | grep -i sps | wc -l` 59 | cp libsps30.so ../../../lib/rpjios/devices/ 60 | echo "success (${numSyms})" 61 | else 62 | echo "failure!" 63 | fi 64 | popd > /dev/null 65 | popd > /dev/null 66 | } 67 | 68 | 69 | cat /etc/os-release | perl -ne "exit(1), if (/ID_LIKE=debian/)" 70 | if [ $? != 1 ]; then 71 | echo "** Looks like you're not on a Debian-like system, and I need 'apt'. Sorry." 72 | exit -1 73 | fi 74 | 75 | IS_RPI=1 76 | REQ_FILE=requirements.txt 77 | 78 | cat /etc/os-release | perl -ne "exit(1), if (/ID=raspbian/)" 79 | if [ $? != 1 ]; then 80 | echo "*** Non-RPi platform detected: omitting unneeded modules." 81 | IS_RPI=0 82 | REQ_FILE=requirements-nonRPi.txt 83 | else 84 | echo "*** RPi platform detected: building sensor drivers and including hardware interface modules." 85 | fi 86 | 87 | echo 88 | 89 | check_and_install "which" "virtualenv" 90 | check_and_install "apt" "redis-server" 91 | check_and_install "which" "zip" 92 | check_and_install "apt" "python-dev" 93 | check_and_install "apt" "libjpeg9-dev" 94 | 95 | if [ ${IS_RPI} == 1 ]; then 96 | check_and_install "apt" "python-smbus" 97 | build_embedded_sps 98 | fi 99 | 100 | if [ ! -d "./env" ]; then 101 | echo -n "* Initializing virtualenv: " 102 | virtualenv --system-site-packages --prompt="(rpjios venv) " ./env > virtualenv-init.stdout 2> virtualenv-init.stderr 103 | if [ $? != 0 ]; then 104 | echo "failed! Cannot continue." 105 | exit -1 106 | else 107 | echo "done" 108 | fi 109 | fi 110 | 111 | source env/bin/activate 112 | 113 | link_local_py_lib "lib/rpjios" 114 | 115 | echo -n "* Installing modules from '${REQ_FILE}' (this may take awhile): " 116 | pip install -r ${REQ_FILE} > pip-install.stdout 2> pip-install.stderr 117 | echo "done" 118 | 119 | if [ $? == 0 ]; then 120 | echo "" 121 | echo "*** Success! Run 'source env/bin/activate' to get started." 122 | else 123 | echo "*** ERROR ***" 124 | fi 125 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 0.0.5-67 (master@a95d5a6) 2 | --------------------------------------------------------------------------------