From 8b136f42bf0c83f8aee52c315f7dbebc371cce1b Mon Sep 17 00:00:00 2001 From: Ralf Jung Date: Thu, 3 Oct 2013 17:58:48 +0200 Subject: [PATCH] start implementing the new all-great tuerd --- Makefile | 2 +- actor.py | 32 ++++++++++ libtuer.py | 49 ++++++++++++--- pins.py | 71 ++++++++++++++++++++++ ringd | 87 -------------------------- statemachine.py | 158 ++++++++++++++++++++++++++++++++++++++++++++++++ tuerd | 131 +++++++-------------------------------- tyshell | 13 ++-- tysock.py | 80 ++++++++++++++++++++++++ waker.py | 13 ++++ 10 files changed, 425 insertions(+), 211 deletions(-) create mode 100644 actor.py create mode 100644 pins.py delete mode 100755 ringd create mode 100644 statemachine.py create mode 100644 tysock.py create mode 100644 waker.py diff --git a/Makefile b/Makefile index 25f344b..1bdb074 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -files = libtuer.py tuerd ringd tyshell +files = *.py tuerd tryshell tyshell target = /opt/tuer/ all: install diff --git a/actor.py b/actor.py new file mode 100644 index 0000000..3abd232 --- /dev/null +++ b/actor.py @@ -0,0 +1,32 @@ +from libtuer import ThreadFunction, logger +import RPi.GPIO as GPIO + +class Actor: + CMD_BUZZ = 0 + CMD_OPEN = 1 + CMD_CLOSE = 2 + + CMDs = { + CMD_BUZZ: ("buzz", 12, [(True, 0.3), (False, 2.0)]), + CMD_OPEN: ("open", 16, [(None, 0.2), (True, 0.3), (False, 1.0)]), + CMD_CLOSE: ("close", 22, [(None, 0.2), (True, 0.3), (False, 1.0)]), + } + + def __init__(self): + self.act = ThreadFunction(self._act) + for (name, pin, todo) in self.CMDs.values(): + GPIO.setup(pin, GPIO.OUT) + + def _act(self, cmd): + if cmd in self.CMDs: + (name, pin, todo) = self.CMDs[cmd] + logger.info("Actor: Running command %s" % name) + for (value, delay) in todo: + if value is not None: + GPIO.output(pin, value) + time.sleep(delay) + else: + logger.error("Actor: Gut unknown command %d" % cmd) + + def stop(self): + pass diff --git a/libtuer.py b/libtuer.py index 0df4051..60f491b 100644 --- a/libtuer.py +++ b/libtuer.py @@ -1,4 +1,4 @@ -import logging, logging.handlers, syslog, os +import logging, logging.handlers, os, time, queue, threading # logging function class Logger: @@ -9,17 +9,25 @@ class Logger: 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): + + def log (self, lvl, what): thestr = "%s[%d]: %s" % (self.name,os.getpid(),what) print (thestr) - self.logger.info(thestr) + self.logger.log(lvl, thestr) + + 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) - - # Threaded callback class class ThreadFunction(): _CALL = 0 @@ -36,12 +44,15 @@ class ThreadFunction(): (cmd, data) = self._q.get() # run command if cmd == _CALL: - self._f(*data) + try: + self._f(*data) + except Exception: + logger.error("ThreadFunction: Got exception out of handler thread: %s" % str(e)) elif cmd == _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)) @@ -49,3 +60,23 @@ class ThreadFunction(): def stop(self): self._q.put((_TERM, None)) self._t.join() + +# Thread timer-repeater class: Call a function every seconds +class ThreadRepeater(): + def __init__(self, f, sleep_time): + 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(): + while True: + if self._stop: + break + self._f() + time.sleep(sleep_time) + + def stop(self): + self._stop = True + self._t.join() diff --git a/pins.py b/pins.py new file mode 100644 index 0000000..8b699be --- /dev/null +++ b/pins.py @@ -0,0 +1,71 @@ +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, 5), + 'door_locked': PinWatcher(9, 5), + 'space_active': PinWatcher(10, 5), + } + self._sm = state_machine + + # start a thread doing the work + self._t = ThreadRepeater(self._read, 0.02) + + def _read(): + saw_change = False + for name in self.pins.keys(): + pin = pins[name] + if pin.read(): + saw_change = True + logger.debug("Pin %s changed to %d" % (name, pin.state) + if not saw_change: return + # 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._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.py b/statemachine.py new file mode 100644 index 0000000..b3d1e2a --- /dev/null +++ b/statemachine.py @@ -0,0 +1,158 @@ +from libtuer import ThreadFunction, logger +from actor import Actor + +# logger.{debug,info,warning,error,critical} + +# StateOpening constants +OPEN_REPEAT_TIMEOUT = 8 +OPEN_REPEAT_NUMBER = 3 + +def play_sound (what): + print ("I would now play the sound %s... IF I HAD SOUNDS!" % what) + +# StateAboutToOpen constants +ABOUTOPEN_NERVLIST = [(5, lambda : play_sound("heydrückdenknopf.mp3")), (10, lambda:play_sound("alterichmeinsernst.mp3"))] +# TODO: erzeuge mehr nerv + +class StateMachine(): + # commands you can send + CMD_PINS = 0 + CMD_BUZZ = 1 + CMD_OPEN = 2 + CMD_WAKEUP = 3 + CMD_LAST = 4 + + class State(): + def __init__(self, state_machine): + self.state_machine = state_machine + self.time_entered = time.time() + self.theDict = None + 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_open_event(self,arg): + if arg is not None: + arg("412 Precondition Failed: The current state (%s) cannot handle the OPEN event" % self.__class__.__name__) + def handle_wakeup_event(self): + pass # one needn't implement this + def pins(self): + return self.state_machine.pins + def actor(self): + return self.state_machine.actor + def handle_event(self,ev,arg): + if arg is CMD_PINS: + self.handle_pins_event() + elif arg is CMD_BUZZ: + self.handle_buzz_event(arg) + elif arg is CMD_OPEN: + self.handle_open_event(arg) + elif arg is CMD_WAKEUP: + self.handle_wakeup_event() + else: + raise Exception("Unknown command number: %d" % ev) + + class StateStart(State): + def __init__(self, sm): + State.__init__(self,sm) + def handle_pins_event(self): + thepins = self.pins() + for pin in thepins: + if pin is None: + return None + if thepins.door_locked: + return StateZu + else: + return StateAuf + + class StateZu(State): + def __init__(self,sm): + State.__init__(self,sm) + def handle_pins_event(self): + pins = self.pins() + if not pins.door_locked: + return StateAboutToOpen(self.state_machine) + def handle_open_event(self,callback): + return StateOpening(callback,self.state_machine) + + class StateOpening(State): + def __init__(self,callback,sm): + State.__init__(self,sm) + self.callbacks=[callback] + self.tries = 0 + self.actor().act(Actor.CMD_OPEN) + def notify(self, did_it_work): + s = "200 okay: door open" if did_it_work else ("500 internal server error: Couldn't open 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 handle_pins_event(self): + pins = self.pins() + if not pins.door_locked: + self.notify(True) + return StateAboutToOpen(self.state_machine) + def handle_open_event(self,callback): + self.callbacks.append(callback) + def handle_wakeup_event(self): + over = time.time() - self.time_entered + nexttry = (self.tries+1) * OPEN_REPEAT_TIMEOUT + if over > nexttry: + if self.tries < OPEN_REPEAT_NUMBER: + self.actor().act(Actor.CMD_OPEN) + self.tries += 1 + else: + #TODO: LOG ERROR und EMAIL an Admins + self.notify(False) + return StateZu(self.state_machine) + + class StateAboutToOpen(State): + def __init__(self, sm): + State.__init__(sm) + def handle_pins_event(self): + pins = self.pins() + if pins.door_locked: + return StateZu(self.state_machine) + elif pins.space_active: + return StateAuf(self.state_machine) + else: + over = time.time() - self.time_entered + # TODO: Nerv + logger.debug("AboutToOpen since %f seconds. TODO: nerv the user" % over) + # TODO + + class StateAuf(State): + #TODO + pass + + class StateClosing(State): + #TODO + pass + + class StateAboutToLeave(State): + #TODO + pass + + class StateLeaving(State): + #TODO + pass + + def __init__(self, actor): + self.actor = actor + self.callback = ThreadFunction(self._callback) + self.current_state = None + self.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 + while newstate is not None: + logger.info("StateMachine: new state = %s" % newstate.__class__.__name__) + self.current_state = newstate + newstate = self.current_state.handle_event(StateMachine.CMD_PINS, self.pins) diff --git a/tuerd b/tuerd index f03d5db..c3a5a43 100755 --- a/tuerd +++ b/tuerd @@ -1,115 +1,30 @@ #!/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 -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 +import statemachine, actor, pins, tysock, waker -# ******** 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)) +# initialize GPIO stuff +GPIO.setmode(GPIO.BOARD) -# 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! 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_socket.stop() +the_machine.stop() +the_actor.stop() +# shutdown GPIO stuff +GPIO.cleanup() diff --git a/tyshell b/tyshell index 4429c35..2280926 100755 --- a/tyshell +++ b/tyshell @@ -16,7 +16,6 @@ except IOError: pass import atexit atexit.register(readline.write_history_file, histfile) -atexit.register(print, "Bye") # available commands def helpcmd(c): @@ -34,16 +33,16 @@ def sendcmd(addr, cmd): print("Running %s..." % (cmd)) s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect(addr) + s.settimeout(10.0) s.send(cmd.encode()) data = s.recv(4) 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 commands = { 'exit': exitcmd, @@ -68,11 +67,13 @@ while True: cmdoptions = [command[0]] else: cmdoptions = list(filter(lambda x: command[0].startswith(x), commands.keys())) + # 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[cmdoptions[0]](command) + res = commands[cmdoptions[0]](command) + if res: break except Exception as e: print("Error while executing %s: %s" % (command[0], str(e))) else: # multiple commands fit the prefix diff --git a/tysock.py b/tysock.py new file mode 100644 index 0000000..3951d1f --- /dev/null +++ b/tysock.py @@ -0,0 +1,80 @@ +import socket, os, stat +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'open': StateMachine.CMD_OPEN, + } + + 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.error("TySocket: Something went wrong: %s" % str(e)) + + def stop(self): + pass diff --git a/waker.py b/waker.py new file mode 100644 index 0000000..b1d475b --- /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) + + def _wake(self): + self._sm.callback(StateMachine.CMD_WAKEUP) + + def stop(self): + self._t.stop() -- 2.30.2