Repository: missionpinball/mpf

Issue:
Add timeouts to logic blocks
Add a timeout to logic blocks such as counters, sequences and sequels. It should reset the block if it expires. This should only cover this very basic usecase and everything advanced can use an external timer. 

Discussion:
jabdoa2: Looks good on the first glance. Thanks for also extending the tests. I will rebase it later (seems to be based on an older fork - dont worry I can fix that).
atummons: Sounds good. Yeah I forked for my other fix, and then thought I got back to this one.  Had to google how to wipe it.  Its saying I am 57 commits behind.  I tried to bring it back to fresh before I started, but clearly didn't do it right.

How do I get my fork to be back to up even with dev?

Patch:
diff --git a/mpf/config_spec.yaml b/mpf/config_spec.yaml
index 4f90ba675..dce39ea73 100644
--- a/mpf/config_spec.yaml
+++ b/mpf/config_spec.yaml
@@ -835,6 +835,7 @@ logic_blocks_common:
     start_enabled: single|bool|None
     events_when_complete: list|event_posted|None
     events_when_hit: list|event_posted|None
+    logic_block_timeout: single|ms|0
 accruals:
     __valid_in__: machine, mode
     __type__: device
diff --git a/mpf/devices/logic_blocks.py b/mpf/devices/logic_blocks.py
index 54c88591b..5a9cf2747 100644
--- a/mpf/devices/logic_blocks.py
+++ b/mpf/devices/logic_blocks.py
@@ -32,11 +32,12 @@ class LogicBlock(SystemWideDevice, ModeDevice):
 
     """Parent class for each of the logic block classes."""
 
-    __slots__ = ["_state", "_start_enabled", "player_state_variable"]
+    __slots__ = ["delay", "_state", "_start_enabled", "player_state_variable"]
 
     def __init__(self, machine: MachineController, name: str) -> None:
         """Initialize logic block."""
         super().__init__(machine, name)
+        self.delay = DelayManager(self.machine)
         self._state = None          # type: Optional[LogicBlockState]
         self._start_enabled = None  # type: Optional[bool]
 
@@ -189,6 +190,7 @@ class LogicBlock(SystemWideDevice, ModeDevice):
         self.debug_log("Enabling")
         self.enabled = True
         self.post_update_event()
+        self._logic_block_timer_start()
 
     def _post_hit_events(self, **kwargs):
         self.post_update_event()
@@ -221,6 +223,7 @@ class LogicBlock(SystemWideDevice, ModeDevice):
         self.debug_log("Disabling")
         self.enabled = False
         self.post_update_event()
+        self.delay.remove("timeout")
 
     @event_handler(4)
     def event_reset(self, **kwargs):
@@ -238,6 +241,26 @@ class LogicBlock(SystemWideDevice, ModeDevice):
         self.value = self.get_start_value()
         self.debug_log("Resetting")
         self.post_update_event()
+        self._logic_block_timer_start()
+
+    def _logic_block_timer_start(self):
+        if self.config['logic_block_timeout']:
+            self.debug_log("Setting up a logic block timer for %sms",
+                           self.config['logic_block_timeout'])
+
+            self.delay.reset(name="timeout",
+                             ms=self.config['logic_block_timeout'],
+                             callback=self._logic_block_timeout)
+
+    def _logic_block_timeout(self):
+        """Reset the progress towards completion of this logic block when timer expires.
+
+        Automatically called when one of the logic_block_timer_complete
+        events is called.
+        """
+        self.info_log("Logic Block timeouted")
+        self.machine.events.post("{}_timeout".format(self.name))
+        self.reset()
 
     @event_handler(5)
     def event_restart(self, **kwargs):
@@ -268,6 +291,7 @@ class LogicBlock(SystemWideDevice, ModeDevice):
 
         # otherwise mark as completed
         self.completed = True
+        self.delay.remove("timeout")
 
         self.debug_log("Complete")
         if self.config['events_when_complete']:
@@ -313,7 +337,6 @@ class Counter(LogicBlock):
     def __init__(self, machine: MachineController, name: str) -> None:
         """Initialise counter."""
         super().__init__(machine, name)
-        self.delay = DelayManager(self.machine)
 
         self.ignore_hits = False
         self.hit_value = -1


Test patch:
diff --git a/mpf/tests/machine_files/logic_blocks/config/config.yaml b/mpf/tests/machine_files/logic_blocks/config/config.yaml
index 6870cdfc8..cd75e1c12 100644
--- a/mpf/tests/machine_files/logic_blocks/config/config.yaml
+++ b/mpf/tests/machine_files/logic_blocks/config/config.yaml
@@ -59,6 +59,19 @@ accruals:
         enable_events: accrual10_enable
         disable_events: accrual10_disable
         reset_events: accrual10_reset
+    accrual7:
+        events:
+          - accrual7_step1
+          - accrual7_step2
+          - accrual7_step3
+        events_when_complete: accrual7_complete
+        events_when_hit: accrual7_hit
+        reset_on_complete: True
+        disable_on_complete: False
+        enable_events: accrual7_enable
+        disable_events: accrual7_disable
+        reset_events: accrual7_reset
+        logic_block_timeout: 50
 counters:
     counter1:
         count_events: counter1_count
@@ -91,6 +104,16 @@ counters:
         reset_events: counter4_reset
     counter5:
         count_events: counter5_count
