370 lines
12 KiB
Python
370 lines
12 KiB
Python
'''
|
|
Controls and manages state of the alarm system. The design follows a reactor-
|
|
like model (similar to twisted) except the statemachine itself is passive (eg it
|
|
does not run/block on its own thread waiting for events). Instead the
|
|
StateMachine object holds the current status as well as the logic to move
|
|
between states, while child threads (listeners) deliver signals to the state
|
|
machine in response to external events. The state machine itself does not "wait"
|
|
but rather its selectState method could be called within any child thread (hence
|
|
the lock) which also means the entry/exit functions of each state are run within
|
|
the signal-originating child thread.
|
|
'''
|
|
import RPi.GPIO as GPIO
|
|
import time, logging, enum, os
|
|
from threading import Lock, Event
|
|
from functools import partial
|
|
from collections import namedtuple
|
|
|
|
from exceptionThreading import ExceptionThread
|
|
from config import stateFile
|
|
from sensors import startDoorSensor, startMotionSensor
|
|
from gmail import intruderAlert
|
|
from listeners import KeypadListener, PipeListener
|
|
from blinkenLights import Blinkenlights
|
|
from soundLib import SoundLib
|
|
from webInterface import startWebInterface
|
|
from stream import Camera, FileDump
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class _SIGNALS(enum.Enum):
|
|
'''
|
|
Valid signals the statemachine can recieve. The consequence of each signal
|
|
depends on the current state
|
|
'''
|
|
ARM = enum.auto()
|
|
INSTANT_ARM = enum.auto()
|
|
LOCK = enum.auto()
|
|
INSTANT_LOCK = enum.auto()
|
|
DISARM = enum.auto()
|
|
TIMOUT = enum.auto()
|
|
TRIP = enum.auto()
|
|
|
|
class _CountdownTimer(ExceptionThread):
|
|
'''
|
|
Launches thread which self terminates after some time (given in seconds).
|
|
Termination triggers some action (a function). Optionally, a sound can be
|
|
assigned to each 'tick'
|
|
'''
|
|
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()
|
|
|
|
def _resetUSBDevice(device):
|
|
'''
|
|
Resets a USB device using the de/reauthorization method. This is really
|
|
crude but works beautifully
|
|
'''
|
|
devpath = os.path.join('/sys/bus/usb/devices/' + device + '/authorized')
|
|
with open(devpath, 'w') as f:
|
|
f.write('0')
|
|
with open(devpath, 'w') as f:
|
|
f.write('1')
|
|
logger.debug('Reset USB device: %s', devpath)
|
|
|
|
class _State:
|
|
'''
|
|
Represents one discrete status of the system. Each state has a set of entry
|
|
and exit functions and optionaly has sound that can play upon state entry.
|
|
States link to other states via the addTransition function, which links
|
|
another state with a signal. In this way, many states can be linked together
|
|
in a network.
|
|
|
|
There is currently nothing stopping multiple states from sharing the same
|
|
name...try not to be an idiot. This mostly matters in equality tests, which
|
|
only compares the name
|
|
'''
|
|
def __init__(self, name, entryCallbacks=[], exitCallbacks=[], sound=None):
|
|
self.name = name
|
|
self.entryCallbacks = entryCallbacks
|
|
self.exitCallbacks = exitCallbacks
|
|
self._transTbl = {}
|
|
|
|
self._sound = sound
|
|
|
|
def entry(self):
|
|
logger.info('entering ' + self.name)
|
|
if self._sound:
|
|
self._sound.play()
|
|
for c in self.entryCallbacks:
|
|
c()
|
|
|
|
def exit(self):
|
|
logger.info('exiting ' + self.name)
|
|
if self._sound:
|
|
self._sound.stop()
|
|
for c in self.exitCallbacks:
|
|
c()
|
|
|
|
def next(self, signal):
|
|
if signal in _SIGNALS:
|
|
return self if signal not in self._transTbl else self._transTbl[signal]
|
|
else:
|
|
raise Exception('Illegal signal')
|
|
|
|
def addTransition(self, signal, state):
|
|
self._transTbl[signal] = state
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def __eq__(self, other):
|
|
return self.name == other
|
|
|
|
class StateMachine:
|
|
'''
|
|
Manager for states. This is intended to be used as a context manager (eg
|
|
"with" statement) for brevity...and because there should only be one.
|
|
|
|
Init is responsible for setting up all objects (including child threads) as
|
|
well as contructing the state network. Each thread functions as some kind of
|
|
listener (or supports one) that wait for an event to happen.
|
|
|
|
Note we distinguish between "managed" and "non-managed" objects; the former
|
|
need to be started/stopped to ensure things get cleaned. Managed objects are
|
|
added to the managed list with _addManaged, which also returns a ref to the
|
|
object for other uses.
|
|
|
|
Upon opening the context, all threads are started. Note that not all threads
|
|
are managed; some are started and forgotten, as these require no cleanup.
|
|
Managed threads are started with _startManaged and stopped with _stopManaged
|
|
upon closing the context. This system has the added benefit of not trying to
|
|
stop an object that has not been initialized, as it cannot appear in the
|
|
managed list otherwise
|
|
|
|
During steady-state operation, the receiver for signals that make things
|
|
happen is selectState, intended to be called from any of the state machine's
|
|
child threads. This calls the current state's "next" method and sets the
|
|
result as the new current state. Note the lock because it could be called by
|
|
any number of listeners simultaneously
|
|
'''
|
|
def __init__(self):
|
|
self._lock = Lock()
|
|
self._managed = []
|
|
|
|
self.soundLib = self._addManaged(SoundLib())
|
|
self.fileDump = self._addManaged(FileDump())
|
|
|
|
self._addManaged(Camera())
|
|
|
|
# add signals to self to avoid calling partial every time
|
|
for sig in _SIGNALS:
|
|
setattr(self, sig.name, partial(self.selectState, sig))
|
|
|
|
secretTable = {
|
|
'dynamoHum': self.DISARM,
|
|
'zombyWoof': self.ARM,
|
|
'imTheSlime': self.INSTANT_ARM,
|
|
'fiftyFifty': self.LOCK,
|
|
'dentalFloss': self.INSTANT_LOCK
|
|
}
|
|
|
|
def secretCallback(secret, logger):
|
|
if secret in secretTable:
|
|
secretTable[secret]()
|
|
logger.debug('Secret pipe listener received: \"%s\"', secret)
|
|
elif logger:
|
|
logger.debug('Secret pipe listener received invalid secret')
|
|
|
|
self._addManaged(PipeListener(callback=secretCallback, name='secret'))
|
|
|
|
self._addManaged(KeypadListener(stateMachine=self, passwd='5918462'))
|
|
|
|
def startTimer(t, sound):
|
|
self._timer = _CountdownTimer(t, self.TIMOUT, sound)
|
|
|
|
def stopTimer():
|
|
if self._timer.is_alive():
|
|
self._timer.stop()
|
|
self._timer = None
|
|
|
|
sfx = self.soundLib.soundEffects
|
|
|
|
LED = self._addManaged(Blinkenlights(17))
|
|
|
|
def squareBlink(t):
|
|
LED.setBlink(True)
|
|
LED.setLinear(False)
|
|
LED.setCyclePeriod(t)
|
|
|
|
def linearBlink(t):
|
|
LED.setBlink(True)
|
|
LED.setLinear(True)
|
|
LED.setCyclePeriod(t)
|
|
|
|
stateObjs = [
|
|
_State(
|
|
name = 'disarmed',
|
|
entryCallbacks = [partial(LED.setBlink, False)],
|
|
sound = sfx['disarmed']
|
|
),
|
|
_State(
|
|
name = 'armedCountdown',
|
|
entryCallbacks = [partial(squareBlink, 1), partial(startTimer, 30, sfx['armedCountdown'])],
|
|
exitCallbacks = [stopTimer],
|
|
sound = sfx['armedCountdown']
|
|
),
|
|
_State(
|
|
name = 'armed',
|
|
entryCallbacks = [partial(linearBlink, 1)],
|
|
sound = sfx['armed']
|
|
),
|
|
_State(
|
|
name = 'lockedCountdown',
|
|
entryCallbacks = [partial(squareBlink, 1), partial(startTimer, 30, sfx['lockedCountdown'])],
|
|
exitCallbacks = [stopTimer],
|
|
sound = sfx['lockedCountdown']
|
|
),
|
|
_State(
|
|
name = 'locked',
|
|
entryCallbacks = [partial(squareBlink, 2)],
|
|
sound = sfx['locked']
|
|
),
|
|
_State(
|
|
name = 'trippedCountdown',
|
|
entryCallbacks = [partial(squareBlink, 1), partial(startTimer, 30, sfx['trippedCountdown'])],
|
|
exitCallbacks = [stopTimer],
|
|
sound = sfx['trippedCountdown']
|
|
),
|
|
_State(
|
|
name = 'tripped',
|
|
entryCallbacks = [partial(linearBlink, 0.5), intruderAlert],
|
|
sound = sfx['tripped']
|
|
)
|
|
]
|
|
|
|
self.states = st = namedtuple('States', [obj.name for obj in stateObjs])(*stateObjs)
|
|
|
|
st.disarmed.addTransition( _SIGNALS.ARM, st.armedCountdown)
|
|
st.disarmed.addTransition( _SIGNALS.INSTANT_ARM, st.armed)
|
|
st.disarmed.addTransition( _SIGNALS.LOCK, st.lockedCountdown)
|
|
st.disarmed.addTransition( _SIGNALS.INSTANT_LOCK, st.locked)
|
|
|
|
st.armedCountdown.addTransition( _SIGNALS.DISARM, st.disarmed)
|
|
st.armedCountdown.addTransition( _SIGNALS.TIMOUT, st.armed)
|
|
st.armedCountdown.addTransition( _SIGNALS.INSTANT_ARM, st.armed)
|
|
st.armedCountdown.addTransition( _SIGNALS.LOCK, st.lockedCountdown)
|
|
st.armedCountdown.addTransition( _SIGNALS.INSTANT_LOCK, st.locked)
|
|
|
|
st.armed.addTransition( _SIGNALS.DISARM, st.disarmed)
|
|
st.armed.addTransition( _SIGNALS.TRIP, st.trippedCountdown)
|
|
st.armed.addTransition( _SIGNALS.LOCK, st.lockedCountdown)
|
|
st.armed.addTransition( _SIGNALS.INSTANT_LOCK, st.locked)
|
|
|
|
st.lockedCountdown.addTransition( _SIGNALS.DISARM, st.disarmed)
|
|
st.lockedCountdown.addTransition( _SIGNALS.TIMOUT, st.locked)
|
|
st.lockedCountdown.addTransition( _SIGNALS.INSTANT_LOCK, st.locked)
|
|
st.lockedCountdown.addTransition( _SIGNALS.ARM, st.armedCountdown)
|
|
st.lockedCountdown.addTransition( _SIGNALS.INSTANT_ARM, st.armed)
|
|
|
|
st.locked.addTransition( _SIGNALS.DISARM, st.disarmed)
|
|
st.locked.addTransition( _SIGNALS.TRIP, st.trippedCountdown)
|
|
st.locked.addTransition( _SIGNALS.ARM, st.armedCountdown)
|
|
st.locked.addTransition( _SIGNALS.INSTANT_ARM, st.armed)
|
|
|
|
st.trippedCountdown.addTransition( _SIGNALS.DISARM, st.disarmed)
|
|
st.trippedCountdown.addTransition( _SIGNALS.TIMOUT, st.tripped)
|
|
st.trippedCountdown.addTransition( _SIGNALS.ARM, st.armed)
|
|
st.trippedCountdown.addTransition( _SIGNALS.INSTANT_ARM, st.armed)
|
|
st.trippedCountdown.addTransition( _SIGNALS.LOCK, st.locked)
|
|
st.trippedCountdown.addTransition( _SIGNALS.INSTANT_LOCK, st.locked)
|
|
|
|
st.tripped.addTransition( _SIGNALS.DISARM, st.disarmed)
|
|
st.tripped.addTransition( _SIGNALS.ARM, st.armed)
|
|
st.tripped.addTransition( _SIGNALS.INSTANT_ARM, st.armed)
|
|
st.tripped.addTransition( _SIGNALS.LOCK, st.locked)
|
|
st.tripped.addTransition( _SIGNALS.INSTANT_LOCK, st.locked)
|
|
|
|
self.currentState = getattr(self.states, stateFile['state'])
|
|
|
|
def __enter__(self):
|
|
_resetUSBDevice('1-1')
|
|
|
|
# start all managed threads (we retain ref to these to stop them later)
|
|
self._startManaged()
|
|
|
|
activeSensorStates = (self.states.armed, self.states.trippedCountdown, self.states.tripped)
|
|
|
|
def sensorAction(location, logger):
|
|
cst = self.currentState
|
|
level = logging.INFO if cst in activeSensorStates else logging.DEBUG
|
|
logger.log(level, 'detected motion: ' + location)
|
|
if cst == self.states.armed:
|
|
self.selectState(_SIGNALS.TRIP)
|
|
|
|
def videoAction(location, logger, pin):
|
|
sensorAction(location, logger)
|
|
cst = self.currentState
|
|
if cst in activeSensorStates:
|
|
self.fileDump.addInitiator(pin)
|
|
while GPIO.input(pin) and cst in activeSensorStates:
|
|
time.sleep(0.1)
|
|
self.fileDump.removeInitiator(pin)
|
|
|
|
activeDoorStates = activeSensorStates + (self.states.locked,)
|
|
|
|
def doorAction(closed, logger):
|
|
self.soundLib.soundEffects['door'].play()
|
|
cst = self.currentState
|
|
level = logging.INFO if cst in activeDoorStates else logging.DEBUG
|
|
entry = 'door closed' if closed else 'door opened'
|
|
logger.log(level, entry)
|
|
if not closed and cst == self.states.armed or cst == self.states.locked:
|
|
self.selectState(_SIGNALS.TRIP)
|
|
|
|
# start non-managed threads (we forget about these because they can exit with no cleanup)
|
|
startMotionSensor(5, 'Nate\'s room', sensorAction)
|
|
startMotionSensor(19, 'front door', sensorAction)
|
|
startMotionSensor(26, 'Laura\'s room', sensorAction)
|
|
|
|
startMotionSensor(6, 'deck window', partial(videoAction, pin=6))
|
|
startMotionSensor(13, 'kitchen bar', partial(videoAction, pin=13))
|
|
|
|
startDoorSensor(22, doorAction)
|
|
|
|
startWebInterface(self)
|
|
|
|
self.currentState.entry()
|
|
|
|
def __exit__(self, exception_type, exception_value, traceback):
|
|
self._stopManaged()
|
|
|
|
def selectState(self, signal):
|
|
with self._lock:
|
|
nextState = self.currentState.next(signal)
|
|
if nextState != self.currentState:
|
|
self.currentState.exit()
|
|
self.currentState = nextState
|
|
self.currentState.entry()
|
|
|
|
stateFile['state'] = self.currentState.name
|
|
|
|
def _addManaged(self, obj):
|
|
self._managed.append(obj)
|
|
return obj
|
|
|
|
def _startManaged(self):
|
|
for m in self._managed:
|
|
m.start()
|
|
|
|
def _stopManaged(self):
|
|
for m in self._managed:
|
|
m.stop()
|