silence sphinx a bit
[saartuer.git] / statemachine.py
index f2fb8178a608b9623a1f08781d63e2a0d5971813..cb1e4c86b156c94cc15aed4cab418d7345949291 100644 (file)
@@ -1,6 +1,6 @@
-from libtuer import ThreadFunction, logger, fire_and_forget
+from libtuer import ThreadFunction, logger, fire_and_forget, fire_and_forget_cmd
 from actor import Actor
 from actor import Actor
-import os, random, time
+import os, random, time, threading, datetime
 
 # logger.{debug,info,warning,error,critical}
 
 
 # logger.{debug,info,warning,error,critical}
 
@@ -11,8 +11,22 @@ def play_sound (what):
                logger.error("StateMachine: Unable to list sound files in %s" % (SOUNDS_DIRECTORY+what))
                return
        soundfile = SOUNDS_DIRECTORY + what + '/' + random.choice(soundfiles)
                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: ")
+       hour = datetime.datetime.time(datetime.datetime.now()).hour
+       volume = 60 if hour >= 22 or hour <= 6 else 90
+       fire_and_forget_cmd ([SOUNDS_PLAYER, "-volume", str(volume), soundfile], "StateMachine: ")
 
 
+# convert an absolute nervlist to a relative one
+def nervlist_abs2rel(nervlist_abs):
+       nervlist_rel = []
+       last_t = 0
+       for (t, f) in nervlist_abs:
+               assert t >= last_t
+               nervlist_rel.append((t-last_t, f))
+               last_t = t
+       return nervlist_rel
+
+# StateAuf constants
+HELLO_PROBABILITY = 0.2
 
 # StateUnlocking constants
 OPEN_REPEAT_TIMEOUT = 7
 
 # StateUnlocking constants
 OPEN_REPEAT_TIMEOUT = 7
@@ -22,11 +36,17 @@ OPEN_REPEAT_NUMBER = 3
 CLOSE_REPEAT_TIMEOUT = 7
 CLOSE_REPEAT_NUMBER = 3
 
 CLOSE_REPEAT_TIMEOUT = 7
 CLOSE_REPEAT_NUMBER = 3
 
+# StateFallback constants
+FALLBACK_LEAVE_DELAY_LOCK = 5 # seconds
+
 # StateAboutToOpen constants
 # 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"))]
