commit a93f116aaea43ad95b9efaf6eab1b37827101bf6 Author: petrucci4prez Date: Fri Dec 30 02:51:56 2016 -0500 init diff --git a/auxilary.py b/auxilary.py new file mode 100644 index 0000000..ec7dc2f --- /dev/null +++ b/auxilary.py @@ -0,0 +1,83 @@ +import time, psutil, yaml +from subprocess import check_output, DEVNULL, CalledProcessError +from threading import Thread, Event + +class ConfigFile(): + def __init__(self, path): + self._path = path + with open(self._path, 'r') as f: + self._dict = yaml.safe_load(f) + + def __getitem__(self, key): + return self._dict[key] + + def __setitem__(self, key, value): + self._dict[key] = value + + def sync(self): + with open(self._path, 'w') as f: + yaml.dump(self._dict, f, default_flow_style=False) + +def freeBusyPath(path, logger=None): + # check if any other processes are using file path + # if found, politely ask them to exit, else nuke them + + # NOTE: fuser sends diagnostic info (eg filenames and modes...which we + # don't want) to stderr. This is weird, but let's me route to /dev/null + # so I don't have to parse it later + try: + stdout = check_output(['fuser', path], universal_newlines=True, stderr=DEVNULL) + except CalledProcessError: + logger.debug('%s not in use. Execution may continue', path) + else: + # assume stdout is one PID first + try: + processes = [psutil.Process(int(stdout))] + + # else assume we have multiple PIDs separated by arbitrary space + except ValueError: + processes = [psutil.Process(int(s)) for s in stdout.split()] + + for p in processes: + if logger: + logger.warning('%s in use by PID %s. Sending SIGTERM', path, p.pid) + p.terminate() + + dead, alive = psutil.wait_procs(processes, timeout=10) + + for p in alive: + if logger: + logger.warning('Failed to terminate PID %s. Sending SIGKILL', p.pid) + p.kill() + +class async: + def __init__(self, daemon=False): + self._daemon = daemon + + def __call__(self, f): + def wrapper(*args, **kwargs): + t = Thread(target=f, daemon=self._daemon, args=args, kwargs=kwargs) + t.start() + return wrapper + +class CountdownTimer(Thread): + def __init__(self, countdownSeconds, action, sound=None): + self._stopper = Event() + + def countdown(): + for i in range(countdownSeconds, 0, -1): + if self._stopper.isSet(): + return None + if sound and i < countdownSeconds: + sound.play() + time.sleep(1) + action() + + super().__init__(target=countdown, daemon=True) + self.start() + + def stop(self): + self._stopper.set() + + def __del__(self): + self.stop() diff --git a/blinkenLights.py b/blinkenLights.py new file mode 100644 index 0000000..a069530 --- /dev/null +++ b/blinkenLights.py @@ -0,0 +1,44 @@ +import RPi.GPIO as GPIO +import time, logging +from threading import Thread, Event +from itertools import chain + +logger = logging.getLogger(__name__) + +class Blinkenlights(Thread): + def __init__(self, pin, cyclePeriod=2): + self._stopper = Event() + self._pin = pin + + self.blink = False + self.setCyclePeriod(cyclePeriod) #cyclePeriod is length of one blink cycle in seconds + + GPIO.setup(pin, GPIO.OUT) + pwm = GPIO.PWM(self._pin, 60) + + def blinkLights(): + pwm.start(0) + while not self._stopper.isSet(): + t = self._sleeptime + if self.blink: + for dc in chain(range(100, -1, -5), range(0, 101, 5)): + pwm.ChangeDutyCycle(dc) + time.sleep(t) + else: + pwm.ChangeDutyCycle(100) + time.sleep(t) + pwm.stop() # required to avoid core dumps when process terminates + + super().__init__(target=blinkLights, daemon=True) + self.start() + logger.debug('Starting LED on pin %s', self._pin) + + def setCyclePeriod(self, cyclePeriod): + self._sleeptime = cyclePeriod/20/2 + + def stop(self): + self._stopper.set() + logger.debug('Stopping LED on pin %s', self._pin) + + def __del__(self): + self.stop() diff --git a/camera.py b/camera.py new file mode 100644 index 0000000..5d4e3a2 --- /dev/null +++ b/camera.py @@ -0,0 +1,82 @@ +import cv2, time +from threading import RLock +from sharedLogging import SlaveLogger +from auxilary import freeBusyPath + +class Camera: + def __init__(self, queue): + self._lock = RLock() + self._index = 0 + self._logger = SlaveLogger(__name__, 'DEBUG', queue) + + freeBusyPath('/dev/video{}'.format(self._index), self._logger) + + # NOTE: we use 0-255 on forms instead of floats because they look nicer + logitechProperties = { + 'FPS': 25, # integer from 10 to 30 in multiples of 5 + 'BRIGHTNESS': 127/255, # float from 0 to 1 + 'CONTRAST': 32/255, # float from 0 to 1 + 'SATURATION': 32/255, # float from 0 to 1 + 'GAIN': 64/255, # float from 0 to 1 + } + self._properties = {} + self.setProps(**logitechProperties) + + def getProps(self, *args): + return {prop: self._video.get(getattr(cv2, 'CAP_PROP_' + prop)) for prop in args} + + def setProps(self, *args, **kwargs): + # silly hack, need to reset the videoCapture object every time + # we change settings (they can only be changed once apparently) + self._lock.acquire() + try: + if hasattr(self, '_video'): + self._video.release() + + self._video = cv2.VideoCapture(self._index) + + for prop, val in kwargs.items(): + self._properties[prop] = val + self._video.set(getattr(cv2, 'CAP_PROP_' + prop), val) + self._logger.debug('set %s to %s', prop, val) + finally: + self._lock.release() + + # the reset code here could be put in seperate thread to accellarate + def getFrame(self): + frame = None + self._lock.acquire() + try: + # will try 3 attempts to grab frame, will reset on failure + i = 3 + while i > 0: + if self._video.isOpened(): + success, image = self._video.read() + ret, jpeg = cv2.imencode('.jpg', image) + frame = jpeg.tobytes() + break + else: + time.sleep(5) + self.reset() + i -= 1 + + # after 3 fails return the dummy frame + if not frame: + with open('noimage.jpg', 'rb') as f: + time.sleep(1) + frame = f.read() + finally: + self._lock.release() + + return frame + + def reset(self): + self.setProps(**self._properties) + self._logger.debug('camera reset') + + def __del__(self): + try: + self._video.release() + self._logger.debug('Release camera at index %s', self._index) + except AttributeError: + self._logger.debug('Failed to release camera at index %s', self._index) diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..336ba14 --- /dev/null +++ b/config.yaml @@ -0,0 +1,3 @@ +recipientList: +- natedwarshuis@gmail.com +state: disarmed diff --git a/listeners.py b/listeners.py new file mode 100644 index 0000000..26ce8a4 --- /dev/null +++ b/listeners.py @@ -0,0 +1,143 @@ +import logging, os, sys, stat +from threading import Thread +from evdev import InputDevice, ecodes +from select import select +from auxilary import freeBusyPath +import stateMachine + +logger = logging.getLogger(__name__) + +class KeypadListener(Thread): + def __init__(self, stateMachine, callbackDisarm, callbackArm, soundLib, passwd): + + ctrlKeys = { 69: 'NUML', 98: '/', 14: 'BS', 96: 'ENTER'} + + volKeys = { 55: '*', 74: '-', 78: '+'} + + numKeys = { + 71: '7', 72: '8', 73: '9', + 75: '4', 76: '5', 77: '6', + 79: '1', 80: '2', 81: '3', + 82: '0', 83: '.' + } + + devPath = '/dev/input/by-id/usb-04d9_1203-event-kbd' + + freeBusyPath(devPath, logger) + + self._dev = InputDevice(devPath) + self._dev.grab() + + numKeySound = soundLib.soundEffects['numKey'] + ctrlKeySound = soundLib.soundEffects['ctrlKey'] + wrongPassSound = soundLib.soundEffects['wrongPass'] + backspaceSound = soundLib.soundEffects['backspace'] + + self.resetBuffer() + + def getInput(): + while 1: + r, w, x = select([self._dev], [], []) + for event in self._dev.read(): + if event.type == 1 and event.value == 1: + + # numeral input + if event.code in numKeys: + if stateMachine.currentState != stateMachine.states.disarmed: + self._buf = self._buf + numKeys[event.code] + numKeySound.play() + + # ctrl input + elif event.code in ctrlKeys: + val = ctrlKeys[event.code] + + # disarm if correct passwd + if val=='ENTER': + if stateMachine.currentState == stateMachine.states.disarmed: + ctrlKeySound.play() + else: + if self._buf == '': + ctrlKeySound.play() + elif self._buf == passwd: + callbackDisarm() + else: + self.resetBuffer() + wrongPassSound.play() + + # arm + elif val == 'NUML': + callbackArm() + ctrlKeySound.play() + + # delete last char in buffer + elif val == 'BS': + self._buf = self._buf[:-1] + backspaceSound.play() + + # reset buffer + elif val == '/': + self.resetBuffer() + backspaceSound.play() + + # volume input + elif event.code in volKeys: + val = volKeys[event.code] + + if val == '+': + soundLib.changeVolume(10) + + elif val == '-': + soundLib.changeVolume(-10) + + elif val == '*': + soundLib.mute() + + ctrlKeySound.play() + self._dev.set_led(ecodes.LED_NUML, 0 if soundLib.volume > 0 else 1) + + super().__init__(target=getInput, daemon=True) + self.start() + logger.debug('Started keypad device') + + # TODO: make timer to clear buffer if user doesn't clear it + + def resetBuffer(self): + self._buf = '' + + def __del__(self): + try: + self._dev.ungrab() + logger.debug('Released keypad device') + except IOError: + logger.debug('Failed to release keypad device') + except AttributeError: + pass + +# TODO: this code gets really confused if the pipe is deleted +class PipeListener(Thread): + def __init__(self, callback, path): + self._path = path + + if os.path.exists(self._path): + if not stat.S_ISFIFO(os.stat(self._path)[0]): + os.remove(self._path) + os.mkfifo(self._path) + else: + os.mkfifo(self._path) + + os.chmod(self._path, 0o0777) + + def listenForSecret(): + while 1: + with open(self._path, 'r') as f: + msg = f.readline()[:-1] + callback(msg, logger) + + super().__init__(target=listenForSecret, daemon=True) + self.start() + logger.debug('Started pipe listener at path %s', self._path) + + def __del__(self): + if os.path.exists(self._path): + os.remove(self._path) + logger.debug('Cleaned up pipe listener at path %s', self._path) diff --git a/main.py b/main.py new file mode 100755 index 0000000..7c61887 --- /dev/null +++ b/main.py @@ -0,0 +1,95 @@ +#! /bin/python + +import sys, os, time, signal, traceback +import RPi.GPIO as GPIO +from queue import Queue +from multiprocessing.managers import BaseManager, DictProxy + +def clean(): + GPIO.cleanup() + + try: + stateMachine.__del__() + except NameError: + pass + + try: + webInterface.stop() # Kill process 1 + except NameError: + pass + + try: + logger.info('Terminated root process - PID: %s', os.getpid()) + logger.stop() + except NameError: + pass + + try: + manager.__del__() # kill process 2 + except NameError: + pass + +def sigtermHandler(signum, stackFrame): + logger.info('Caught SIGTERM') + clean() + exit() + +class ResourceManager(BaseManager): + def __init__(self): + super().__init__() + + from camera import Camera + from microphone import Microphone + self.register('Camera', Camera) + self.register('Queue', Queue) + self.register('Dict', dict, DictProxy) + + def __del__(self): + self.shutdown() + +if __name__ == '__main__': + try: + os.chdir(os.path.dirname(os.path.realpath(__file__))) + + GPIO.setwarnings(False) + GPIO.setmode(GPIO.BCM) + + manager = ResourceManager() + manager.start() # Child process 1 + + loggerQueue = manager.Queue() # used to buffer logs + camera = manager.Camera(loggerQueue) + stateDict = manager.Dict() # used to hold state info + ttsQueue = manager.Queue() # used as buffer for TTS Engine + + from sharedLogging import MasterLogger + logger = MasterLogger(__name__, 'DEBUG', loggerQueue) + + from notifier import criticalError + + from stateMachine import StateMachine + stateMachine = StateMachine(camera, ttsQueue, stateDict) + + from webInterface import WebInterface + webInterface = WebInterface(camera, stateDict, ttsQueue, loggerQueue) + webInterface.start() # Child process 2 + + signal.signal(signal.SIGTERM, sigtermHandler) + + while 1: + time.sleep(31536000) + + except Exception: + t = 'Exception caught:\n' + traceback.format_exc() + + try: + criticalError(t) + except NameError: + pass + + try: + logger.critical(t) + except NameError: + print('[__main__] [CRITICAL] Logger not initialized, using print for console output:\n' + t) + + clean() diff --git a/microphone.py b/microphone.py new file mode 100644 index 0000000..53cd116 --- /dev/null +++ b/microphone.py @@ -0,0 +1,33 @@ +import pyaudio + +CHUNK = 4096 + +class Microphone: + def __init__(self): + print('aloha bra') + self._pa = pyaudio.PyAudio() + + self._stream = self._pa.open( + format = pyaudio.paInt16, + channels = 1, + rate = 48000, + input = True, + frames_per_buffer = CHUNK + ) + + def getFrame(self): + frame = self._stream.read(CHUNK) + print(len(frame)) + return frame + + def __del__(self): + try: + self._stream.stop_stream() + self._stream.close() + except AttributeError: + pass + + try: + self._pa.terminate() + except AttributeError: + pass diff --git a/noimage.jpg b/noimage.jpg new file mode 100644 index 0000000..1c27363 Binary files /dev/null and b/noimage.jpg differ diff --git a/notifier.py b/notifier.py new file mode 100644 index 0000000..61a47ef --- /dev/null +++ b/notifier.py @@ -0,0 +1,63 @@ +import logging, time +from auxilary import async, ConfigFile +from smtplib import SMTP +from datetime import datetime +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +logger = logging.getLogger(__name__) + +COMMASPACE=', ' + +RECIPIENT_LIST=ConfigFile('config.yaml')['recipientList'] + +GMAIL_USER='natedwarshuis@gmail.com' +GMAIL_PWD='bwsasfxqjbookmed' + +def getNextDate(): + m = datetime.now().month + 1 + y = datetime.now().year + y = y + 1 if m > 12 else y + return datetime(year=y, month=m%12, day=1, hour=12, minute=0) + +@async(daemon=True) +def scheduleAction(action): + while 1: + nextDate = getNextDate() + sleepTime = nextDate - datetime.today() + logger.info('Next monthly test scheduled at %s (%s)', nextDate, sleepTime) + time.sleep(sleepTime.days * 86400 + sleepTime.seconds) + action() + +@async(daemon=False) +def sendEmail(subject, body): + msg = MIMEMultipart() + msg['Subject'] = subject + msg['From'] = GMAIL_USER + msg['To'] = COMMASPACE.join(RECIPIENT_LIST) + msg.attach(MIMEText(body, 'plain')) + + s = SMTP('smtp.gmail.com', 587) + s.starttls() + s.login(GMAIL_USER, GMAIL_PWD) + s.send_message(msg) + s.quit() + +def monthlyTest(): + subject = 'harrison4hegemon - automated monthly test' + body = 'this is an automated message - please do not reply\n\nin the future this may have useful information' + sendEmail(subject, body) + logger.info('Sending monthly test to email list') + +def intruderAlert(): + subject = 'harrison4hegemon - intruder detected' + body = 'intruder detected - alarm was tripped on ' + time.strftime("%H:%M:%S - %d/%m/%Y") + sendEmail(subject, body) + logger.info('Sending intruder alert to email list') + +def criticalError(err): + subject = 'harrison4hegemon - critical error' + sendEmail(subject, err) + logger.info('Sending critical error to email list') + +scheduleAction(monthlyTest) diff --git a/remoteServer.py b/remoteServer.py new file mode 100644 index 0000000..3f85512 --- /dev/null +++ b/remoteServer.py @@ -0,0 +1,26 @@ +from async import async +#~ from logger import logGeneric +import socket +from ftplib import FTP +from io import BytesIO +from functools import partial + +def buildUploader(host, port, user, passwd): + + @async(daemon=False) + def uploader(filepath, filename, buf): + retries = 3 + ftp = FTP() + while retries > 0: + try: + ftp.connect(host=host, port=port) + ftp.login(user=user, passwd=passwd) + ftp.cwd(filepath) + ftp.storbinary('STOR ' + filename, BytesIO(buf)) + break + except IOError: + retries =- 1 + #~ logGeneric('remoteServer: Failed to upload file. ' + str(retries) + ' retries left...', 0) + ftp.quit() + + return uploader diff --git a/sensors.py b/sensors.py new file mode 100644 index 0000000..620e837 --- /dev/null +++ b/sensors.py @@ -0,0 +1,60 @@ +import RPi.GPIO as GPIO +import logging, time +from functools import partial +from auxilary import CountdownTimer + +logger = logging.getLogger(__name__) + +# this should never be higher than INFO or motion will never be logged +logger.setLevel(logging.DEBUG) + +# delay GPIO init to avoid false positive during powerup +INIT_DELAY = 60 + +def lowPassFilter(pin, targetVal, period=0.001): + divisions = 10 + sleepTime = period/divisions + + for i in range(0, divisions): + time.sleep(sleepTime) + if GPIO.input(pin) != targetVal: + return False + + return GPIO.input(pin) == targetVal + +def setupGPIO(name, pin, GPIOEvent, callback): + logger.info('setting up \"%s\" on pin %s', name, pin) + GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) + GPIO.add_event_detect(pin, GPIOEvent, callback=callback, bouncetime=500) + +def setupMotionSensor(pin, location, action): + name = 'MotionSensor@' + location + + def trip(channel): + if lowPassFilter(pin, 1): + logger.info('detected motion: ' + location) + action() + + logger.debug('waiting %s for %s to power on', INIT_DELAY, name) + CountdownTimer(INIT_DELAY, partial(setupGPIO, name, pin, GPIO.RISING, trip)) + +def setupDoorSensor(pin, action, sound=None): + def trip(channel): + nonlocal closed + val = GPIO.input(pin) + + if val != closed: + if lowPassFilter(pin, val): + closed = val + if closed: + logger.info('door closed') + if sound: + sound.play() + else: + logger.info('door opened') + if sound: + sound.play() + action() + + setupGPIO('DoorSensor', pin, GPIO.BOTH, trip) + closed = GPIO.input(pin) diff --git a/sharedLogging.py b/sharedLogging.py new file mode 100644 index 0000000..52a5c26 --- /dev/null +++ b/sharedLogging.py @@ -0,0 +1,34 @@ +import logging +from logging.handlers import TimedRotatingFileHandler, QueueListener, QueueHandler + +def SlaveLogger(name, level, queue): + logger = logging.getLogger(name) + logger.setLevel(getattr(logging, level)) + logger.addHandler(QueueHandler(queue)) + logger.propagate = False + return logger + +class MasterLogger(): + def __init__(self, name, level, queue): + consoleFormat = logging.Formatter('[%(name)s] [%(levelname)s] %(message)s') + fileFormat = logging.Formatter('[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s') + + console = logging.StreamHandler() + console.setFormatter(consoleFormat) + + rotatingFile = TimedRotatingFileHandler('/mnt/glusterfs/pyledriver/logs/pyledriver-log', when='midnight') + rotatingFile.setFormatter(fileFormat) + + logging.basicConfig(level=getattr(logging, level), handlers=[QueueHandler(queue)]) + logger = logging.getLogger(name) + + # since the logger module sucks and doesn't allow me to init + # a logger in a subclass, need to "fake" object inheritance + for i in ['debug', 'info', 'warning', 'error', 'critical']: + setattr(self, i, getattr(logger, i)) + + self.queListener = QueueListener(queue, console, rotatingFile) + self.queListener.start() + + def stop(self): + self.queListener.stop() diff --git a/soundLib.py b/soundLib.py new file mode 100644 index 0000000..acda60a --- /dev/null +++ b/soundLib.py @@ -0,0 +1,199 @@ +import logging, os, hashlib, queue, threading, time, psutil +from pygame import mixer +from subprocess import call +from collections import OrderedDict +from auxilary import async + +logger = logging.getLogger(__name__) + +# TODO: figure out why we have buffer underruns +# TODO: why does the mixer segfault? (at least I think that's the cuprit) + +class SoundEffect(mixer.Sound): + def __init__(self, path, volume=None, loops=0): + super().__init__(path) + self.path = path + self.volume = volume + if volume: + self.set_volume(volume) + self.loops = loops + + def play(self, loops=None): + loops = loops if loops else self.loops + mixer.Sound.play(self, loops=loops) + + def set_volume(self, volume, force=False): + # Note: force only intended to be used by fader + if not self.volume or force: + mixer.Sound.set_volume(self, volume) + +class TTSSound(SoundEffect): + def __init__(self, path): + super().__init__(path, volume=1.0, loops=0) + self.size = os.path.getsize(path) + + def __del__(self): + if os.path.exists(self.path): + os.remove(self.path) + +class TTSCache(OrderedDict): + def __init__(self, memLimit): + super().__init__() + self._memLimit = memLimit + self._memUsed = 0 + + def __setitem__(self, key, value): + if type(value) != TTSSound: + raise TypeError + OrderedDict.__setitem__(self, key, value) + self._memUsed += value.size + self._maintainMemLimit() + + def __delitem__(self, key): + self._memUsed -= self[key].size + OrderedDict.__delitem__(self, key) + + def clear(self): + logger.debug('Clearing TTS Cache') + OrderedDict.clear(self) + self._memUsed = 0 + + def _maintainMemLimit(self): + while self._memUsed > self._memLimit: + OrderedDict.popitem(self, last=False) + +class SoundLib: + + _sentinel = None + + def __init__(self, ttsQueue): + mixer.pre_init(frequency=44100, size=-16, channels=2, buffer=1024) + mixer.init() + + self.soundEffects = { + 'disarmedCountdown': SoundEffect(path='soundfx/smb_coin.wav'), + 'disarmed': SoundEffect(path='soundfx/smb_pause.wav'), + 'armed': SoundEffect(path='soundfx/smb_powerup.wav'), + 'armedCountdown': SoundEffect(path='soundfx/smb_jump-small.wav'), + 'triggered': SoundEffect(path='soundfx/alarms/burgler_alarm.ogg', volume=1.0, loops=-1), + 'door': SoundEffect(path='soundfx/smb_pipe.wav'), + 'numKey': SoundEffect(path='soundfx/smb_bump.wav'), + 'ctrlKey': SoundEffect(path='soundfx/smb_fireball.wav'), + 'wrongPass': SoundEffect(path='soundfx/smb_fireworks.wav'), + 'backspace': SoundEffect(path='soundfx/smb_breakblock.wav'), + } + + self._ttsSounds = TTSCache(psutil.virtual_memory().total * 0.001) + + self.volume = 100 + self._applyVolumesToSounds(self.volume) + + self._ttsQueue = ttsQueue + self._stop = threading.Event() + self._startMonitor() + + def changeVolume(self, volumeDelta): + newVolume = self.volume + volumeDelta + if newVolume >= 0 and newVolume <= 100: + self._applyVolumesToSounds(newVolume) + + def mute(self): + self._applyVolumesToSounds(0) + + def speak(self, text): + basename = hashlib.md5(text.encode()).hexdigest() + + if basename in self._ttsSounds: + self._ttsSounds.move_to_end(basename) + else: + path = '/tmp/' + basename + call(['espeak', '-a180', '-g8', '-p75', '-w', path, text]) + self._ttsSounds[basename] = TTSSound(path) + + self._fader( + lowerVolume=0.1, + totalDuration=self._ttsSounds[basename].get_length() + ) + self._ttsSounds[basename].play() + logger.debug('TTS engine received "%s"', text) + + @async(daemon=False) + def _fader(self, lowerVolume, totalDuration, fadeDuration=0.2, stepSize=5): + alarm = self.soundEffects['triggered'] + alarmVolume = alarm.volume + alarmVolumeDelta = alarmVolume - lowerVolume + + masterVolume = self.volume + masterVolumeDelta = self.volume - lowerVolume + + sleepFadeTime = fadeDuration / stepSize + + for i in range(0, stepSize): + if alarmVolumeDelta > 0: + alarm.set_volume(alarmVolume - alarmVolumeDelta * i / stepSize, force=True) + + if masterVolumeDelta > 0: + self._applyVolumesToSounds(masterVolume - masterVolumeDelta * i / stepSize) + + time.sleep(sleepFadeTime) + + time.sleep(totalDuration - 2 * fadeDuration) + + for i in range(stepSize - 1, -1, -1): + if alarmVolumeDelta > 0: + alarm.set_volume(alarmVolume - alarmVolumeDelta * i / stepSize, force=True) + + if masterVolumeDelta > 0: + self._applyVolumesToSounds(masterVolume - masterVolumeDelta * i / stepSize) + + time.sleep(sleepFadeTime) + + # will not change sounds that have preset volume + def _applyVolumesToSounds(self, volume): + self.volume = volume + v = volume/100 + s = self.soundEffects + for name, sound in s.items(): + sound.set_volume(v) + + def _ttsMonitor(self): + q = self._ttsQueue + has_task_done = hasattr(q, 'task_done') + while not self._stop.isSet(): + try: + text = self._ttsQueue.get(True) + if text is self._sentinel: + break + self.speak(text) + if has_task_done: + q.task_done() + except queue.Empty: + pass + # There might still be records in the queue. + while 1: + try: + text = self._ttsQueue.get(False) + if text is self._sentinel: + break + self.speak(text) + if has_task_done: + q.task_done() + except queue.Empty: + break + + def _startMonitor(self): + self._thread = t = threading.Thread(target=self._ttsMonitor, daemon=True) + t.start() + logger.debug('Starting TTS Queue Monitor') + + def _stopMonitor(self): + self._stop.set() + self._ttsQueue.put_nowait(self._sentinel) + self._thread.join() + self._thread = None + logger.debug('Stopping TTS Queue Monitor') + + def __del__(self): + mixer.quit() + self._stopMonitor() + self._ttsSounds.clear() diff --git a/soundfx/alarms/annoying_clipped_alarm.ogg b/soundfx/alarms/annoying_clipped_alarm.ogg new file mode 100644 index 0000000..6abbb95 Binary files /dev/null and b/soundfx/alarms/annoying_clipped_alarm.ogg differ diff --git a/soundfx/alarms/burgler_alarm.ogg b/soundfx/alarms/burgler_alarm.ogg new file mode 100644 index 0000000..f957dbe Binary files /dev/null and b/soundfx/alarms/burgler_alarm.ogg differ diff --git a/soundfx/alarms/industrial_alarm.ogg b/soundfx/alarms/industrial_alarm.ogg new file mode 100644 index 0000000..e5e631a Binary files /dev/null and b/soundfx/alarms/industrial_alarm.ogg differ diff --git a/soundfx/alarms/nuclear_alarm.ogg b/soundfx/alarms/nuclear_alarm.ogg new file mode 100644 index 0000000..315542b Binary files /dev/null and b/soundfx/alarms/nuclear_alarm.ogg differ diff --git a/soundfx/alarms/screaming_alarm.ogg b/soundfx/alarms/screaming_alarm.ogg new file mode 100644 index 0000000..f11a8f5 Binary files /dev/null and b/soundfx/alarms/screaming_alarm.ogg differ diff --git a/soundfx/alarms/submarine_alarm.ogg b/soundfx/alarms/submarine_alarm.ogg new file mode 100644 index 0000000..621d182 Binary files /dev/null and b/soundfx/alarms/submarine_alarm.ogg differ diff --git a/soundfx/alarms/whirly_alarm.ogg b/soundfx/alarms/whirly_alarm.ogg new file mode 100644 index 0000000..f512bff Binary files /dev/null and b/soundfx/alarms/whirly_alarm.ogg differ diff --git a/soundfx/alarms/wtf_alarm.ogg b/soundfx/alarms/wtf_alarm.ogg new file mode 100644 index 0000000..0aa38fc Binary files /dev/null and b/soundfx/alarms/wtf_alarm.ogg differ diff --git a/soundfx/beep-07.wav b/soundfx/beep-07.wav new file mode 100644 index 0000000..015e1f6 Binary files /dev/null and b/soundfx/beep-07.wav differ diff --git a/soundfx/beep-08b.wav b/soundfx/beep-08b.wav new file mode 100644 index 0000000..24a13d4 Binary files /dev/null and b/soundfx/beep-08b.wav differ diff --git a/soundfx/beep-09b.wav b/soundfx/beep-09b.wav new file mode 100644 index 0000000..01a7674 --- /dev/null +++ b/soundfx/beep-09b.wav @@ -0,0 +1,13 @@ + + + + +Image Download + + +