+    counter9:
+        count_events: counter9_count
+        starting_count: 5
+        count_complete_value: 0
+        direction: down
+        enable_events: counter9_enable
+        disable_events: counter9_disable
+        restart_events: counter9_restart
+        reset_events: counter9_reset
+        logic_block_timeout: 50
 sequences:
     sequence1:
         events:
@@ -101,6 +124,16 @@ sequences:
         enable_events: sequence1_enable
         disable_events: sequence1_disable
         reset_events: sequence1_reset
+    sequence2:
+        events:
+            - sequence2_step1a, sequence2_step1b
+            - sequence2_step2a, sequence2_step2b
+            - sequence2_step3a, sequence2_step3b
+        events_when_complete: sequence2_complete
+        enable_events: sequence2_enable
+        disable_events: sequence2_disable
+        reset_events: sequence2_reset
+        logic_block_timeout: 50
 
 # logic blocks in mode1
 modes:
diff --git a/mpf/tests/test_LogicBlocks.py b/mpf/tests/test_LogicBlocks.py
index ac86c4b43..29490a6dd 100644
--- a/mpf/tests/test_LogicBlocks.py
+++ b/mpf/tests/test_LogicBlocks.py
@@ -758,3 +758,105 @@ class TestLogicBlocks(MpfFakeGameTestCase):
         self.post_event("counter5_count")
 
         self.assertEqual(3, self.machine.counters["counter5"].value)
+
+    def test_counter_delay_timeout(self):
+        self.start_game()
+        self.mock_event("logicblock_counter9_complete")
+        self.mock_event("logicblock_counter9_hit")
+
+        self.post_event("counter9_enable")
+        for i in range(4):
+            self.post_event("counter9_count")
+            self.advance_time_and_run(.01)
+            self.assertEqual(0, self._events["logicblock_counter9_complete"])
+
+        # post final event to complete
+        self.post_event("counter9_count")
+        self.assertEqual(1, self._events["logicblock_counter9_complete"])
+        self.assertEqual(5, self._events["logicblock_counter9_hit"])
+
+        #restart (reset and enable)
+        self.post_event("counter9_restart")
+
+        # 10 more hits with delay causing timeout
+        for i in range(10):
+            self.post_event("counter9_count")
+            self.advance_time_and_run(1)
+        self.assertEqual(1, self._events["logicblock_counter9_complete"])
+        self.assertEqual(15, self._events["logicblock_counter9_hit"])
+
+    def test_sequence_delay_timeout(self):
+        self.start_game()
+        self.mock_event("sequence2_complete")
+        self.mock_event("logicblock_sequence2_hit")
+
+        self.post_event("sequence2_enable")
+
+        # no timer reset
+        self.post_event("sequence2_step1a")
+        self.post_event("sequence2_step2a")
+        self.post_event("sequence2_step3a")
+        self.assertEqual(1, self._events["sequence2_complete"])
+        self.assertEqual(3, self._events["logicblock_sequence2_hit"])
+
+        # enable and reset
+        self.post_event("sequence2_enable")
+        self.post_event("sequence2_reset")
+
+        # timer expired
+        self.post_event("sequence2_step1a")
+        self.assertEqual(4, self._events["logicblock_sequence2_hit"])
+        self.advance_time_and_run(1)
+        self.post_event("sequence2_step2a")
+        self.post_event("sequence2_step3a")
+        self.assertEqual(1, self._events["sequence2_complete"])
+        self.assertEqual(4, self._events["logicblock_sequence2_hit"])
+
+        #time expired and restart
+        self.post_event("sequence2_step1a")
+        self.advance_time_and_run(.1)
+        self.post_event("sequence2_step1a")
+        self.post_event("sequence2_step2a")
+        self.advance_time_and_run(.01)
+        self.post_event("sequence2_step3a")
+        self.assertEqual(2, self._events["sequence2_complete"])
+        self.assertEqual(8, self._events["logicblock_sequence2_hit"])
+
+    def test_accruals_delay_timeout(self):
+        self.start_game()
+        self.mock_event("accrual7_complete")
+        self.mock_event("accrual7_hit")
+
+        # enable accrual
+        self.post_event("accrual7_enable")
+
+        # no timer reset
+        self.post_event("accrual7_step1")
+        self.post_event("accrual7_step2")
+        self.post_event("accrual7_step3")
+        self.assertEqual(1, self._events["accrual7_complete"])
+        self.assertEqual(3, self._events["accrual7_hit"])
+
+        # time advance after each step but under timeout
+        self.post_event("accrual7_step1")
+        self.advance_time_and_run(.01)
+        self.post_event("accrual7_step2")
+        self.advance_time_and_run(.01)
+        self.post_event("accrual7_step3")
+        self.assertEqual(2, self._events["accrual7_complete"])
+        self.assertEqual(6, self._events["accrual7_hit"])
+
+        # timer advance after each step over timeout
+        self.post_event("accrual7_step1")
+        self.advance_time_and_run(1)
+        self.post_event("accrual7_step2")
+        self.advance_time_and_run(1)
+        self.post_event("accrual7_step3")
+        self.assertEqual(2, self._events["accrual7_complete"])
+        self.assertEqual(9, self._events["accrual7_hit"])
+
+        #final two steps without additional time passed
+        self.post_event("accrual7_step1")
+        self.post_event("accrual7_step2")
+        self.assertEqual(3, self._events["accrual7_complete"])
+        self.assertEqual(11, self._events["accrual7_hit"])
\ No newline at end of file