From: mar-v-in Date: Wed, 23 Oct 2013 20:03:27 +0000 (+0200) Subject: Merge branch 'master' of ralfj.de:saartuer X-Git-Url: https://git.ralfj.de/saartuer.git/commitdiff_plain/9a0250c32f71db043dc1690be985d92459a3dbff?hp=802734827389d1d939305e36f30b3b4280c94218 Merge branch 'master' of ralfj.de:saartuer --- diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1bdb074 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +files = *.py tuerd tryshell tyshell +target = /opt/tuer/ + +all: install +.PHONY: install + +install: + cp -v $(files) $(target) diff --git a/actor.py b/actor.py new file mode 100644 index 0000000..f2f1ad2 --- /dev/null +++ b/actor.py @@ -0,0 +1,59 @@ +from libtuer import ThreadFunction, logger +import RPi.GPIO as GPIO +import time + +class Actor: + CMD_BUZZ = 0 + CMD_UNLOCK = 1 + CMD_LOCK = 2 + CMD_GREEN_ON = 3 + CMD_GREEN_OFF = 4 + CMD_RED_ON = 5 + CMD_RED_OFF = 6 + + class CMD(): + def __init__(self, name, pin, tid, todo): + self.name = name + self.pin = pin + self.tid = tid + self.todo = todo + # don't do the GPIO setup here, the main init did not yet run + + def execute(self): + logger.info("Actor: Running command %s" % self.name) + for (value, delay) in self.todo: + if value is not None: + logger.debug("Actor: Setting pin %d to %d" % (self.pin, value)) + GPIO.output(self.pin, value) + if delay > 0: + time.sleep(delay) + + CMDs = { + CMD_UNLOCK: CMD("unlock", pin=12, tid=0, todo=[(True, 0.3), (False, 0.1)]), + CMD_LOCK: CMD("lock", pin=16, tid=0, todo=[(True, 0.3), (False, 0.1)]), + CMD_BUZZ: CMD("buzz", pin=22, tid=1, todo=[(True, 2.5), (False, 0.1)]), + CMD_GREEN_ON: CMD("green on", pin=23, tid=2, todo=[(True, 0)]), + CMD_GREEN_OFF: CMD("green off", pin=23, tid=2, todo=[(False, 0)]), + CMD_RED_ON: CMD("red on", pin=26, tid=2, todo=[(True, 0)]), + CMD_RED_OFF: CMD("red off", pin=26, tid=2, todo=[(False, 0)]), + } + + def __init__(self): + # launch threads, all running the "_execute" method + self.threads = {} + for cmd in Actor.CMDs.values(): + GPIO.setup(cmd.pin, GPIO.OUT) + GPIO.output(cmd.pin, False) + if not cmd.tid in self.threads: + self.threads[cmd.tid] = ThreadFunction(self._execute, "Actor TID %d" % cmd.tid) + + def _execute(self, cmd): + Actor.CMDs[cmd].execute() + + def act(self, cmd): + # dispatch command to correct thread + self.threads[Actor.CMDs[cmd].tid](cmd) + + def stop(self): + for thread in self.threads.values(): + thread.stop() diff --git a/libtuer.py b/libtuer.py index 0df4051..1789b90 100644 --- a/libtuer.py +++ b/libtuer.py @@ -1,31 +1,81 @@ -import logging, logging.handlers, syslog, os +import logging, logging.handlers, os, time, queue, threading, subprocess +import traceback, smtplib +import email.mime.text, email.utils + +# Logging configuration +syslogLevel = logging.INFO +mailLevel = logging.CRITICAL # must be "larger" than syslog level! +mailAddress = ['post+tuer'+'@'+'ralfj.de', 'vorstand@lists.hacksaar.de'] +printLevel = logging.DEBUG + +# Mail logging handler +def sendeMail(subject, text, receivers, sender='sphinx@hacksaar.de', replyTo=None): + assert isinstance(receivers, list) + if not len(receivers): return # nothing to do + # construct content + msg = email.mime.text.MIMEText(text.encode('UTF-8'), 'plain', 'UTF-8') + msg['Subject'] = subject + msg['Date'] = email.utils.formatdate(localtime=True) + msg['From'] = sender + msg['To'] = ', '.join(receivers) + if replyTo is not None: + msg['Reply-To'] = replyTo + # put into envelope and send + s = smtplib.SMTP('localhost') + s.sendmail(sender, receivers, msg.as_string()) + s.quit() # logging function class Logger: def __init__ (self): - import __main__ as main - self.name = os.path.basename(main.__file__) - self.logger = logging.getLogger(self.name) - self.logger.setLevel(logging.INFO) - self.handler = logging.handlers.SysLogHandler(address = '/dev/log', facility = logging.handlers.SysLogHandler.LOG_LOCAL0) - self.logger.addHandler(self.handler) - def log (self, what): - thestr = "%s[%d]: %s" % (self.name,os.getpid(),what) - print (thestr) - self.logger.info(thestr) + self.syslog = logging.getLogger("tuerd") + self.syslog.setLevel(logging.DEBUG) + self.syslog.addHandler(logging.handlers.SysLogHandler(address = '/dev/log', + facility = logging.handlers.SysLogHandler.LOG_LOCAL0)) + + def _log (self, lvl, what): + thestr = "%s[%d]: %s" % ("tuerd", os.getpid(), what) + # console log + if lvl >= printLevel: + print(thestr) + # syslog + if lvl >= syslogLevel: + self.syslog.log(lvl, thestr) + # mail log + if lvl >= mailLevel and mailAddress is not None: + sendeMail('Kritischer Türfehler', what, mailAddress) + + def debug(self, what): + self._log(logging.DEBUG, what) + def info(self, what): + self._log(logging.INFO, what) + def warning(self, what): + self._log(logging.WARNING, what) + def error(self, what): + self._log(logging.ERROR, what) + def critical(self, what): + self._log(logging.CRITICAL, what) logger = Logger() -def log (what): - logger.log(what) - +# run a command asynchronously and log the return value if not 0 +# prefix must be a string identifying the code position where the call came from +def fire_and_forget (cmd, log, prefix): + def _fire_and_forget (): + with open("/dev/null", "w") as fnull: + retcode = subprocess.call(cmd, stdout=fnull, stderr=fnull) + if retcode is not 0: + logger.error("%sReturn code %d at command: %s" % (prefix,retcode,str(cmd))) + t = threading.Thread(target=_fire_and_forget) + t.start() # Threaded callback class class ThreadFunction(): _CALL = 0 _TERM = 1 - def __init__(self, f): + def __init__(self, f, name): + self.name = name self._f = f self._q = queue.Queue() self._t = threading.Thread(target=self._thread_func) @@ -35,17 +85,46 @@ class ThreadFunction(): while True: (cmd, data) = self._q.get() # run command - if cmd == _CALL: - self._f(*data) - elif cmd == _TERM: + if cmd == ThreadFunction._CALL: + try: + self._f(*data) + except Exception as e: + logger.critical("ThreadFunction: Got exception out of handler thread %s: %s" % (self.name, str(e))) + logger.debug(traceback.format_exc()) + elif cmd == ThreadFunction._TERM: assert data is None break else: - raise NotImplementedError("Command %d does not exist" % cmd) + logger.error("ThreadFunction: Command %d does not exist" % cmd) def __call__(self, *arg): - self._q.put((self._CALL, arg)) + self._q.put((ThreadFunction._CALL, arg)) + + def stop(self): + self._q.put((ThreadFunction._TERM, None)) + self._t.join() + +# Thread timer-repeater class: Call a function every seconds +class ThreadRepeater(): + def __init__(self, f, sleep_time, name): + self.name = name + self._f = f + self._stop = False + self._sleep_time = sleep_time + self._t = threading.Thread(target=self._thread_func) + self._t.start() + + def _thread_func(self): + while True: + if self._stop: + break + try: + self._f() + except Exception as e: + logger.critical("ThreadRepeater: Got exception out of handler thread %s: %s" % (self.name, str(e))) + logger.debug(traceback.format_exc()) + time.sleep(self._sleep_time) def stop(self): - self._q.put((_TERM, None)) + self._stop = True self._t.join() diff --git a/pins.py b/pins.py new file mode 100644 index 0000000..88a17c2 --- /dev/null +++ b/pins.py @@ -0,0 +1,72 @@ +import RPi.GPIO as GPIO +from collections import namedtuple +from libtuer import ThreadRepeater, logger +from statemachine import StateMachine + +class PinsState(): + pass + +class PinWatcher(): + def __init__(self, pin, histlen): + GPIO.setup(pin, GPIO.IN) + assert histlen > 1 # otherwise our logic goes nuts... + self.pin = pin + self._histlen = histlen + # state change detection + self.state = None + self._newstate = None # != None iff we are currently seeing a state change + self._newstatelen = 0 # only valid if newstate != None + + def read(self): + curstate = GPIO.input(self.pin) + assert curstate in (0, 1) + if curstate != self.state: + # the state is about to change + if curstate == self._newstate: + # we already saw this new state + self._newstatelen += 1 + if self._newstatelen >= self._histlen: + # we saw it often enough to declare it the new state + self.state = curstate + self._newstate = None + return True + else: + # now check for how long we see this new state + self._newstate = curstate + self._newstatelen = 1 + else: + # old state is preserved + self._newstate = None + return False + +class PinsWatcher(): + def __init__(self, state_machine): + self._pins = { + 'bell_ringing': PinWatcher(18, 2), + 'door_closed': PinWatcher(8, 4), + 'door_locked': PinWatcher(10, 4), + 'space_active': PinWatcher(24, 4), + } + self._sm = state_machine + + # start a thread doing the work + self._t = ThreadRepeater(self._read, 0.02, name="PinsWatcher") + + def _read(self): + saw_change = False + for name in self._pins.keys(): + pin = self._pins[name] + if pin.read(): + saw_change = True + logger.debug("Pin %s changed to %d" % (name, pin.state)) + if not saw_change: + return None + # create return object + pinsState = PinsState() + for name in self._pins.keys(): + setattr(pinsState, name, self._pins[name].state) + # send it to state machine + self._sm.callback(StateMachine.CMD_PINS, pinsState) + + def stop(self): + self._t.stop() diff --git a/ringd b/ringd deleted file mode 100755 index b6a27f9..0000000 --- a/ringd +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/python3 -import time, socket, atexit -import queue, threading, select -from libtuer import log, ThreadFunction -import RPi.GPIO as GPIO -GPIO.setmode(GPIO.BOARD) -atexit.register(GPIO.cleanup) - -tuerSock = "/run/tuer.sock" -ringPin = 18 - - -# Main classes -class PinWatcher(): - def __init__(self, pin, histlen): - GPIO.setup(pin, GPIO.IN) - assert histlen > 1 # otherwise our logic goes nuts... - self._pin = pin - self._histlen = histlen - # state change detection - self._state = None - self._newstate = None # != None iff we are currently seeing a state change - self._newstatelen = 0 # only valid if newstate != None - # start state change handler thread - self._callback = ThreadFunction(self.callback) - self.stop = self._callback.stop - - def read(self): - curstate = GPIO.input(self._pin) - assert curstate in (0, 1) - if curstate != self._state: - # the state is about to change - if curstate == self._newstate: - # we already saw this new state - self._newstatelen += 1 - if self._newstatelen >= self._histlen: - self._callback(self._state, curstate) # send stuff to the other thread - self._state = curstate - self._newstate = None - else: - # now check for how long we see this new state - self._newstate = curstate - self._newstatelen = 1 - else: - # old state is preserved - self._newstate = None - -class RingWatcher(PinWatcher): - def __init__(self): - super().__init__(ringPin, 2) - self.last1Event = None - - def callback(self, oldstate, newstate): - if oldstate is None: - return # ignore the very first state change - # now (oldstate, newstate) is either (0, 1) or (1, 0) - if newstate: - self.last1Event = time.time() - elif self.last1Event is not None: - # how long was this pressed? - timePressed = time.time() - self.last1Event - log("Ring button pressed for",timePressed) - if timePressed >= 1.5 and timePressed <= 3: - self.buzz() - - def buzz(self): - log("Opening door") - # talk with tuerd - s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - s.connect(tuerSock) - s.send(b'buzz') - s.close() - -# MAIN PROGRAM -pins = [ - RingWatcher(), -] - -try: - log("entering loop") - while True: - for pin in pins: - pin.read() - time.sleep(0.02) -except KeyboardInterrupt: - for pin in pins: - pin.stop() diff --git a/statemachine.jpg b/statemachine.jpg new file mode 100644 index 0000000..4643df3 Binary files /dev/null and b/statemachine.jpg differ diff --git a/statemachine.odg b/statemachine.odg new file mode 100644 index 0000000..d32026c Binary files /dev/null and b/statemachine.odg differ diff --git a/statemachine.py b/statemachine.py new file mode 100644 index 0000000..ca58e1d --- /dev/null +++ b/statemachine.py @@ -0,0 +1,277 @@ +from libtuer import ThreadFunction, logger, fire_and_forget +from actor import Actor +import os, random, time + +# logger.{debug,info,warning,error,critical} + +def play_sound (what): + try: + soundfiles = os.listdir(SOUNDS_DIRECTORY+what) + except FileNotFoundError: + logger.error("StateMachine: Unable to list sound files in %s" % (SOUNDS_DIRECTORY+what)) + return + soundfile = SOUNDS_DIRECTORY + what + '/' + random.choice(soundfiles) + fire_and_forget ([SOUNDS_PLAYER,soundfile], logger.error, "StateMachine: ") + + +# StateUnlocking constants +OPEN_REPEAT_TIMEOUT = 7 +OPEN_REPEAT_NUMBER = 3 + +# StateLocking constants +CLOSE_REPEAT_TIMEOUT = 7 +CLOSE_REPEAT_NUMBER = 3 + +# StateAboutToOpen constants +ABOUTOPEN_NERVLIST = [(5, lambda : play_sound("flipswitch")), (5, lambda:play_sound("flipswitch")), (0, lambda:logger.warning("Space open but switch not flipped for 10 seconds")),\ + (10, lambda:play_sound("flipswitch")), (10, lambda:play_sound("flipswitch")), (0, lambda:logger.error("Space open but switch not flipped for 30 seconds")),\ + (10, lambda:play_sound("flipswitch")), (10, lambda:play_sound("flipswitch")), (6, lambda:play_sound("flipswitch")), (4, lambda:logger.critical("Space open but switch not flipped for 60 seconds")), + (59*60, lambda:logger.critical("Space open but switch not flipped for one hour"))] + +# Timeout we wait after the switch was switched to "Closed", until we assume nobody will open the door and we just lock it +# ALso the time we wait after the door was opend, till we assume something went wrong and start nerving +LEAVE_TIMEOUT = 20 + +# play_sound constants +SOUNDS_DIRECTORY = "/opt/tuer/sounds/" +SOUNDS_PLAYER = "/usr/bin/mplayer" + + +class Nerver(): + # A little utility class used to run the nervlists. A nervlist is a list of (n, f) tuples where f() is run after n seconds. + # If f returns something, that's also returned by nerv. + def __init__(self, nervlist): + self.nervlist = list(nervlist) + self.last_event_time = time.time() + + def nerv(self): + if len(self.nervlist): + (wait_time, f) = self.nervlist[0] + now = time.time() + time_gone = now-self.last_event_time + # check if the first element is to be triggered + if time_gone >= wait_time: + self.nervlist = self.nervlist[1:] # "pop" the first element, but do not modify original list + self.last_event_time = now + return f() + + +class StateMachine(): + # commands you can send + CMD_PINS = 0 + CMD_BUZZ = 1 + CMD_UNLOCK = 2 + CMD_WAKEUP = 3 + CMD_LAST = 4 + + class State(): + def __init__(self, state_machine, nervlist = None): + self.state_machine = state_machine + self._nerver = None if nervlist is None else Nerver(nervlist) + def handle_pins_event(self): + pass # one needn't implement this + def handle_buzz_event(self,arg): # this shouldn't be overwritten + self.actor().act(Actor.CMD_BUZZ) + arg("200 okay: buzz executed") + def handle_cmd_unlock_event(self,arg): + if arg is not None: + arg("412 Precondition Failed: The current state (%s) cannot handle the UNLOCK command. Try again later." % self.__class__.__name__) + def handle_wakeup_event(self): + if self._nerver is not None: + return self._nerver.nerv() + def on_leave(self): + pass + def pins(self): + return self.state_machine.pins + def old_pins(self): + return self.state_machine.old_pins + def actor(self): + return self.state_machine.actor + def handle_event(self,ev,arg): # don't override + if ev == StateMachine.CMD_PINS: + return self.handle_pins_event() + elif ev == StateMachine.CMD_BUZZ: + return self.handle_buzz_event(arg) + elif ev == StateMachine.CMD_UNLOCK: + return self.handle_cmd_unlock_event(arg) + elif ev == StateMachine.CMD_WAKEUP: + return self.handle_wakeup_event() + else: + raise Exception("Unknown command number: %d" % ev) + + class AbstractNonStartState(State): + def handle_pins_event(self): + if self.pins().door_locked != (not self.pins().space_active): + self.actor().act(Actor.CMD_RED_ON) + else: + self.actor().act(Actor.CMD_RED_OFF) + return super().handle_pins_event() + + class AbstractLockedState(AbstractNonStartState): + '''A state with invariant "The space is locked", switching to StateAboutToOpen when the space becomes unlocked''' + def __init__(self, sm, nervlist = None): + super().__init__(sm, nervlist) + self.actor().act(Actor.CMD_GREEN_OFF) + def handle_pins_event(self): + if not self.pins().door_locked: + logger.info("Door unlocked, space is about to open") + return StateMachine.StateAboutToOpen(self.state_machine) + if not self.old_pins().space_active and self.pins().space_active: + logger.info("Space toggled to active while it was closed - unlocking the door") + return StateMachine.StateUnlocking(self.state_machine) + return super().handle_pins_event() + + class AbstractUnlockedState(AbstractNonStartState): + '''A state with invariant "The space is unlocked", switching to StateZu when the space becomes locked''' + def __init__(self, sm, nervlist = None): + super().__init__(sm, nervlist) + self.actor().act(Actor.CMD_GREEN_ON) + def handle_pins_event(self): + if self.pins().door_locked: + logger.info("Door locked, closing space") + if self.pins().space_active: + logger.warning("StateMachine: door manually locked, but space switch is still on - going to StateZu") + play_sound("manual_lock") + return StateMachine.StateZu(self.state_machine) + return super().handle_pins_event() + + class StateStart(State): + def handle_pins_event(self): + pins = self.pins() + if not (pins.door_locked is None or pins.door_closed is None or pins.space_active is None or pins.bell_ringing is None): + logger.info("All sensors got a value, switching to a proper state") + if pins.door_locked: + return StateMachine.StateZu(self.state_machine) + else: + return StateMachine.StateAboutToOpen(self.state_machine) + return super().handle_pins_event() + + class StateZu(AbstractLockedState): + def handle_cmd_unlock_event(self,callback): + return StateMachine.StateUnlocking(self.state_machine, callback) + + class StateUnlocking(AbstractLockedState): + def __init__(self,sm,callback=None): + # construct a nervlist + nervlist = [(OPEN_REPEAT_TIMEOUT, lambda: self.actor().act(Actor.CMD_UNLOCK)) for t in range(OPEN_REPEAT_NUMBER)] + nervlist += [(OPEN_REPEAT_TIMEOUT, self.could_not_open)] + super().__init__(sm,nervlist) + self.callbacks=[callback] + # TODO: can we send "202 processing: Trying to unlock the door" here? Are the callbacks multi-use? + self.actor().act(Actor.CMD_UNLOCK) + def notify(self, did_it_work): + s = "200 okay: door unlocked" if did_it_work else ("500 internal server error: Couldn't unlock door with %d tries à %f seconds" % (OPEN_REPEAT_NUMBER,OPEN_REPEAT_TIMEOUT)) + for cb in self.callbacks: + if cb is not None: + cb(s) + def on_leave(self): + self.notify(not self.pins().door_locked) + def handle_cmd_unlock_event(self,callback): + # TODO: 202 notification also here if possible + self.callbacks.append(callback) + def could_not_open(self): + logger.critical("StateMachine: Couldn't open door after %d tries. Going back to StateZu." % OPEN_REPEAT_NUMBER) + return StateMachine.StateZu(self.state_machine) + + class AbstractStateWhereUnlockingIsRedundant(AbstractUnlockedState): + def handle_cmd_unlock_event(self, callback): + callback("299 redundant: Space seems to be already open. Still processing your request tough.") + logger.info("StateMachine: Received UNLOCK command in %s. This should not be necessary." % self.__class__.__name__) + self.actor().act(Actor.CMD_UNLOCK) + + class StateAboutToOpen(AbstractStateWhereUnlockingIsRedundant): + def __init__(self, sm): + super().__init__(sm, ABOUTOPEN_NERVLIST) + def handle_pins_event(self): + pins = self.pins() + if pins.space_active: + logger.info("Space activated, opening procedure completed") + return StateMachine.StateAuf(self.state_machine) + return super().handle_pins_event() + + class StateAuf(AbstractStateWhereUnlockingIsRedundant): + def __init__(self,sm): + nervlist = [(24*60*60, lambda: logger.critical("Space is now open for 24h. Is everything all right?"))] + super().__init__(sm, nervlist) + self.last_buzzed = None + def handle_pins_event(self): + pins = self.pins() + if pins.bell_ringing and not self.old_pins().bell_ringing: + # someone just pressed the bell + logger.info("StateMachine: buzzing because of bell ringing in StateAuf") + self.actor().act(Actor.CMD_BUZZ) + if not pins.space_active: + logger.info("StateMachine: space switch turned off - starting leaving procedure") + return StateMachine.StateAboutToLeave(self.state_machine) + return super().handle_pins_event() + + class StateLocking(AbstractUnlockedState): + def __init__(self,sm): + # construct a nervlist + nervlist = [(CLOSE_REPEAT_TIMEOUT, lambda: self.actor().act(Actor.CMD_LOCK)) for t in range(CLOSE_REPEAT_NUMBER)] + nervlist += [(CLOSE_REPEAT_TIMEOUT, self.could_not_close)] + super().__init__(sm, nervlist) + if self.pins().door_closed: # this should always be true, but just to be sure... + self.actor().act(Actor.CMD_LOCK) + def handle_pins_event(self): + pins = self.pins() + if not pins.door_closed: + # TODO play a sound? This shouldn't happen, door was opened while we are locking + logger.warning("StateMachine: door manually opened during locking") + return StateMachine.StateAboutToOpen(self.state_machine) + # TODO do anything here if the switch is activated now? + return super().handle_pins_event() + def handle_cmd_unlock_event(self,callback): + callback("409 conflict: The sphinx is currently trying to lock the door. Try again later.") + def could_not_close(self): + logger.critical("StateMachine: Couldn't close door after %d tries. Going back to StateAboutToOpen." % CLOSE_REPEAT_NUMBER) + return StateMachine.StateAboutToOpen(self.state_machine) + + class StateAboutToLeave(AbstractUnlockedState): + def __init__(self, sm): + nervlist = [(LEAVE_TIMEOUT, lambda: StateMachine.StateLocking(self.state_machine))] + super().__init__(sm, nervlist) + def handle_pins_event(self): + if not self.pins().door_closed: + return StateMachine.StateLeaving(self.state_machine) + if self.pins().space_active: + logger.info("Space re-activated, cancelling leaving procedure") + return StateMachine.StateAuf(self.state_machine) + return super().handle_pins_event() + + class StateLeaving(AbstractUnlockedState): + def __init__(self, sm): + nervlist = [(LEAVE_TIMEOUT, lambda: StateMachine.StateAboutToOpen(self.state_machine))] + super().__init__(sm, nervlist) + def handle_pins_event(self): + if self.pins().door_closed: + logger.info("The space was left, locking the door") + return StateMachine.StateLocking(self.state_machine) + if self.pins().space_active: + logger.info("Space re-activated, cancelling leaving procedure") + return StateMachine.StateAuf(self.state_machine) + return super().handle_pins_event() + + def __init__(self, actor): + self.actor = actor + self.callback = ThreadFunction(self._callback, name="StateMachine") + self.current_state = StateMachine.StateStart(self) + self.pins = None + self.old_pins = None + + def stop (self): + self.callback.stop() + + def _callback(self, cmd, arg=None): + # update pins + if cmd == StateMachine.CMD_PINS: + self.pins = arg + # handle stuff + newstate = self.current_state.handle_event(cmd,arg) # returns None or an instance of the new state + self.old_pins = self.pins + while newstate is not None: + assert isinstance(newstate, StateMachine.State), "I should get a state" + self.current_state.on_leave() + logger.debug("StateMachine: Doing state transition %s -> %s" % (self.current_state.__class__.__name__, newstate.__class__.__name__)) + self.current_state = newstate + newstate = self.current_state.handle_event(StateMachine.CMD_PINS, self.pins) diff --git a/tryshell b/tryshell new file mode 100755 index 0000000..3d4e3ed --- /dev/null +++ b/tryshell @@ -0,0 +1,15 @@ +#!/usr/bin/python3 + +import readline +import socket + +tuerSock = "/run/tuer.sock" + +while True: + command = input("# ") # EOFError used to exit + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(tuerSock) + s.send(command.encode()) + data = s.recv(64) + s.close() + print(str(data)) diff --git a/tuerd b/tuerd index f03d5db..1bee788 100755 --- a/tuerd +++ b/tuerd @@ -1,115 +1,42 @@ #!/usr/bin/python3 -import time, socket, os, stat, atexit, errno, struct, pwd -from libtuer import log -from datetime import datetime import RPi.GPIO as GPIO -SO_PEERCRED = 17 # DO - NOT - TOUCH +import statemachine, actor, pins, tysock, waker +from libtuer import logger +import argparse + +# Parse arguments +parser = argparse.ArgumentParser(description='Run a door') +parser.add_argument("-d", "--debug", + action="store_true", dest="debug", + help="Don't send emails") +args = parser.parse_args() +if args.debug: + import libtuer + libtuer.mailAddress = [] + +# initialize GPIO stuff GPIO.setmode(GPIO.BOARD) -atexit.register(GPIO.cleanup) -#tmp -def recv_timeout(conn, size, time): - (r, w, x) = select.select([conn], [], [], time) - if len(r): - assert r[0] == conn - return conn.recv(size) - return None - -# ******** definitions ********* -# send to client for information but don't care if it arrives -def waynesend (conn, what): - try: - conn.send(what) - except: - log("Couldn't send %s" % str(what)) - -# for command not found: do nothing with the pins and send a "0" to the client -def doNothing (conn): - log ("doing nothing") - waynesend(conn,b"0") +# bring 'em all up +the_actor = actor.Actor() +the_machine = statemachine.StateMachine(the_actor) +the_socket = tysock.TySocket(the_machine) +the_pins = pins.PinsWatcher(the_machine) +the_waker = waker.Waker(the_machine) + +# we do the socket accept thing in the main thread +try: + the_socket.accept() +except KeyboardInterrupt: + # this is what we waited for! + logger.info("Got SIGINT, terminating...") pass -# delete a file, don't care if it did not exist in the first place -def forcerm(name): - try: - os.unlink (name) - except OSError as e: - # only ignore error if it was "file didn't exist" - if e.errno != errno.ENOENT: - raise - -# commands: on a pin do a series of timed on/off switches -class Pinoutput: - # name is for logging and also used for mapping command names to instances of this class - # actionsanddelays is a list of pairs: (bool to set on pin, delay in seconds to wait afterwards) - def __init__ (self, name, pinnumber, actionsanddelays): - self.name = name - self.pin = pinnumber - self.todo = actionsanddelays - GPIO.setup(pinnumber, GPIO.OUT) - log ("Pin %d set to be an output pin for %s." % (pinnumber,name)) - # actually send the signal to the pins - def __call__ (self, conn): - for (value,delay) in self.todo: - GPIO.output(self.pin, value) - # log ("%s: Pin %d set to %s." % (self.name,self.pin,str(value))) - time.sleep(delay) - # notify success - log - waynesend(conn,b"1") - -# ******** configuration ********* - -tuergroupid = 1005 -socketname = "/run/tuer.sock" -pinlist = [Pinoutput("open", 12, [(True, 0.3), (False, 5.0)]), - Pinoutput("close", 16, [(True, 0.3), (False, 5.0)]), - Pinoutput("buzz", 22, [(True, 2.0), (False, 0.1)])] - - -# ******** main ********* -# convert list of pin objects to dictionary for command lookup -pindict = {} -for pin in pinlist: - pindict[pin.name.encode()] = pin - -# create socket -sock = socket.socket (socket.AF_UNIX, socket.SOCK_STREAM) -# delete old socket file and don't bitch around if it's not there -forcerm(socketname) -# bind socket to file name -sock.bind (socketname) -# allow only users in the tuergroup to write to the socket -os.chown (socketname, 0, tuergroupid) -os.chmod (socketname, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP) -# listen to the people, but not too many at once -sock.listen(1) - -# shutdown handling -def shutdown(): - log("Shutting down") - sock.close() - forcerm(socketname) -atexit.register(shutdown) - -# main loop -# FIXME: DoS by opening socket but not sending data, because this loop is single threaded; maybe settimeout helps a bit. -while True: - # accept connections - conn, addr = sock.accept() - try: - # get peer information - (pid, uid, gid) = (struct.unpack('3i', conn.getsockopt(socket.SOL_SOCKET, SO_PEERCRED, struct.calcsize('3i')))) - # get some data from the client (enough to hold any valid command) - data = conn.recv (32) - # log the command - log("received command from %s (uid %d): %s" % (pwd.getpwuid(uid).pw_name,uid, str(data))) - # lookup the command, if it's not in the dict, use the doNothing function instead - # and execute the looked up command or doNothing with the connection, so it can respond to the client - pindict.get(data,doNothing)(conn) - log("done") - # close connection cleanly - conn.close() - except Exception as e: - log("Something went wrong: %s\n...continuing." % str(e)) +# bring 'em all down +the_waker.stop() +the_pins.stop() +the_machine.stop() +the_actor.stop() +# shutdown GPIO stuff +GPIO.cleanup() diff --git a/tyshell b/tyshell index 9350e12..37873bc 100755 --- a/tyshell +++ b/tyshell @@ -5,6 +5,10 @@ import shlex import sys import subprocess import socket +import pwd +import grp +import traceback +from collections import namedtuple tuerSock = "/run/tuer.sock" @@ -16,11 +20,14 @@ except IOError: pass import atexit atexit.register(readline.write_history_file, histfile) -atexit.register(print, "Bye") # available commands def helpcmd(c): - print("Available commands: %s" % ", ".join(sorted(commands.keys()))) + if (len(c) > 1): + print(commands.get(c[1],(None,'Can\'t find help for command %s'%(c[1]))).helpstring) + else: + print("Available commands: %s" % ", ".join(sorted(commands.keys()))) + print("Use 'help command' to get more information on the command 'command'") def extcmd(cmd): def run(c): @@ -31,27 +38,48 @@ def extcmd(cmd): def sendcmd(addr, cmd): def run(c): - print("Running %s..." % (cmd)) + print("206 Sending command %s..." % (cmd)) s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect(addr) + s.settimeout(60.0) s.send(cmd.encode()) - data = s.recv(4) + data = s.recv(256) s.close() - print("...done") - if data != b'1': - print("Received unexpected answer %s" % str(data)) + print(data.decode('utf-8')) return run def exitcmd(c): - sys.exit(0) + print("Bye") + return True + +def whocmd(c): + for n in grp.getgrnam("tuer").gr_mem: + p = pwd.getpwnam(n) + print (p.pw_name, " - ", p.pw_gecos) + +def alias (cmds, aliases): + for newname, oldname in aliases.items(): + cmds[newname] = cmds[oldname] + return cmds + +CmdEntry = namedtuple('CmdEntry','function helpstring') + +commands = alias({ + 'exit': CmdEntry(exitcmd, 'Quits this shell'), + 'help': CmdEntry(helpcmd, 'Helps you getting to know the available commands'), + 'unlock': CmdEntry(sendcmd(tuerSock, 'unlock'), 'Will try to unlock the apartment door'), + 'buzz': CmdEntry(sendcmd(tuerSock, 'buzz'), 'Will buzz the buzzer for the street door'), + 'who': CmdEntry(whocmd, 'Shows the list of people, who are allowed to control this system'), +},{ + # aliases + 'open': 'unlock', +}) -commands = { - 'exit': exitcmd, - 'help': helpcmd, - 'open': sendcmd(tuerSock, 'open'), - 'close': sendcmd(tuerSock, 'close'), - 'buzz': sendcmd(tuerSock, 'buzz'), -} +def complete_command(cmd): + '''returns a list of commands (as strings) starting with cmd''' + return list(filter(lambda x: x.startswith(cmd), commands.keys())) +readline.set_completer(lambda cmd, num: (complete_command(cmd)+[None])[num]) # wrap complete_command for readline's weird completer API +readline.parse_and_bind("tab: complete") # run completion on tab # input loop print("Welcome to tyshell. Use help to see what you can do.") @@ -63,12 +91,23 @@ while True: break command = shlex.split(command) if not len(command): continue - # execute command - if command[0] in commands: + # find suiting commands + if command[0] in commands: # needed in case a complete command is a prefix of another one + cmdoptions = [command[0]] + else: + cmdoptions = complete_command(command[0]) + # check how many we found + if len(cmdoptions) == 0: # no commands fit prefix + print("Command %s not found. Use help." % command[0]) + elif len(cmdoptions) == 1: # exactly one command fits (prefix) try: - commands[command[0]](command) + res = commands[cmdoptions[0]].function(command) + if res: break except Exception as e: print("Error while executing %s: %s" % (command[0], str(e))) - else: - print("Command %s not found. Use help." % command[0]) + #print(traceback.format_exc()) + else: # multiple commands fit the prefix + print("Ambiguous command prefix, please choose one of the following:") + print("\t", " ".join(cmdoptions)) + # TODO: put current "command[0]" into the shell for the next command, but such that it is deletable with backspace diff --git a/tysock.py b/tysock.py new file mode 100644 index 0000000..0984b2a --- /dev/null +++ b/tysock.py @@ -0,0 +1,77 @@ +import socket, os, stat, struct, pwd, errno +from statemachine import StateMachine +from libtuer import logger +SO_PEERCRED = 17 # DO - NOT - TOUCH + +tuergroupid = 1005 +socketname = "/run/tuer.sock" + +# send to client for information but don't care if it arrives +def waynesend (conn, what): + try: + conn.send(what.encode()) + except: + pass # we do not care + +# delete a file, don't care if it did not exist in the first place +def forcerm(name): + try: + os.unlink (name) + except OSError as e: + # only ignore error if it was "file didn't exist" + if e.errno != errno.ENOENT: + raise + +# the class doing the actual work +class TySocket(): + CMDs = { + b'buzz': StateMachine.CMD_BUZZ, + b'unlock': StateMachine.CMD_UNLOCK, + } + + def __init__(self, sm): + self._sm = sm + # create socket + self._sock = socket.socket (socket.AF_UNIX, socket.SOCK_STREAM) + # delete old socket file and don't bitch around if it's not there + forcerm(socketname) + # bind socket to file name + self._sock.bind (socketname) + # allow only users in the tuergroup to write to the socket + os.chown (socketname, 0, tuergroupid) + os.chmod (socketname, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP) + # listen to the people, but not too many at once + self._sock.listen(1) + + def _answer(self, conn): + def answer(msg): + # this is called in another thread, so it should be quick and not touch the TySocket + waynesend(conn, msg) + conn.close() + return answer + + def accept(self): + '''Handles incoming connections and keyboard events''' + self._sock.settimeout(None) + while True: + # accept connections + conn, addr = self._sock.accept() + conn.settimeout(0.1) + try: + # get peer information + (pid, uid, gid) = struct.unpack('3i', conn.getsockopt(socket.SOL_SOCKET, SO_PEERCRED, struct.calcsize('3i'))) + # get some data from the client (enough to hold any valid command) + data = conn.recv (32) + # log the command + logger.info("TySocket: Received command from %s (uid %d): %s" % (pwd.getpwuid(uid).pw_name, uid, str(data))) + # lookup the command, send it to state machine + if data in self.CMDs: + self._sm.callback(self.CMDs[data], self._answer(conn)) + # _answer will be called, and it will close the connection + else: + waynesend(conn, 'Command not found') + conn.close() + except KeyboardInterrupt: + raise # forward Ctrl-C to the outside + except Exception as e: + logger.critical("TySocket: Something went wrong: %s" % str(e)) diff --git a/waker.py b/waker.py new file mode 100644 index 0000000..7b212a1 --- /dev/null +++ b/waker.py @@ -0,0 +1,13 @@ +from libtuer import ThreadRepeater +from statemachine import StateMachine + +class Waker(): + def __init__(self, sm): + self._sm = sm + self._t = ThreadRepeater(self._wake, 0.5, name="Waker") + + def _wake(self): + self._sm.callback(StateMachine.CMD_WAKEUP) + + def stop(self): + self._t.stop()