Merge branch 'master' of ralfj.de:saartuer
authormar-v-in <github@rvin.mooo.com>
Wed, 23 Oct 2013 20:03:27 +0000 (22:03 +0200)
committermar-v-in <github@rvin.mooo.com>
Wed, 23 Oct 2013 20:03:27 +0000 (22:03 +0200)
14 files changed:
.gitignore [new file with mode: 0644]
Makefile [new file with mode: 0644]
actor.py [new file with mode: 0644]
libtuer.py
pins.py [new file with mode: 0644]
ringd [deleted file]
statemachine.jpg [new file with mode: 0644]
statemachine.odg [new file with mode: 0644]
statemachine.py [new file with mode: 0644]
tryshell [new file with mode: 0755]
tuerd
tyshell
tysock.py [new file with mode: 0644]
waker.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..bee8a64
--- /dev/null
@@ -0,0 +1 @@
+__pycache__
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
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 (file)
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()
index 0df4051203cf8cad29da53d439dd263e39449922..1789b90ce8c9307f22e06d9d5cdfe3623c456988 100644 (file)
@@ -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 <sleep_time> 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 (file)
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 (executable)
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 (file)
index 0000000..4643df3
Binary files /dev/null and b/statemachine.jpg differ
diff --git a/statemachine.odg b/statemachine.odg
new file mode 100644 (file)
index 0000000..d32026c
Binary files /dev/null and b/statemachine.odg differ
diff --git a/statemachine.py b/statemachine.py
new file mode 100644 (file)
index 0000000..ca58e1d
--- /dev/null
@@ -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 (executable)
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 f03d5dbc69d17d382a1757b8b2fc44851fe668f1..1bee788341c776acbb894475409707ea2e78a611 100755 (executable)
--- a/tuerd
+++ b/tuerd
 #!/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 9350e125272cd4f2ff7e046808ed0364d96ec2ff..37873bcff4bc21df5a5e4f27dc867466a2ee7f9e 100755 (executable)
--- 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 (file)
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 (file)
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()