Download Failed

+

The error message is: Requested file is not available

+ + \ No newline at end of file diff --git a/soundfx/smb2_door_appears.wav b/soundfx/smb2_door_appears.wav new file mode 100644 index 0000000..0453afd Binary files /dev/null and b/soundfx/smb2_door_appears.wav differ diff --git a/soundfx/smb2_enter_door.wav b/soundfx/smb2_enter_door.wav new file mode 100644 index 0000000..b615a94 Binary files /dev/null and b/soundfx/smb2_enter_door.wav differ diff --git a/soundfx/smb2_shrink.wav b/soundfx/smb2_shrink.wav new file mode 100644 index 0000000..a4fd036 Binary files /dev/null and b/soundfx/smb2_shrink.wav differ diff --git a/soundfx/smb2_throw.wav b/soundfx/smb2_throw.wav new file mode 100644 index 0000000..8940972 Binary files /dev/null and b/soundfx/smb2_throw.wav differ diff --git a/soundfx/smb_1-up.wav b/soundfx/smb_1-up.wav new file mode 100644 index 0000000..1449c27 Binary files /dev/null and b/soundfx/smb_1-up.wav differ diff --git a/soundfx/smb_breakblock.wav b/soundfx/smb_breakblock.wav new file mode 100644 index 0000000..0c7b3c9 Binary files /dev/null and b/soundfx/smb_breakblock.wav differ diff --git a/soundfx/smb_bump.wav b/soundfx/smb_bump.wav new file mode 100644 index 0000000..246bcee Binary files /dev/null and b/soundfx/smb_bump.wav differ diff --git a/soundfx/smb_coin.wav b/soundfx/smb_coin.wav new file mode 100644 index 0000000..fef4191 Binary files /dev/null and b/soundfx/smb_coin.wav differ diff --git a/soundfx/smb_fireball.wav b/soundfx/smb_fireball.wav new file mode 100644 index 0000000..56ed57f Binary files /dev/null and b/soundfx/smb_fireball.wav differ diff --git a/soundfx/smb_fireworks.wav b/soundfx/smb_fireworks.wav new file mode 100644 index 0000000..04cd53c Binary files /dev/null and b/soundfx/smb_fireworks.wav differ diff --git a/soundfx/smb_flagpole.wav b/soundfx/smb_flagpole.wav new file mode 100644 index 0000000..10640ac Binary files /dev/null and b/soundfx/smb_flagpole.wav differ diff --git a/soundfx/smb_jump-small.wav b/soundfx/smb_jump-small.wav new file mode 100644 index 0000000..6883fda Binary files /dev/null and b/soundfx/smb_jump-small.wav differ diff --git a/soundfx/smb_jump-super.wav b/soundfx/smb_jump-super.wav new file mode 100644 index 0000000..0d94118 Binary files /dev/null and b/soundfx/smb_jump-super.wav differ diff --git a/soundfx/smb_kick.wav b/soundfx/smb_kick.wav new file mode 100644 index 0000000..14cc827 Binary files /dev/null and b/soundfx/smb_kick.wav differ diff --git a/soundfx/smb_mariodie.wav b/soundfx/smb_mariodie.wav new file mode 100644 index 0000000..bd3400f Binary files /dev/null and b/soundfx/smb_mariodie.wav differ diff --git a/soundfx/smb_pause.wav b/soundfx/smb_pause.wav new file mode 100644 index 0000000..0074f74 Binary files /dev/null and b/soundfx/smb_pause.wav differ diff --git a/soundfx/smb_pipe.wav b/soundfx/smb_pipe.wav new file mode 100644 index 0000000..bbeec36 Binary files /dev/null and b/soundfx/smb_pipe.wav differ diff --git a/soundfx/smb_powerup.wav b/soundfx/smb_powerup.wav new file mode 100644 index 0000000..d085783 Binary files /dev/null and b/soundfx/smb_powerup.wav differ diff --git a/soundfx/smb_powerup_appears.wav b/soundfx/smb_powerup_appears.wav new file mode 100644 index 0000000..1ab578d Binary files /dev/null and b/soundfx/smb_powerup_appears.wav differ diff --git a/soundfx/smb_stomp.wav b/soundfx/smb_stomp.wav new file mode 100644 index 0000000..00ac6c9 Binary files /dev/null and b/soundfx/smb_stomp.wav differ diff --git a/soundfx/smb_vine.wav b/soundfx/smb_vine.wav new file mode 100644 index 0000000..acbc1a3 Binary files /dev/null and b/soundfx/smb_vine.wav differ diff --git a/soundfx/smb_warning.wav b/soundfx/smb_warning.wav new file mode 100644 index 0000000..a5ca2c5 Binary files /dev/null and b/soundfx/smb_warning.wav differ diff --git a/stateMachine.py b/stateMachine.py new file mode 100644 index 0000000..36785eb --- /dev/null +++ b/stateMachine.py @@ -0,0 +1,230 @@ +import RPi.GPIO as GPIO +import time, logging +from datetime import datetime +from threading import Lock +from functools import partial +from collections import namedtuple + +from auxilary import CountdownTimer, ConfigFile +from sensors import setupDoorSensor, setupMotionSensor +from notifier import intruderAlert +from listeners import KeypadListener, PipeListener +from blinkenLights import Blinkenlights +from soundLib import SoundLib + +logger = logging.getLogger(__name__) + +class SIGNALS: + ARM = 1 + INSTANT_ARM = 2 + DISARM = 3 + TIMOUT = 4 + TRIGGER = 5 + +class State: + def __init__(self, stateMachine, name, entryCallbacks=[], exitCallbacks=[], blinkLED=True, sound=None): + self.stateMachine = stateMachine + self.name = name + self.entryCallbacks = entryCallbacks + self.exitCallbacks = exitCallbacks + self.blinkLED = blinkLED + + if not sound and name in stateMachine.soundLib.soundEffects: + self.sound = stateMachine.soundLib.soundEffects[name] + else: + self.sound = sound + + def entry(self): + logger.debug('entering ' + self.name) + #~ if self.sound: + #~ self.sound.play() + #~ self.stateMachine.LED.blink = self.blinkLED + #~ self.stateMachine.keypadListener.resetBuffer() + for c in self.entryCallbacks: + c() + + def exit(self): + logger.debug('exiting ' + self.name) + #~ if self.sound: + #~ self.sound.stop() + for c in self.exitCallbacks: + c() + + def next(self, signal): + t = (self, signal) + return self if t not in self.stateMachine.transitionTable else self.stateMachine.transitionTable[t] + + def __str__(self): + return self.name + + def __eq__(self, other): + return self.name == other + + def __hash__(self): + return hash(self.name) + +class StateMachine: + def __init__(self, camera, ttsQueue, sharedState): + self.sharedState = sharedState + self.soundLib = SoundLib(ttsQueue) + self._cfg = ConfigFile('config.yaml') + + def startTimer(t, sound): + self._timer = CountdownTimer(t, partial(self.selectState, SIGNALS.TIMOUT), sound) + + def stopTimer(): + if self._timer.is_alive(): + self._timer.stop() + self._timer = None + + States = namedtuple('States', ['disarmed', 'disarmedCountdown', 'armed', 'armedCountdown', 'triggered']) + + self.states = States( + State( + self, + name = 'disarmed', + blinkLED = False + ), + State( + self, + name='disarmedCountdown', + entryCallbacks = [partial(startTimer, 30, self.soundLib.soundEffects['disarmedCountdown'])], + exitCallbacks = [stopTimer] + ), + State( + self, + name = 'armed' + ), + State( + self, + name = 'armedCountdown', + entryCallbacks = [partial(startTimer, 30, self.soundLib.soundEffects['armedCountdown'])], + exitCallbacks = [stopTimer] + ), + State( + self, + name = 'triggered', + entryCallbacks = [intruderAlert] + ) + ) + + self.currentState = getattr(self.states, self._cfg['state']) + + self.transitionTable = { + (self.states.disarmed, SIGNALS.ARM): self.states.disarmedCountdown, + (self.states.disarmed, SIGNALS.INSTANT_ARM): self.states.armed, + + (self.states.disarmedCountdown, SIGNALS.DISARM): self.states.disarmed, + (self.states.disarmedCountdown, SIGNALS.TIMOUT): self.states.armed, + (self.states.disarmedCountdown, SIGNALS.INSTANT_ARM): self.states.armed, + + (self.states.armed, SIGNALS.DISARM): self.states.disarmed, + (self.states.armed, SIGNALS.TRIGGER): self.states.armedCountdown, + + (self.states.armedCountdown, SIGNALS.DISARM): self.states.disarmed, + (self.states.armedCountdown, SIGNALS.ARM): self.states.armed, + (self.states.armedCountdown, SIGNALS.TIMOUT): self.states.triggered, + + (self.states.triggered, SIGNALS.DISARM): self.states.disarmed, + (self.states.triggered, SIGNALS.ARM): self.states.armed, + } + + self._lock = Lock() + + #~ self.LED = Blinkenlights(17) + + def action(): + if self.currentState == self.armed: + self.selectState(SIGNALS.TRIGGER) + + def actionVideo(pin): + if self.currentState in (self.armed, self.armedCountdown, self.triggered): + self.selectState(SIGNALS.TRIGGER) + while GPIO.input(pin): + path = '/mnt/glusterfs/pyledriver/images/%s.jpg' + with open(path % datetime.now(), 'wb') as f: + f.write(camera.getFrame()) + time.sleep(0.2) + + #~ setupMotionSensor(5, 'Nate\'s room', action) + #~ setupMotionSensor(19, 'front door', action) + #~ setupMotionSensor(26, 'Laura\'s room', action) + #~ setupMotionSensor(6, 'deck window', partial(actionVideo, 6)) + #~ setupMotionSensor(13, 'kitchen bar', partial(actionVideo, 13)) + + #~ setupDoorSensor(22, action, self.soundLib.soundEffects['door']) + + secretTable = { + "dynamoHum": partial(self.selectState, SIGNALS.DISARM), + "zombyWoof": partial(self.selectState, SIGNALS.ARM), + "imTheSlime": partial(self.selectState, SIGNALS.INSTANT_ARM) + } + + #~ def secretCallback(secret, logger): + #~ if secret in secretTable: + #~ secretTable[secret]() + #~ logger.debug('Secret pipe listener received: \"%s\"', secret) + #~ elif logger: + #~ logger.error('Secret pipe listener received invalid secret') + + #~ self.secretListener = PipeListener( + #~ callback = secretCallback, + #~ path = '/tmp/secret' + #~ ) + + #~ def ttsCallback(text, logger): + #~ self.soundLib.speak(text) + #~ logger.debug('TTS pipe listener received text: \"%s\"', text) + + #~ self.ttsListener = PipeListener( + #~ callback = ttsCallback, + #~ path = '/tmp/tts' + #~ ) + #~ self.keypadListener = KeypadListener( + #~ stateMachine = self, + #~ callbackDisarm = partial(self.selectState, 'disarm'), + #~ callbackArm = partial(self.selectState, 'arm'), + #~ soundLib = self.soundLib, + #~ passwd = '5918462' + #~ ) + + self.sharedState['name'] = self.currentState.name + self.currentState.entry() + + def selectState(self, signal): + self._lock.acquire() # make state transitions threadsafe + try: + nextState = self.currentState.next(signal) + if nextState != self.currentState: + self.currentState.exit() + self.currentState = nextState + self.currentState.entry() + finally: + self._lock.release() + self.sharedState['name'] = self.currentState.name + + self._cfg['state'] = self.currentState.name + self._cfg.sync() + + logger.info('state changed to %s', self.currentState) + + def __del__(self): + try: + self.LED.__del__() + except AttributeError: + pass + + try: + self.soundLib.__del__() + except AttributeError: + pass + + try: + self.pipeListener.__del__() + except AttributeError: + pass + + try: + self.keypadListener.__del__() + except AttributeError: + pass diff --git a/static/index.css b/static/index.css new file mode 100644 index 0000000..2af273a --- /dev/null +++ b/static/index.css @@ -0,0 +1,49 @@ +html, body { + margin: 0px; + min-height: 100%; + font-family: DejaVu-Sans; +} + +.titlebar { + color: #eee; + font-size: 32px; + text-align: center; + padding: 10px; + margin-bottom: 12px; + background: #2b2b2b; +} + +#state, #FPSValue { + color: #7196D9; +} + +.camera_container { + margin: auto; + display: block; + width: 960px; +} + +.camera { + float: left; + width: 640px; +} + +.controls { + color: #111; + height: 418px; + width: 300px; + padding: 30px; + border: 1px solid #dcdcdc; + margin-left: 660px; + background: #f7f7f7; +} + +.controls form { + width: 80%; + margin: auto; + padding: 0px 30px 10px 30px; +} + +.controls form:not(:first-child) { + border-top: 2px solid #888; +} diff --git a/stream.py b/stream.py new file mode 100755 index 0000000..bea87a4 --- /dev/null +++ b/stream.py @@ -0,0 +1,76 @@ +#! /bin/python + +from auxilary import async + +import gi, time +gi.require_version('Gst', '1.0') + +from gi.repository import Gst + +Gst.init(None) + +pipe = Gst.Pipeline.new("streamer") +bus = pipe.get_bus() + +vidSrc = Gst.ElementFactory.make("v4l2src", "vidSrc") +vidConv = Gst.ElementFactory.make("videoconvert", "vidConv") +vidScale = Gst.ElementFactory.make("videoscale", "vidScale") +vidClock = Gst.ElementFactory.make("clockoverlay", "vidClock") +vidEncode = Gst.ElementFactory.make("omxh264enc", "vidEncode") +vidParse = Gst.ElementFactory.make("h264parse", "vidParse") +mux = Gst.ElementFactory.make("mp4mux", "mux") +#~ sink = Gst.ElementFactory.make("tcpserversink", "sink") +sink = Gst.ElementFactory.make("filesink", "sink") + +vidSrc.set_property('device', '/dev/video0') +#~ sink.set_property('host', '0.0.0.0') +#~ sink.set_property('port', 8080) +sink.set_property('location', '/home/alarm/testicle.mp4') + +vidRawCaps = Gst.Caps.from_string('video/x-raw,width=320,height=240,framerate=30/1') +parseCaps = Gst.Caps.from_string('video/x-h264,stream-format=avc') + +pipe.add(vidSrc, vidConv, vidScale, vidClock, vidEncode, vidParse, mux, sink) + +print(vidSrc.link(vidConv)) +print(vidConv.link(vidScale)) +print(vidScale.link_filtered(vidClock, vidRawCaps)) +print(vidClock.link(vidEncode)) +print(vidEncode.link(vidParse)) +print(vidParse.link_filtered(mux, parseCaps)) +print(mux.link(sink)) + +pipe.set_state(Gst.State.PLAYING) + +#~ signal.signal(signal.SIGTERM, exit()) + +def terminate(): + pipe.set_state(Gst.State.NULL) + exit() + +@async(daemon=True) +def errorHandler(): + while 1: + msg = bus.timed_pop_filtered(1e18, Gst.MessageType.ERROR) + print('howdy') + print(msg.parse_error()) + terminate() + +@async(daemon=True) +def eosHandler(): + while 1: + msg = bus.timed_pop_filtered(1e18, Gst.MessageType.EOS) + print('EOS reached') + terminate() + +try: + errorHandler() + eosHandler() + + while 1: + time.sleep(3600) + +except KeyboardInterrupt: + pass + + diff --git a/templates/framerate.html b/templates/framerate.html new file mode 100644 index 0000000..3ae0703 --- /dev/null +++ b/templates/framerate.html @@ -0,0 +1,8 @@ + + + Camera 1 + + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7867e31 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,35 @@ + + + + Camera 1 + + +
+ Pyledriver Status: {{ state }} +
+
+
+ +
+
+
+ {{ camForm.hidden_tag() }} +

