0fa485abcd7d25a146d578357a3803aa8f1905b6
[saartuer.git] / statemachine.py
1 from libtuer import ThreadFunction, logger, fire_and_forget, fire_and_forget_cmd
2 from actor import Actor
3 import os, random, time, threading
4
5 # logger.{debug,info,warning,error,critical}
6
7 def play_sound (what):
8         try:
9                 soundfiles = os.listdir(SOUNDS_DIRECTORY+what)
10         except FileNotFoundError:
11                 logger.error("StateMachine: Unable to list sound files in %s" % (SOUNDS_DIRECTORY+what))
12                 return
13         soundfile = SOUNDS_DIRECTORY + what + '/' + random.choice(soundfiles)
14         fire_and_forget_cmd ([SOUNDS_PLAYER,soundfile], "StateMachine: ")
15
16 # convert an absolute nervlist to a relative one
17 def nervlist_abs2rel(nervlist_abs):
18         nervlist_rel = []
19         last_t = 0
20         for (t, f) in nervlist_abs:
21                 assert t >= last_t
22                 nervlist_rel.append((t-last_t, f))
23                 last_t = t
24         return nervlist_rel
25
26 # StateAuf constants
27 HELLO_PROBABILITY = 0.2
28
29 # StateUnlocking constants
30 OPEN_REPEAT_TIMEOUT = 7
31 OPEN_REPEAT_NUMBER = 3
32
33 # StateLocking constants
34 CLOSE_REPEAT_TIMEOUT = 7
35 CLOSE_REPEAT_NUMBER = 3
36
37 # StateFallback constants
38 FALLBACK_LEAVE_DELAY_LOCK = 5 # seconds
39
40 # StateAboutToOpen constants
41 SWITCH_PRAISE_PROBABILITY = 0.5
42 ABOUTOPEN_NERVLIST = nervlist_abs2rel([(10, lambda:play_sound("flipswitch")),\
43         (20, lambda:play_sound("flipswitch")), (30, lambda:play_sound("flipswitch")), (30, lambda:logger.error("Space open but switch not flipped for 30 seconds")),\
44         (40, lambda:play_sound("flipswitch")), (50, lambda:play_sound("flipswitch")), (60, lambda:play_sound("mail_sent")),
45         (60, lambda:logger.critical("Space open but switch not flipped for 60 seconds")), (120, lambda:play_sound("mail_sent")),
46         (10*60, lambda:logger.critical("Space open but switch not flipped for 10 minutes")),
47         (60*60, lambda:logger.critical("Space open but switch not flipped for one hour"))])
48
49 # Timeout we wait after the switch was switched to "Closed", until we assume nobody will open the door and we just lock it
50 # ALso the time we wait after the door was opend, till we assume something went wrong and start nerving
51 LEAVE_TIMEOUT = 20
52
53 # play_sound constants
54 SOUNDS_DIRECTORY = "/opt/tuer/sounds/"
55 SOUNDS_PLAYER = "/usr/bin/mplayer"
56
57
58 class Nerver():
59         # 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.
60         # If f returns something, that's also returned by nerv.
61         def __init__(self, nervlist):
62                 self.nervlist = list(nervlist)
63                 self.last_event_time = time.time()
64         
65         def nerv(self):
66                 if len(self.nervlist):
67                         (wait_time, f) = self.nervlist[0]
68                         now = time.time()
69                         time_gone = now-self.last_event_time
70                         # check if the first element is to be triggered
71                         if time_gone >= wait_time:
72                                 self.nervlist = self.nervlist[1:] # "pop" the first element, but do not modify original list
73                                 self.last_event_time = now
74                                 return f()
75
76
77 class StateMachine():
78         # commands you can send
79         CMD_PINS = 0
80         CMD_BUZZ = 1
81         CMD_UNLOCK = 2
82         CMD_WAKEUP = 3
83         CMD_LAST = 4
84         
85         class State():
86                 def __init__(self, state_machine, nervlist = None):
87                         self.state_machine = state_machine
88                         self._nerver = None if nervlist is None else Nerver(nervlist)
89                 def handle_pins_event(self):
90                         pass # one needn't implement this
91                 def handle_buzz_event(self,arg): # this shouldn't be overwritten
92                         self.actor().act(Actor.CMD_BUZZ)
93                         arg("200 okay: buzz executed")
94                 def handle_cmd_unlock_event(self,arg):
95                         if arg is not None:
96                                 arg("412 Precondition Failed: The current state (%s) cannot handle the UNLOCK command. Try again later." % self.__class__.__name__)
97                 def handle_wakeup_event(self):
98                         if self._nerver is not None:
99                                 return self._nerver.nerv()
100                 def on_leave(self):
101                         pass
102                 def pins(self):
103                         return self.state_machine.pins
104                 def old_pins(self):
105                         return self.state_machine.old_pins
106                 def actor(self):
107                         return self.state_machine.actor
108                 def api(self):
109                         return self.state_machine.api
110                 def handle_event(self,ev,arg): # don't override
111                         if ev == StateMachine.CMD_PINS:
112                                 return self.handle_pins_event()
113                         elif ev == StateMachine.CMD_BUZZ:
114                                 return self.handle_buzz_event(arg)
115                         elif ev == StateMachine.CMD_UNLOCK:
116                                 return self.handle_cmd_unlock_event(arg)
117                         elif ev == StateMachine.CMD_WAKEUP:
118                                 return self.handle_wakeup_event()
119                         else:
120                                 raise Exception("Unknown command number: %d" % ev)
121         
122         class AbstractNonStartState(State):
123                 def handle_pins_event(self):
124                         if self.pins().door_locked != (not self.pins().space_active):
125                                 self.actor().act(Actor.CMD_RED_ON)
126                         else:
127                                 self.actor().act(Actor.CMD_RED_OFF)
128                         return super().handle_pins_event()
129         
130         class AbstractLockedState(AbstractNonStartState):
131                 '''A state with invariant "The space is locked", switching to StateAboutToOpen when the space becomes unlocked'''
132                 def __init__(self, sm, nervlist = None):
133                         super().__init__(sm, nervlist)
134                         self.actor().act(Actor.CMD_GREEN_OFF)
135                 def handle_pins_event(self):
136                         if not self.pins().door_locked:
137                                 logger.info("Door unlocked, space is about to open")
138                                 return StateMachine.StateAboutToOpen(self.state_machine)
139                         return super().handle_pins_event()
140         
141         class AbstractUnlockedState(AbstractNonStartState):
142                 '''A state with invariant "The space is unlocked", switching to StateZu when the space becomes locked'''
143                 def __init__(self, sm, nervlist = None):
144                         super().__init__(sm, nervlist)
145                         self.actor().act(Actor.CMD_GREEN_ON)
146                 def handle_pins_event(self):
147                         if self.pins().door_locked:
148                                 logger.info("Door locked, closing space")
149                                 if self.pins().space_active:
150                                         logger.warning("StateMachine: door manually locked, but space switch is still on - going to StateZu")
151                                         play_sound("manual_lock")
152                                 return StateMachine.StateZu(self.state_machine)
153                         return super().handle_pins_event()
154         
155         class StateStart(State):
156                 def __init__(self, sm, nervlist = None, fallback=False):
157                         super().__init__(sm, nervlist)
158                         self.fallback = fallback
159                 def handle_pins_event(self):
160                         pins = self.pins()
161                         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):
162                                 if self.fallback:
163                                         logger.info("Going to StateFallback because running in fallback mode")
164                                         return StateMachine.StateFallback(self.state_machine)
165                                 if pins.door_locked:
166                                         logger.info("All sensors got a value, switching to a proper state: Space is closed")
167                                         return StateMachine.StateZu(self.state_machine)
168                                 else:
169                                         logger.info("All sensors got a value, switching to a proper state: Space is (about to) open")
170                                         return StateMachine.StateAboutToOpen(self.state_machine)
171                         return super().handle_pins_event()
172         
173         class StateFallback(State):
174                 def __init__(self, sm, nervlist = None):
175                         super().__init__(sm, nervlist)
176                         self._red_state = False
177                 def handle_pins_event(self):
178                         pins = self.pins()
179                         # buzz if open and bell rang
180                         if pins.space_active and pins.bell_ringing and not self.old_pins().bell_ringing:
181                                 logger.info("StateFallback: Space switch on and door bell rung => buzzing")
182                                 self.actor().act(Actor.CMD_BUZZ)
183                         # set green LED according to space switch
184                         if pins.space_active:
185                                 self.actor().act(Actor.CMD_GREEN_ON)
186                         else:
187                                 self.actor().act(Actor.CMD_GREEN_OFF)
188                         # primitive leaving procedure if space switch turned off
189                         if not pins.space_active and self.old_pins().space_active:
190                                 def _close_after_time():
191                                         time.sleep(FALLBACK_LEAVE_DELAY_LOCK)
192                                         self.actor().act(Actor.CMD_LOCK)
193                                 fire_and_forget(_close_after_time)
194                         # not calling superclass because we want to stay in fallback mode
195                 def handle_wakeup_event(self):
196                         # blink red LED
197                         if self._red_state:
198                                 self.actor().act(Actor.CMD_RED_OFF)
199                                 self._red_state = False
200                         else:
201                                 self.actor().act(Actor.CMD_RED_ON)
202                                 self._red_state = True
203                 def handle_cmd_unlock_event(self,arg):
204                         if arg is not None:
205                                 arg("298 Fallback Okay: Trying to unlock the door. The System is in fallback mode, success information is not available.")
206                         self.actor().act(Actor.CMD_UNLOCK)
207         
208         class StateZu(AbstractLockedState):
209                 def handle_cmd_unlock_event(self,callback):
210                         return StateMachine.StateUnlocking(self.state_machine, callback)
211                 def handle_pins_event(self):
212                         if not self.old_pins().space_active and self.pins().space_active: # first thing to check: edge detection
213                                 logger.info("Space toggled to active while it was closed - unlocking the door")
214                                 return StateMachine.StateUnlocking(self.state_machine)
215                         return super().handle_pins_event()
216         
217         class StateUnlocking(AbstractLockedState):
218                 def __init__(self,sm,callback=None):
219                         # construct a nervlist
220                         nervlist = [(OPEN_REPEAT_TIMEOUT, lambda: self.actor().act(Actor.CMD_UNLOCK)) for t in range(OPEN_REPEAT_NUMBER)]
221                         nervlist += [(OPEN_REPEAT_TIMEOUT, self.could_not_open)]
222                         super().__init__(sm,nervlist)
223                         self.callbacks = []
224                         self.actor().act(Actor.CMD_UNLOCK)
225                         # enqueue the callback
226                         self.handle_cmd_unlock_event(callback)
227                 def notify(self, s, lastMsg):
228                         for cb in self.callbacks:
229                                 cb(s, lastMsg)
230                 def on_leave(self):
231                         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))
232                         self.notify(s, lastMsg=True)
233                 def handle_cmd_unlock_event(self,callback):
234                         if callback is not None:
235                                 callback("202 processing: Trying to unlock the door", lastMsg=False)
236                                 self.callbacks.append(callback)
237                 def could_not_open(self):
238                         logger.critical("StateMachine: Couldn't open door after %d tries. Going back to StateZu." % OPEN_REPEAT_NUMBER)
239                         return StateMachine.StateZu(self.state_machine)
240         
241         class AbstractStateWhereUnlockingIsRedundant(AbstractUnlockedState):
242                 def handle_cmd_unlock_event(self, callback):
243                         callback("299 redundant: Space seems to be already open. Still processing your request tough.")
244                         logger.info("StateMachine: Received UNLOCK command in %s. This should not be necessary." % self.__class__.__name__)
245                         self.actor().act(Actor.CMD_UNLOCK)
246         
247         class StateAboutToOpen(AbstractStateWhereUnlockingIsRedundant):
248                 def __init__(self, sm):
249                         super().__init__(sm, ABOUTOPEN_NERVLIST)
250                 def handle_pins_event(self):
251                         pins = self.pins()
252                         if pins.space_active:
253                                 logger.info("Space activated, opening procedure completed")
254                                 if not self.old_pins().space_active and random.random() <= SWITCH_PRAISE_PROBABILITY:
255                                         play_sound("success")
256                                 return StateMachine.StateAuf(self.state_machine)
257                         return super().handle_pins_event()
258         
259         class StateAuf(AbstractStateWhereUnlockingIsRedundant):
260                 def __init__(self,sm):
261                         nervlist = [(24*60*60, lambda: logger.critical("Space is now open for 24h. Is everything all right?"))]
262                         super().__init__(sm, nervlist)
263                         self.last_buzzed = None
264                         self.api().set_state(True)
265                 def handle_pins_event(self):
266                         pins = self.pins()
267                         if pins.bell_ringing and not self.old_pins().bell_ringing: # first thing to check: edge detection
268                                 # someone just pressed the bell
269                                 logger.info("StateMachine: buzzing because of bell ringing in StateAuf")
270                                 self.actor().act(Actor.CMD_BUZZ)
271                         if not pins.space_active:
272                                 logger.info("StateMachine: space switch turned off - starting leaving procedure")
273                                 return StateMachine.StateAboutToLeave(self.state_machine)
274                         if not pins.door_closed and self.old_pins().door_closed and random.random() <= HELLO_PROBABILITY:
275                                 play_sound("hello")
276                         return super().handle_pins_event()
277                 def on_leave(self):
278                         self.api().set_state(False)
279         
280         class StateLocking(AbstractUnlockedState):
281                 def __init__(self,sm):
282                         # construct a nervlist
283                         nervlist = [(CLOSE_REPEAT_TIMEOUT, lambda: self.actor().act(Actor.CMD_LOCK)) for t in range(CLOSE_REPEAT_NUMBER)]
284                         nervlist += [(CLOSE_REPEAT_TIMEOUT, self.could_not_close)]
285                         super().__init__(sm, nervlist)
286                         if self.pins().door_closed: # this should always be true, but just to be sure...
287                                 self.actor().act(Actor.CMD_LOCK)
288                 def handle_pins_event(self):
289                         pins = self.pins()
290                         if not pins.door_closed:
291                                 # TODO play a sound? This shouldn't happen, door was opened while we are locking
292                                 logger.warning("StateMachine: door manually opened during locking")
293                                 return StateMachine.StateAboutToOpen(self.state_machine)
294                         # TODO do anything here if the switch is activated now?
295                         return super().handle_pins_event()
296                 def handle_cmd_unlock_event(self,callback):
297                         callback("409 conflict: The sphinx is currently trying to lock the door. Try again later.")
298                 def could_not_close(self):
299                         logger.critical("StateMachine: Couldn't close door after %d tries. Going back to StateAboutToOpen." % CLOSE_REPEAT_NUMBER)
300                         return StateMachine.StateAboutToOpen(self.state_machine)
301         
302         class StateAboutToLeave(AbstractUnlockedState):
303                 def __init__(self, sm):
304                         nervlist = [(LEAVE_TIMEOUT, lambda: StateMachine.StateLocking(self.state_machine))]
305                         super().__init__(sm, nervlist)
306                 def handle_pins_event(self):
307                         if not self.pins().door_closed:
308                                 return StateMachine.StateLeaving(self.state_machine)
309                         if self.pins().space_active:
310                                 logger.info("Space re-activated, cancelling leaving procedure")
311                                 return StateMachine.StateAuf(self.state_machine)
312                         return super().handle_pins_event()
313         
314         class StateLeaving(AbstractUnlockedState):
315                 def __init__(self, sm):
316                         nervlist = [(LEAVE_TIMEOUT, lambda: StateMachine.StateAboutToOpen(self.state_machine))]
317                         super().__init__(sm, nervlist)
318                 def handle_pins_event(self):
319                         if self.pins().door_closed:
320                                 logger.info("The space was left, locking the door")
321                                 return StateMachine.StateLocking(self.state_machine)
322                         if self.pins().space_active:
323                                 logger.info("Space re-activated, cancelling leaving procedure")
324                                 return StateMachine.StateAuf(self.state_machine)
325                         return super().handle_pins_event()
326         
327         def __init__(self, actor, waker, api, fallback = False):
328                 self.actor = actor
329                 self.api = api
330                 self.callback = ThreadFunction(self._callback, name="StateMachine")
331                 self.current_state = StateMachine.StateStart(self, fallback=fallback)
332                 self.pins = None
333                 self.old_pins = None
334                 waker.register(lambda: self.callback(StateMachine.CMD_WAKEUP), 1.0) # wake up every second
335                 # initially, the space is closed
336                 api.set_state(False)
337         
338         def stop (self):
339                 self.callback.stop()
340         
341         def _callback(self, cmd, arg=None):
342                 # update pins
343                 if cmd == StateMachine.CMD_PINS:
344                         self.pins = arg
345                 # handle stuff
346                 newstate = self.current_state.handle_event(cmd,arg) # returns None or an instance of the new state
347                 self.old_pins = self.pins
348                 while newstate is not None:
349                         assert isinstance(newstate, StateMachine.State), "I should get a state"
350                         self.current_state.on_leave()
351                         logger.debug("StateMachine: Doing state transition %s -> %s" % (self.current_state.__class__.__name__, newstate.__class__.__name__))
352                         self.current_state = newstate
353                         newstate = self.current_state.handle_event(StateMachine.CMD_PINS, self.pins)