├── .gitignore ├── Config.py ├── Constants.py ├── InfluxDbLogger.py ├── LICENSE ├── LoggingUtils.py ├── MotionDetectionServer.py ├── MotionStateMachine.py ├── OximeterEmulator.py ├── OximeterReader.py ├── ProcessProtocolUtils.py ├── README.md ├── SleepMonitor.py ├── TestSleepMonitor.py ├── ZeroConfUtils.py ├── build.sh ├── get_latest_grafana_dashboard.sh ├── grafana.ini ├── grafana_dashboard.json ├── grafana_datasource.json ├── gstream_audio.sh ├── gstream_test_video.sh ├── gstream_video.sh ├── influxdb.conf ├── janus.plugin.streaming.cfg ├── setup.cfg ├── setup_grafana.py ├── setup_grafana.sh ├── sleep_monitor.service ├── testpicam.py ├── testserial.py └── web ├── cam.html ├── connection_alarm.mp3 ├── index.html ├── jabba_the_hutt.gif ├── js ├── Alarm.js ├── SleepMonitorApp.js ├── cam.js ├── jquery_ext.js ├── motion.js ├── slow.js ├── start.js ├── status.js ├── stream.js └── update_config.js ├── js3p ├── adapter.js ├── bootbox.min.js ├── bootstrap.js ├── bootstrap.min.js ├── janus.js ├── jquery.min.js ├── md5.min.js └── spin.min.js ├── main.css ├── minimal.html ├── motion.html ├── motion_alarm.mp3 ├── oximeter_alarm.mp3 ├── slow.html ├── start.html ├── test.css ├── test.html ├── test.jpeg └── update_config.html /.gitignore: -------------------------------------------------------------------------------- 1 | # Vim swap files 2 | *.sw? 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | env3/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # IPython Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | venv/ 87 | ENV/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | -------------------------------------------------------------------------------- /Config.py: -------------------------------------------------------------------------------- 1 | import ConfigParser 2 | import os 3 | 4 | class Config: 5 | def __init__(self): 6 | self.paramNames = ('sustainedTime', 'calmTime', 'awakeBpm', 'spo2AlarmThreshold', 'spo2AlarmTime') 7 | 8 | # Time (in seconds) for which we need to see sustained motion to claim that 9 | # the baby is moving 10 | self.sustainedTime = 90 11 | 12 | # Time (in seconds) for which if there is no motion, we think the baby has 13 | # gone back to sleep 14 | self.calmTime = 30 15 | 16 | # Heart rate (beats per minute) above which we think of the baby as being 17 | # awake. This is used in addition to any motion detected by the camera 18 | self.awakeBpm = 140 19 | 20 | # SPO2 threshold below which we want to see an alarm 21 | self.spo2AlarmThreshold = 94 22 | 23 | # Time (in seconds) for which we need to see the SPO2 fall below 24 | # `spo2AlarmThreshold` to raise an alarm 25 | self.spo2AlarmTime = 20 26 | 27 | configPath = self.getConfigFilePath() 28 | if os.path.isfile(configPath): 29 | config = self.getConfigParser() 30 | config.read(configPath) 31 | 32 | for (key, val) in config.items('Main'): 33 | if hasattr(self, key): 34 | setattr(self, key, int(val)) 35 | 36 | def getConfigParser(self): 37 | config = ConfigParser.RawConfigParser() 38 | # The below option preserves case of the key names. Otherwise, they 39 | # all get converted to lowercase by default (!) 40 | config.optionxform = str 41 | return config 42 | 43 | def getConfigFilePath(self): 44 | return '/home/pi/sleep_monitor.cfg' 45 | 46 | def write(self): 47 | config = self.getConfigParser() 48 | 49 | config.add_section('Main') 50 | for propName in self.paramNames: 51 | config.set('Main', propName, str(getattr(self, propName))) 52 | 53 | with open(self.getConfigFilePath(), 'wb') as configfile: 54 | config.write(configfile) 55 | 56 | if __name__ == "__main__": 57 | config = Config() 58 | for name in config.paramNames: 59 | print '%s: %s' % (name, getattr(config, name)) 60 | -------------------------------------------------------------------------------- /Constants.py: -------------------------------------------------------------------------------- 1 | class MotionReason: 2 | NONE = "none" 3 | BPM = "(heart rate is higher than threshold)" 4 | CAMERA = "(motion detected on camera)" 5 | 6 | class OximeterStatus: 7 | CONNECTED = 'connected' 8 | PROBE_DISCONNECTED = 'Oximeter probe disconnected' 9 | CABLE_DISCONNECTED = 'Serial cable disconnected' 10 | -------------------------------------------------------------------------------- /InfluxDbLogger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from twisted.internet import reactor, stdio 4 | from twisted.protocols import basic 5 | 6 | from datetime import datetime, timedelta 7 | from influxdb import InfluxDBClient 8 | 9 | HOST = "localhost" 10 | PORT = 9001 11 | USER = "pi" 12 | PASSWORD = "pi" 13 | DB_NAME = "sleep_monitor" 14 | 15 | class ProcessInput(basic.LineReceiver): 16 | # This seemingly unused line is necessary to over-ride the delimiter 17 | # property of basic.LineReceiver which by default is '\r\n'. Do not 18 | # remove this! 19 | from os import linesep as delimiter 20 | 21 | def __init__(self, client): 22 | self.client = client 23 | self.session = 'production' 24 | self.runNo = datetime.utcnow().strftime('%Y%m%d%H%M') 25 | self.lastLogTime = datetime.min 26 | 27 | self.lastSpo2 = -1 28 | self.lastBpm = -1 29 | self.lastMotion = 0 30 | self.lastAlarm = 0 31 | 32 | def shouldLog(self, time, spo2, bpm, motion, alarm): 33 | if spo2 != self.lastSpo2: 34 | return True 35 | if bpm != self.lastBpm: 36 | return True 37 | if motion != self.lastMotion: 38 | return True 39 | if alarm != self.lastAlarm: 40 | return True 41 | if (time - self.lastLogTime) > timedelta(minutes=10): 42 | return True 43 | 44 | return False 45 | 46 | def lineReceived(self, line): 47 | nums = [int(s) for s in line.split()] 48 | (spo2, bpm, motion, alarm) = nums 49 | 50 | time = datetime.utcnow() 51 | 52 | if self.shouldLog(time, spo2, bpm, motion, alarm): 53 | json_body = [{ 54 | "measurement": self.session, 55 | "tags": { 56 | "run": self.runNo, 57 | }, 58 | "time": time.ctime(), 59 | "fields": { 60 | "spo2": spo2, 61 | "bpm": bpm, 62 | "motion": motion, 63 | "alarm": alarm 64 | } 65 | }] 66 | 67 | # Write JSON to InfluxDB 68 | self.client.write_points(json_body) 69 | self.lastLogTime = time 70 | 71 | self.lastSpo2 = spo2 72 | self.lastBpm = bpm 73 | self.lastMotion = motion 74 | self.lastAlarm = alarm 75 | 76 | def createInfluxClient(): 77 | client = InfluxDBClient(HOST, PORT, USER, PASSWORD, DB_NAME) 78 | 79 | dbs = client.get_list_database() 80 | for db in dbs: 81 | if db['name'] == DB_NAME: 82 | break 83 | else: 84 | client.create_database(DB_NAME) 85 | 86 | return client 87 | 88 | def main(): 89 | client = createInfluxClient() 90 | stdio.StandardIO(ProcessInput(client)) 91 | reactor.run() 92 | 93 | if __name__ == "__main__": 94 | main() 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Srinath Avadhanula 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 | -------------------------------------------------------------------------------- /LoggingUtils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import logging 3 | import os.path 4 | from ProcessProtocolUtils import TerminalEchoProcessProtocol 5 | 6 | def log(msg): 7 | tnow = datetime.now() 8 | logging.info('%s' % msg) 9 | 10 | def setupLogging(): 11 | logFormatter = logging.Formatter("%(message)s") 12 | rootLogger = logging.getLogger() 13 | rootLogger.setLevel(logging.DEBUG) 14 | 15 | consoleHandler = logging.StreamHandler() 16 | consoleHandler.setFormatter(logFormatter) 17 | rootLogger.addHandler(consoleHandler) 18 | 19 | class LoggingProtocol(TerminalEchoProcessProtocol): 20 | def __init__(self, prefix): 21 | TerminalEchoProcessProtocol.__init__(self) 22 | self.prefix = prefix 23 | 24 | def outLineReceived(self, line): 25 | txt = '%s: %s' % (self.prefix, line) 26 | log(txt) 27 | 28 | def errLineReceived(self, line): 29 | txt = '%s: ERROR: %s' % (self.prefix, line) 30 | log(txt) 31 | -------------------------------------------------------------------------------- /MotionDetectionServer.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | from twisted.internet import reactor, protocol, stdio 4 | from twisted.protocols import basic 5 | 6 | import io 7 | import sys 8 | 9 | from PIL import Image 10 | from PIL import ImageOps 11 | from PIL import ImageFilter 12 | from PIL import ImageChops 13 | from Config import Config 14 | 15 | from MotionStateMachine import MotionStateMachine 16 | 17 | from datetime import datetime 18 | import logging 19 | 20 | def log(msg): 21 | tnow = datetime.now() 22 | logging.info('%s: %s' % (tnow.isoformat(), msg)) 23 | 24 | def setupLogging(): 25 | logFormatter = logging.Formatter("%(message)s") 26 | rootLogger = logging.getLogger() 27 | rootLogger.setLevel(logging.DEBUG) 28 | 29 | consoleHandler = logging.StreamHandler() 30 | consoleHandler.setFormatter(logFormatter) 31 | rootLogger.addHandler(consoleHandler) 32 | 33 | class ProcessInput(basic.LineReceiver): 34 | # This seemingly unused line is necessary to over-ride the delimiter 35 | # property of basic.LineReceiver which by default is '\r\n'. Do not 36 | # remove this! 37 | from os import linesep as delimiter 38 | 39 | def __init__(self, factory): 40 | self.factory = factory 41 | 42 | def lineReceived(self, line): 43 | log('line recd: %s' % line) 44 | if line == 'reset': 45 | log('resetting motion detection service') 46 | self.factory.reset() 47 | 48 | class JpegStreamReaderFactory(protocol.Factory): 49 | def __init__(self): 50 | self.protocol = JpegStreamReaderForMotion 51 | self.reset() 52 | 53 | def reset(self): 54 | self.config = Config() 55 | 56 | self.motionStateMachine = MotionStateMachine() 57 | log('starting motion state machine with (%d, %d)' % (self.config.sustainedTime, self.config.calmTime)) 58 | self.motionStateMachine.SUSTAINED_TIME = self.config.sustainedTime 59 | self.motionStateMachine.CALM_TIME = self.config.calmTime 60 | 61 | class JpegStreamReaderForMotion(protocol.Protocol): 62 | DETECTION_THRESHOLD = 0.01 63 | 64 | def __init__(self): 65 | self.data = '' 66 | self.motionDetected = False 67 | self.motionSustained = False 68 | self.prevImage = None 69 | self.imgcounter = 0 70 | 71 | def processImage(self, im): 72 | im = im.resize((320, 240)) 73 | im = ImageOps.grayscale(im) 74 | im = im.filter(ImageFilter.BLUR) 75 | 76 | if not self.prevImage: 77 | self.prevImage = im 78 | return 79 | 80 | imd = ImageChops.difference(im, self.prevImage) 81 | 82 | def mappoint(pix): 83 | if pix > 20: 84 | return 255 85 | else: 86 | return 0 87 | imbw = imd.point(mappoint) 88 | 89 | hist = imbw.histogram() 90 | percentWhitePix = hist[-1] / (hist[0] + hist[-1]) 91 | motionDetected = (percentWhitePix > self.DETECTION_THRESHOLD) 92 | self.factory.motionStateMachine.step(motionDetected) 93 | motionSustained = self.factory.motionStateMachine.inSustainedMotion() 94 | 95 | print '%d %d' % (motionDetected, motionSustained) 96 | sys.stdout.flush() 97 | 98 | self.prevImage = im 99 | 100 | def processChunk(self, data): 101 | if not data: 102 | return 103 | 104 | idx = data.find(b'\xff\xd8\xff') 105 | data = data[idx:] 106 | 107 | stream = io.BytesIO(data) 108 | img = Image.open(stream) 109 | self.processImage(img) 110 | self.imgcounter += 1 111 | 112 | def dataReceived(self, data): 113 | self.data += data 114 | chunks = self.data.split('--spionisto\r\n') 115 | 116 | for chunk in chunks[:-1]: 117 | self.processChunk(chunk) 118 | 119 | self.data = chunks[-1] 120 | 121 | def startServer(): 122 | print 'Starting...' 123 | 124 | factory = JpegStreamReaderFactory() 125 | 126 | stdio.StandardIO(ProcessInput(factory)) 127 | 128 | reactor.listenTCP(9998, factory) 129 | 130 | print 'MOTION_DETECTOR_READY' 131 | log('printed ready signal') 132 | sys.stdout.flush() 133 | 134 | reactor.run() 135 | 136 | if __name__ == "__main__": 137 | setupLogging() 138 | log('Starting main method of motion detection') 139 | try: 140 | startServer() 141 | except: # noqa: E722 (OK to use bare except) 142 | logging.exception("startServer() threw exception") 143 | -------------------------------------------------------------------------------- /MotionStateMachine.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | def timeElapsed(tnow, t): 4 | dt = tnow - t 5 | return dt.total_seconds() 6 | 7 | class MotionStateMachine: 8 | IDLE = 1 9 | MOTION_DETECTED = 2 10 | SUSTAINED_MOTION = 3 11 | 12 | MOTION_DETECTED_MOTION = 1 13 | MOTION_DETECTED_NOMOTION = 2 14 | 15 | SUSTAINED_MOTION_MOTION = 1 16 | SUSTAINED_MOTION_NOMOTION = 2 17 | 18 | def __init__(self): 19 | self.reset() 20 | 21 | def reset(self): 22 | self.state = 0 23 | self.MOTION_DETECTED_state = 0 24 | self.SUSTAINED_MOTION_state = 0 25 | 26 | self.MOTION_DETECTED_tStart = 0 27 | self.MOTION_DETECTED_NOMOTION_tStart = 0 28 | self.SUSTAINED_MOTION_NOMOTION_tStart = 0 29 | 30 | self.CALM_TIME = 30 31 | self.SUSTAINED_TIME = 90 32 | 33 | def inSustainedMotion(self): 34 | return self.state == self.SUSTAINED_MOTION 35 | 36 | def secondsInSustainedMotion(self): 37 | if not self.inSustainedMotion(): 38 | return 0 39 | 40 | return timeElapsed(self.SUSTAINED_MOTION_tStart) 41 | 42 | def step(self, motion, tnow=None): 43 | if not tnow: 44 | tnow = datetime.now() 45 | 46 | if self.state == 0: 47 | self.state = self.IDLE 48 | 49 | if self.state == self.IDLE: 50 | 51 | if motion: 52 | self.state = self.MOTION_DETECTED 53 | self.MOTION_DETECTED_state = self.MOTION_DETECTED_MOTION 54 | self.MOTION_DETECTED_tStart = tnow 55 | 56 | elif self.state == self.MOTION_DETECTED: 57 | 58 | if motion and timeElapsed(tnow, self.MOTION_DETECTED_tStart) >= self.SUSTAINED_TIME: 59 | self.state = self.SUSTAINED_MOTION 60 | self.MOTION_DETECTED_state = 0 61 | self.SUSTAINED_MOTION_state = self.SUSTAINED_MOTION_MOTION 62 | self.SUSTAINED_MOTION_tStart = tnow 63 | 64 | elif self.MOTION_DETECTED_state == self.MOTION_DETECTED_MOTION: 65 | 66 | if not motion: 67 | self.MOTION_DETECTED_state = self.MOTION_DETECTED_NOMOTION 68 | self.MOTION_DETECTED_NOMOTION_tStart = tnow 69 | 70 | elif self.MOTION_DETECTED_state == self.MOTION_DETECTED_NOMOTION: 71 | 72 | if motion: 73 | self.MOTION_DETECTED_state = self.MOTION_DETECTED_MOTION 74 | else: 75 | dt = timeElapsed(tnow, self.MOTION_DETECTED_NOMOTION_tStart) 76 | if dt >= self.CALM_TIME: 77 | self.MOTION_DETECTED_state = 0 78 | self.state = self.IDLE 79 | 80 | elif self.state == self.SUSTAINED_MOTION: 81 | 82 | if self.SUSTAINED_MOTION_state == self.SUSTAINED_MOTION_MOTION: 83 | if not motion: 84 | self.SUSTAINED_MOTION_state = self.SUSTAINED_MOTION_NOMOTION 85 | self.SUSTAINED_MOTION_NOMOTION_tStart = tnow 86 | 87 | elif self.SUSTAINED_MOTION_state == self.SUSTAINED_MOTION_NOMOTION: 88 | if motion: 89 | self.SUSTAINED_MOTION_state = self.SUSTAINED_MOTION_MOTION 90 | elif timeElapsed(tnow, self.SUSTAINED_MOTION_NOMOTION_tStart) >= self.CALM_TIME: 91 | self.SUSTAINED_MOTION_state = 0 92 | self.state = self.IDLE 93 | 94 | if __name__ == "__main__": 95 | sm = MotionStateMachine() 96 | 97 | from dateutil.parser import parse 98 | import matplotlib.pyplot as plot 99 | import re 100 | 101 | PAT_INFO = re.compile(r'(?P