Current Framerate: {{ fps }}

+

Set Framerate {{ camForm.fps }}

+

{{ camForm.submitFPS }}

+
+
+ {{ ttsForm.hidden_tag() }} +

Speak your mind

+

{{ ttsForm.tts }}

+

{{ ttsForm.submitTTS }}

+
+
+ {{ ttsForm.hidden_tag() }} +

If something goes wrong...

+

{{ resetForm.submitReset }}

+
+
+
+ + diff --git a/webInterface.py b/webInterface.py new file mode 100644 index 0000000..1c3d03f --- /dev/null +++ b/webInterface.py @@ -0,0 +1,84 @@ +from multiprocessing import Process +from sharedLogging import SlaveLogger +from flask import Flask, render_template, Response, Blueprint, redirect, url_for +from flask_wtf import FlaskForm +from wtforms.fields import SelectField, StringField, SubmitField +from wtforms.validators import InputRequired + +class CamForm(FlaskForm): + fps = SelectField(choices=[(i, '%s fps' % i) for i in range(10, 31, 5)], coerce=int) + submitFPS = SubmitField('Set') + +class TTSForm(FlaskForm): + tts = StringField(validators=[InputRequired()]) + submitTTS = SubmitField('Speak') + +class ResetForm(FlaskForm): + submitReset = SubmitField('Reset') + +# TODO: fix random connection fails (might be an nginx thing) +# TODO: show camera failed status here somewhere + +class WebInterface(Process): + def __init__(self, camera, stateDict, ttsQueue, loggerQueue): + self._moduleLogger = SlaveLogger(__name__, 'INFO', loggerQueue) + self._flaskLogger = SlaveLogger('werkzeug', 'ERROR', loggerQueue) + + camPage = Blueprint('camPage', __name__, static_folder='static', template_folder='templates') + + def generateFrame(): + while 1: + yield (b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' + camera.getFrame() + b'\r\n') + + @camPage.route('/', methods=['GET', 'POST']) + @camPage.route('/index', methods=['GET', 'POST']) + def index(): + props = camera.getProps('FPS') + fps = int(props['FPS']) + camForm = CamForm(fps = props['FPS']) + ttsForm = TTSForm() + resetForm = ResetForm() + + if camForm.validate_on_submit() and camForm.submitFPS.data: + camera.setProps(FPS=camForm.fps.data) + return redirect(url_for('camPage.index')) + + if ttsForm.validate_on_submit() and ttsForm.submitTTS.data: + ttsQueue.put_nowait(ttsForm.tts.data) + return redirect(url_for('camPage.index')) + + if resetForm.validate_on_submit() and resetForm.submitReset.data: + camera.reset() + return redirect(url_for('camPage.index')) + + return render_template( + 'index.html', + camForm=camForm, + ttsForm=ttsForm, + resetForm=resetForm, + fps=fps, + state=stateDict['name'] + ) + + @camPage.route('/videoFeed') + def videoFeed(): + return Response(generateFrame(), mimetype='multipart/x-mixed-replace; boundary=frame') + + self._app = Flask(__name__) + self._app.secret_key = '3276d68dac56985bea352325125641ff' + self._app.register_blueprint(camPage, url_prefix='/pyledriver') + + super().__init__(daemon=True) + + def run(self): + # TODO: not sure exactly how threaded=True works, intended to enable + # multiple connections. May want to use something more robust w/ camera + # see here: https://blog.miguelgrinberg.com/post/video-streaming-with-flask + + self._moduleLogger.info('Started web interface') + self._app.run(debug=False, threaded=True) + + def stop(self): + self.terminate() + self.join() + self._moduleLogger.info('Terminated web interface')