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