+SWITCH_PRAISE_PROBABILITY = 0.5
+ABOUTOPEN_NERVLIST = nervlist_abs2rel([(10, lambda:play_sound("flipswitch")),\
+       (20, lambda:play_sound("flipswitch")), (30, lambda:play_sound("flipswitch")), (30, lambda:logger.error("Space open but switch not flipped for 30 seconds")),\
+       (40, lambda:play_sound("flipswitch")), (50, lambda:play_sound("flipswitch")), (60, lambda:play_sound("mail_sent")),
+       (60, lambda:logger.critical("Space open but switch not flipped for 60 seconds")), (120, lambda:play_sound("mail_sent")),
+       (10*60, lambda:logger.critical("Space open but switch not flipped for 10 minutes")),
+       (60*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
 
 # 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
@@ -63,6 +83,10 @@ class StateMachine():
        CMD_UNLOCK = 2
        CMD_WAKEUP = 3
        CMD_LAST = 4
        CMD_UNLOCK = 2
        CMD_WAKEUP = 3
        CMD_LAST = 4
+       CMD_LOCK = 5
+       CMD_FALLBACK_ON = 6
+       CMD_FALLBACK_OFF = 7
+       CMD_STATUS = 8
        
        class State():
                def __init__(self, state_machine, nervlist = None):
        
        class State():
                def __init__(self, state_machine, nervlist = None):
@@ -79,12 +103,27 @@ class StateMachine():
                def handle_wakeup_event(self):
                        if self._nerver is not None:
                                return self._nerver.nerv()
                def handle_wakeup_event(self):
                        if self._nerver is not None:
                                return self._nerver.nerv()
+               def handle_cmd_lock_event(self,arg):
+                       arg("412 Precondition Failed: If not in fallback mode, use the hardware switch to lock the space.")
+               def handle_cmd_fallback_on_event(self,arg):
+                       arg("200 okay: Entering fallback mode and notifying admins.")
+                       logger.critical("Entering fallback mode. Somebody thinks, the hardware sensors are broken.")
+                       return StateMachine.StateFallback(self.state_machine)
+               def handle_cmd_fallback_off_event(self,arg):
+                       arg("412 Precondition Failed: Not in fallback mode!")
+               def handle_cmd_status_event(self,arg):
+                       # TODO use a proper JSON lib
+                       arg('200 okay: {state:\"%s\"}' % self.__class__.__name__)
+               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 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 api(self):
+                       return self.state_machine.api
                def handle_event(self,ev,arg): # don't override
                        if ev == StateMachine.CMD_PINS:
                                return self.handle_pins_event()
                def handle_event(self,ev,arg): # don't override
                        if ev == StateMachine.CMD_PINS:
                                return self.handle_pins_event()
@@ -94,41 +133,117 @@ class StateMachine():
                                return self.handle_cmd_unlock_event(arg)
                        elif ev == StateMachine.CMD_WAKEUP:
                                return self.handle_wakeup_event()
                                return self.handle_cmd_unlock_event(arg)
                        elif ev == StateMachine.CMD_WAKEUP:
                                return self.handle_wakeup_event()
+                       elif ev == StateMachine.CMD_LOCK:
+                               return self.handle_cmd_lock_event(arg)
+                       elif ev == StateMachine.CMD_FALLBACK_ON:
+                               return self.handle_cmd_fallback_on_event(arg)
+                       elif ev == StateMachine.CMD_FALLBACK_OFF:
+                               return self.handle_cmd_fallback_off_event(arg)
+                       elif ev == StateMachine.CMD_STATUS:
+                               return self.handle_cmd_status_event(arg)
                        else:
                                raise Exception("Unknown command number: %d" % ev)
        
                        else:
                                raise Exception("Unknown command number: %d" % ev)
        
-       class AbstractLockedState(State):
+       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'''
                '''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:
                def handle_pins_event(self):
                        if not self.pins().door_locked:
-                               return StateAboutToOpen(self.state_machine)
-                       return super().handle_pins_event
+                               logger.info("Door unlocked, space is about to open")
+                               return StateMachine.StateAboutToOpen(self.state_machine)
+                       return super().handle_pins_event()
        
        
-       class AbstractUnlockedState(State):
+       class AbstractUnlockedState(AbstractNonStartState):
                '''A state with invariant "The space is unlocked", switching to StateZu when the space becomes locked'''
                '''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:
                def handle_pins_event(self):
                        if self.pins().door_locked:
+                               logger.info("Door locked, closing space")
                                if self.pins().space_active:
                                if self.pins().space_active:
-                                       # FIXME the same state can be reached by first locking the door, and then activating the space. What to do then?
-                                       logger.info("StateMachine: door manually locked, but space switch is still on - going to StateZu")
+                                       logger.warning("StateMachine: door manually locked, but space switch is still on - going to StateZu")
                                        play_sound("manual_lock")
                                        play_sound("manual_lock")
-                               return StateZu(self.state_machine)
-                       return super().handle_pins_event
+                               return StateMachine.StateZu(self.state_machine)
+                       return super().handle_pins_event()
        
        class StateStart(State):
        
        class StateStart(State):
+               def __init__(self, sm, nervlist = None, fallback=False):
+                       super().__init__(sm, nervlist)
+                       self.fallback = fallback
                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):
                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):
-                               # All sensors got a value, switch to a proper state
+                               if self.fallback:
+                                       logger.info("Going to StateFallback because running in fallback mode")
+                                       return StateMachine.StateFallback(self.state_machine)
                                if pins.door_locked:
                                if pins.door_locked:
+                                       logger.info("All sensors got a value, switching to a proper state: Space is closed")
                                        return StateMachine.StateZu(self.state_machine)
                                else:
                                        return StateMachine.StateZu(self.state_machine)
                                else:
+                                       logger.info("All sensors got a value, switching to a proper state: Space is (about to) open")
                                        return StateMachine.StateAboutToOpen(self.state_machine)
                        return super().handle_pins_event()
        
                                        return StateMachine.StateAboutToOpen(self.state_machine)
                        return super().handle_pins_event()
        
+       class StateFallback(State):
+               def __init__(self, sm, nervlist = None):
+                       super().__init__(sm, nervlist)
+                       self._red_state = False
+               def handle_pins_event(self):
+                       pins = self.pins()
+                       # set green LED according to space switch
+                       if pins.space_active:
+                               self.actor().act(Actor.CMD_GREEN_ON)
+                       else:
+                               self.actor().act(Actor.CMD_GREEN_OFF)
+                       # primitive leaving procedure if space switch turned off
+                       if not pins.space_active and self.old_pins().space_active:
+                               def _close_after_time():
+                                       time.sleep(FALLBACK_LEAVE_DELAY_LOCK)
+                                       self.actor().act(Actor.CMD_LOCK)
+                               fire_and_forget(_close_after_time)
+                       # not calling superclass because we want to stay in fallback mode
+               def handle_wakeup_event(self):
+                       # blink red LED
+                       if self._red_state:
+                               self.actor().act(Actor.CMD_RED_OFF)
+                               self._red_state = False
+                       else:
+                               self.actor().act(Actor.CMD_RED_ON)
+                               self._red_state = True
+               def handle_cmd_unlock_event(self,arg):
+                       if arg is not None:
+                               arg("200 okay: Trying to unlock the door. The System is in fallback mode, success information is not available.")
+                       self.actor().act(Actor.CMD_UNLOCK)
+               def handle_cmd_lock_event(self,arg):
+                       if arg is not None:
+                               arg("200 okay: Trying to lock the door. The System is in fallback mode, success information is not available.")
+                       self.actor().act(Actor.CMD_LOCK)
+               def handle_cmd_fallback_on_event(self,arg):
+                       arg("412 Precondition Failed: Fallback mode already active.")
+               def handle_cmd_fallback_off_event(self,arg):
+                       arg("200 okay: Leaving fallback mode and notifying admins.")
+                       logger.critical("Leaving fallback mode. Somebody thinks, the sensors are working again.")
+                       return StateMachine.StateStart(self.state_machine)
+       
        class StateZu(AbstractLockedState):
                def handle_cmd_unlock_event(self,callback):
                        return StateMachine.StateUnlocking(self.state_machine, callback)
        class StateZu(AbstractLockedState):
                def handle_cmd_unlock_event(self,callback):
                        return StateMachine.StateUnlocking(self.state_machine, callback)
+               def handle_pins_event(self):
+                       if not self.old_pins().space_active and self.pins().space_active: # first thing to check: edge detection
+                               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 StateUnlocking(AbstractLockedState):
                def __init__(self,sm,callback=None):
        
        class StateUnlocking(AbstractLockedState):
                def __init__(self,sm,callback=None):
@@ -136,56 +251,62 @@ class StateMachine():
                        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)
                        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.callbacks = []
                        self.actor().act(Actor.CMD_UNLOCK)
                        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))
+                       # enqueue the callback
+                       self.handle_cmd_unlock_event(callback)
+               def notify(self, s, lastMsg):
                        for cb in self.callbacks:
                        for cb in self.callbacks:
-                               if cb is not None:
-                                       cb(s)
-               def handle_pins_event(self):
-                       # overriding superclass as we need to do notification (TODO can this be done better? on_leave?)
-                       if not self.pins().door_locked:
-                               self.notify(True)
-                               return StateMachine.StateAboutToOpen(self.state_machine)
+                               cb(s, lastMsg)
+               def on_leave(self):
+                       s = "200 okay: door unlocked" if not self.pins().door_locked else ("500 internal server error: Couldn't unlock door with %d tries à %f seconds" % (OPEN_REPEAT_NUMBER,OPEN_REPEAT_TIMEOUT))
+                       self.notify(s, lastMsg=True)
                def handle_cmd_unlock_event(self,callback):
                def handle_cmd_unlock_event(self,callback):
-                       # TODO: 202 notification also here if possible
-                       self.callbacks.append(callback)
+                       if callback is not None:
+                               callback("202 processing: Trying to unlock the door", lastMsg=False)
+                               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)
                def could_not_open(self):
                        logger.critical("StateMachine: Couldn't open door after %d tries. Going back to StateZu." % OPEN_REPEAT_NUMBER)
-                       self.notify(False)
                        return StateMachine.StateZu(self.state_machine)
        
                        return StateMachine.StateZu(self.state_machine)
        
-       class AbstractStateWhereOpeningIsRedundant(AbstractUnlockedState):
+       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)
        
                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(AbstractStateWhereOpeningIsRedundant):
+       class StateAboutToOpen(AbstractStateWhereUnlockingIsRedundant):
                def __init__(self, sm):
                        super().__init__(sm, ABOUTOPEN_NERVLIST)
                def handle_pins_event(self):
                        pins = self.pins()
                        if pins.space_active:
                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")
+                               if not self.old_pins().space_active and random.random() <= SWITCH_PRAISE_PROBABILITY:
+                                       play_sound("success")
                                return StateMachine.StateAuf(self.state_machine)
                        return super().handle_pins_event()
        
                                return StateMachine.StateAuf(self.state_machine)
                        return super().handle_pins_event()
        
-       class StateAuf(AbstractStateWhereOpeningIsRedundant):
+       class StateAuf(AbstractStateWhereUnlockingIsRedundant):
                def __init__(self,sm):
                def __init__(self,sm):
-                       super().__init__(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
                        self.last_buzzed = None
+                       self.api().set_state(True)
                def handle_pins_event(self):
                        pins = self.pins()
                def handle_pins_event(self):
                        pins = self.pins()
-                       if pins.bell_ringing and not self.old_pins().bell_ringing:
+                       if pins.bell_ringing and not self.old_pins().bell_ringing: # first thing to check: edge detection
                                # 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)
                                # 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)
+                       if not pins.door_closed and self.old_pins().door_closed and random.random() <= HELLO_PROBABILITY:
+                               play_sound("hello")
                        return super().handle_pins_event()
                        return super().handle_pins_event()
+               def on_leave(self):
+                       self.api().set_state(False)
        
        class StateLocking(AbstractUnlockedState):
                def __init__(self,sm):
        
        class StateLocking(AbstractUnlockedState):
                def __init__(self,sm):
@@ -217,6 +338,7 @@ class StateMachine():
                        if not self.pins().door_closed:
                                return StateMachine.StateLeaving(self.state_machine)
                        if self.pins().space_active:
                        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()
        
                                return StateMachine.StateAuf(self.state_machine)
                        return super().handle_pins_event()
        
@@ -226,21 +348,28 @@ class StateMachine():
                        super().__init__(sm, nervlist)
                def handle_pins_event(self):
                        if self.pins().door_closed:
                        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:
                                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()
        
                                return StateMachine.StateAuf(self.state_machine)
                        return super().handle_pins_event()
        
-       def __init__(self, actor):
+       def __init__(self, actor, waker, api, fallback = False):
                self.actor = actor
                self.actor = actor
+               self.api = api
                self.callback = ThreadFunction(self._callback, name="StateMachine")
                self.callback = ThreadFunction(self._callback, name="StateMachine")
-               self.current_state = StateMachine.StateStart(self)
+               self.current_state = StateMachine.StateStart(self, fallback=fallback)
                self.pins = None
                self.old_pins = None
                self.pins = None
                self.old_pins = None
+               waker.register(lambda: self.callback(StateMachine.CMD_WAKEUP), 1.0) # wake up every second
+               # initially, the space is closed
+               api.set_state(False)
        
        def stop (self):
                self.callback.stop()
        
        
        def stop (self):
                self.callback.stop()
        
+       # actually call this.callback (is set in the constructor to make this thread safe)
        def _callback(self, cmd, arg=None):
                # update pins
                if cmd == StateMachine.CMD_PINS:
        def _callback(self, cmd, arg=None):
                # update pins
                if cmd == StateMachine.CMD_PINS:
@@ -249,6 +378,8 @@ class StateMachine():
                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:
                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:
-                       logger.debug("StateMachine: Doing state transition %s -> %s" % (self.current_state.__class__.__name__, newstate.__class__.__name__))
+                       assert isinstance(newstate, StateMachine.State), "I should get a state"
+                       self.current_state.on_leave()
+                       logger.info("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)
                        self.current_state = newstate
                        newstate = self.current_state.handle_event(StateMachine.CMD_PINS, self.pins)