redzed 25.12.30__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redzed/__init__.py +44 -0
- redzed/base_block.py +132 -0
- redzed/block.py +290 -0
- redzed/blocklib/__init__.py +26 -0
- redzed/blocklib/counter.py +45 -0
- redzed/blocklib/fsm.py +554 -0
- redzed/blocklib/inputs.py +210 -0
- redzed/blocklib/outputs.py +361 -0
- redzed/blocklib/repeat.py +72 -0
- redzed/blocklib/timedate.py +150 -0
- redzed/blocklib/timeinterval.py +192 -0
- redzed/blocklib/timer.py +50 -0
- redzed/circuit.py +756 -0
- redzed/cron_service.py +243 -0
- redzed/debug.py +86 -0
- redzed/formula_trigger.py +205 -0
- redzed/initializers.py +249 -0
- redzed/signal_shutdown.py +64 -0
- redzed/undef.py +38 -0
- redzed/utils/__init__.py +14 -0
- redzed/utils/async_utils.py +145 -0
- redzed/utils/data_utils.py +116 -0
- redzed/utils/time_utils.py +262 -0
- redzed-25.12.30.dist-info/METADATA +52 -0
- redzed-25.12.30.dist-info/RECORD +28 -0
- redzed-25.12.30.dist-info/WHEEL +5 -0
- redzed-25.12.30.dist-info/licenses/LICENSE.txt +21 -0
- redzed-25.12.30.dist-info/top_level.txt +1 -0
redzed/blocklib/fsm.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event driven finite-state machine (FSM) extended with optional timers.
|
|
3
|
+
|
|
4
|
+
- - - - - -
|
|
5
|
+
Docs: https://redzed.readthedocs.io/en/latest/
|
|
6
|
+
Home: https://github.com/xitop/redzed/
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ['FSM']
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
14
|
+
import functools
|
|
15
|
+
import inspect
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
import types
|
|
19
|
+
import typing as t
|
|
20
|
+
|
|
21
|
+
import redzed
|
|
22
|
+
from redzed.utils import check_identifier, is_multiple, time_period, to_tuple
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_logger = logging.getLogger(__package__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _loop_to_unixtime(looptime: float) -> float:
|
|
29
|
+
"""Convert event loop time to standard Unix time."""
|
|
30
|
+
unixtime_func = time.time
|
|
31
|
+
looptime_func = asyncio.get_running_loop().time
|
|
32
|
+
unixbefore = unixtime_func()
|
|
33
|
+
loopnow = looptime_func()
|
|
34
|
+
unixafter = unixtime_func()
|
|
35
|
+
timediff = (unixbefore + unixafter) / 2 - loopnow
|
|
36
|
+
return looptime + timediff
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_ALLOWED_KINDS = [inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD]
|
|
40
|
+
|
|
41
|
+
@functools.cache
|
|
42
|
+
def _hook_args(func: Callable[..., t.Any]) -> int:
|
|
43
|
+
"""Check if *func* takes 0 or 1 argument."""
|
|
44
|
+
params = inspect.signature(func).parameters
|
|
45
|
+
plen = len(params)
|
|
46
|
+
if plen > 1 or any(p.kind not in _ALLOWED_KINDS for p in params.values()):
|
|
47
|
+
raise TypeError(
|
|
48
|
+
f"Function {func.__name__} is not usable as an FSM hook "
|
|
49
|
+
+ "(incompatible call signature)")
|
|
50
|
+
return plen
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FSM(redzed.Block):
|
|
54
|
+
"""
|
|
55
|
+
A base class for a Finite-state Machine with optional timers.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
# subclasses should define:
|
|
59
|
+
ALL_STATES: Sequence[str] = []
|
|
60
|
+
TIMED_STATES: Sequence[Sequence] = []
|
|
61
|
+
# each timed state: [state, duration, next_state]
|
|
62
|
+
EVENTS: Sequence[Sequence] = []
|
|
63
|
+
# each item: [event, [state1, state2, ..., stateN], next_state]
|
|
64
|
+
# or: [event, ..., next_state] <- literal ellipsis
|
|
65
|
+
# --- and redzed will translate that to: ---
|
|
66
|
+
_ct_default_state: str
|
|
67
|
+
# the default initial state (first item of ALL_STATES)
|
|
68
|
+
_ct_duration: dict[str, float]
|
|
69
|
+
# {timed_state: default_duration_in_seconds}
|
|
70
|
+
_ct_events: set[str]
|
|
71
|
+
# all valid events
|
|
72
|
+
_ct_methods: dict[str, dict[str, Callable[[t.Self, str], t.Any]]]
|
|
73
|
+
# summary of cond_EVENT, duration_TIMED_STATE enter_STATE and exit_STATE methods
|
|
74
|
+
_ct_valid_names: dict[str, Iterable[str]]
|
|
75
|
+
# helper for parsing
|
|
76
|
+
_ct_states: set[str]
|
|
77
|
+
# all valid states
|
|
78
|
+
_ct_timed_states: dict[str, str]
|
|
79
|
+
# {timed_state: following_state}
|
|
80
|
+
# keys: all timed states
|
|
81
|
+
_ct_transition: dict[tuple[str, str|None], str|None]
|
|
82
|
+
# the transition table - higher priority: {(event, state): next_state}
|
|
83
|
+
# the transition table - lower priority: {(event, None): next_state}
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def _check_state(cls, state: str) -> None:
|
|
87
|
+
if state not in cls._ct_states:
|
|
88
|
+
raise ValueError(f"FSM state '{state}' is unknown (missing in ALL_STATES)")
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def _add_transition(cls, event: str, state: str|None, next_state: str|None) -> None:
|
|
92
|
+
if state is None and next_state is None and redzed.get_debug_level() >= 2:
|
|
93
|
+
_logger.warning("Useless transition rule: [%s, ..., None]", event)
|
|
94
|
+
key = (event, state)
|
|
95
|
+
if key in cls._ct_transition:
|
|
96
|
+
state_msg = "..." if state is None else state
|
|
97
|
+
raise ValueError(f"Multiple transitions rules ['{event}', {state_msg}, ???]")
|
|
98
|
+
cls._ct_transition[key] = next_state
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def _build_tables(cls) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Build control tables from ALL_STATES, TIMED_STATES and EVENTS.
|
|
104
|
+
|
|
105
|
+
Control tables must be created for each subclass. The original
|
|
106
|
+
tables are left unchanged. All control tables are class
|
|
107
|
+
variables and have the '_ct_' prefix.
|
|
108
|
+
"""
|
|
109
|
+
# states
|
|
110
|
+
if not is_multiple(cls.ALL_STATES) or not cls.ALL_STATES:
|
|
111
|
+
raise ValueError("ALL_STATES: Expecting non-empty sequence of names")
|
|
112
|
+
for state in cls.ALL_STATES:
|
|
113
|
+
check_identifier(state, "FSM state name")
|
|
114
|
+
cls._ct_states = set(cls.ALL_STATES)
|
|
115
|
+
cls._ct_default_state = cls.ALL_STATES[0]
|
|
116
|
+
# timed states
|
|
117
|
+
cls._ct_duration = {}
|
|
118
|
+
cls._ct_timed_states = {}
|
|
119
|
+
for state, duration, next_state in cls.TIMED_STATES:
|
|
120
|
+
cls._check_state(state)
|
|
121
|
+
cls._check_state(next_state)
|
|
122
|
+
if state in cls._ct_timed_states:
|
|
123
|
+
raise ValueError(f"TIMED_STATES: Multiple rules for timed state '{state}'")
|
|
124
|
+
if duration is not None:
|
|
125
|
+
try:
|
|
126
|
+
cls._ct_duration[state] = time_period(duration, zero_ok=True)
|
|
127
|
+
except (ValueError, TypeError) as err:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"TIMED_STATES: could not convert duration of state '{state}' "
|
|
130
|
+
+ f"to seconds: {err}") from None
|
|
131
|
+
cls._ct_timed_states[state] = next_state
|
|
132
|
+
|
|
133
|
+
# events and state transitions
|
|
134
|
+
cls._ct_transition = {}
|
|
135
|
+
cls._ct_events = set()
|
|
136
|
+
for event, from_states, next_state in cls.EVENTS:
|
|
137
|
+
check_identifier(event, "FSM event name")
|
|
138
|
+
if event in cls._edt_handlers:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Ambiguous event '{event}': "
|
|
141
|
+
+ "the name is used for both FSM and Block event")
|
|
142
|
+
cls._ct_events.add(event)
|
|
143
|
+
if next_state is not None:
|
|
144
|
+
cls._check_state(next_state)
|
|
145
|
+
if from_states is ...:
|
|
146
|
+
# The ellipsis means any state. In control tables we are using None instead
|
|
147
|
+
cls._add_transition(event, None, next_state)
|
|
148
|
+
else:
|
|
149
|
+
if not is_multiple(from_states):
|
|
150
|
+
exc = ValueError(
|
|
151
|
+
"Expected is a literal ellipsis (...) or a sequence of states, "
|
|
152
|
+
+ f"got {from_states!r}")
|
|
153
|
+
exc.add_note(
|
|
154
|
+
f"Problem was found in the transition rule for event '{event}'")
|
|
155
|
+
if from_states in cls._ct_states:
|
|
156
|
+
exc.add_note(f"Did you mean: ['{from_states}'] ?")
|
|
157
|
+
raise exc
|
|
158
|
+
for fstate in from_states:
|
|
159
|
+
cls._check_state(fstate)
|
|
160
|
+
cls._add_transition(event, fstate, next_state)
|
|
161
|
+
|
|
162
|
+
# helper table: name 'prefix_suffix' is valid if prefix is a dict key
|
|
163
|
+
# and suffix is listed in the corresponding dict value
|
|
164
|
+
cls._ct_valid_names = {
|
|
165
|
+
'cond': cls._ct_events,
|
|
166
|
+
'duration': cls._ct_timed_states,
|
|
167
|
+
'enter': cls._ct_states,
|
|
168
|
+
'exit': cls._ct_states,
|
|
169
|
+
't': cls._ct_timed_states,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# class hooks
|
|
173
|
+
cls._ct_methods = {
|
|
174
|
+
'cond': {}, # data format: {EVENT: cond_EVENT method}
|
|
175
|
+
'duration': {}, # data format: {TIMED_STATE: duration_TIMED_STATE method}
|
|
176
|
+
'enter': {}, # {STATE: enter_STATE method}
|
|
177
|
+
'exit': {}, # {STATE: exit_STATE method}
|
|
178
|
+
}
|
|
179
|
+
for method_name, method in inspect.getmembers(cls):
|
|
180
|
+
if method_name.startswith('_') or not callable(method):
|
|
181
|
+
continue
|
|
182
|
+
try:
|
|
183
|
+
# ValueError in assignment if split into two pieces fails:
|
|
184
|
+
hook_type, name = method_name.split('_', 1)
|
|
185
|
+
hook_dict = cls._ct_methods[hook_type]
|
|
186
|
+
except (ValueError, KeyError):
|
|
187
|
+
continue
|
|
188
|
+
if name in cls._ct_valid_names[hook_type]:
|
|
189
|
+
hook_dict[name] = method
|
|
190
|
+
elif redzed.get_debug_level() >= 2:
|
|
191
|
+
_logger.warning(
|
|
192
|
+
"Method .%s() was not accepted by the FSM. "
|
|
193
|
+
"Check the name '%s' if necessary",
|
|
194
|
+
method_name, name)
|
|
195
|
+
|
|
196
|
+
def __init_subclass__(cls, *args, **kwargs) -> None:
|
|
197
|
+
"""Build control tables."""
|
|
198
|
+
# call super().__init_subclass__ first, we will then check for possible
|
|
199
|
+
# event name clashes with the Block._edt_handlers
|
|
200
|
+
super().__init_subclass__(*args, **kwargs)
|
|
201
|
+
try:
|
|
202
|
+
cls._build_tables()
|
|
203
|
+
except Exception as err:
|
|
204
|
+
err.add_note(
|
|
205
|
+
f"Error occurred in FSM '{cls.__name__}' during validation of control tables")
|
|
206
|
+
raise
|
|
207
|
+
|
|
208
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Create FSM.
|
|
211
|
+
|
|
212
|
+
Handle keyword arguments named t_TIMED_STATE, cond_EVENT,
|
|
213
|
+
enter_STATE and exit_STATE.
|
|
214
|
+
"""
|
|
215
|
+
if type(self) is FSM: # pylint: disable=unidiomatic-typecheck
|
|
216
|
+
raise TypeError("Can't instantiate abstract FSM class")
|
|
217
|
+
prefixed: dict[str, list[tuple[str, t.Any]]] = {
|
|
218
|
+
'cond': [],
|
|
219
|
+
'enter': [],
|
|
220
|
+
'exit': [],
|
|
221
|
+
't': [],
|
|
222
|
+
}
|
|
223
|
+
for arg in list(kwargs):
|
|
224
|
+
try:
|
|
225
|
+
hook_type, name = arg.split('_', 1)
|
|
226
|
+
hook_list = prefixed[hook_type]
|
|
227
|
+
except (ValueError, KeyError):
|
|
228
|
+
continue
|
|
229
|
+
valid_names = type(self)._ct_valid_names[hook_type]
|
|
230
|
+
if name in valid_names:
|
|
231
|
+
value = kwargs.pop(arg)
|
|
232
|
+
if value is not redzed.UNDEF:
|
|
233
|
+
hook_list.append((name, value))
|
|
234
|
+
else:
|
|
235
|
+
err = TypeError(
|
|
236
|
+
f"'{arg}' is an invalid keyword argument for {self.type_name}")
|
|
237
|
+
# Python 3.11 doesn't allow nested quotes in f-strings
|
|
238
|
+
names_msg = ', '.join(f"'{hook_type}_{n}'" for n in valid_names)
|
|
239
|
+
err.add_note(f"Valid are: {names_msg}")
|
|
240
|
+
raise err
|
|
241
|
+
# extra arguments are now removed from kwargs -> can call super().__init__
|
|
242
|
+
super().__init__(*args, **kwargs)
|
|
243
|
+
|
|
244
|
+
self._t_duration: dict[str, float] = {} # values passed as t_TIMED_STATE=duration
|
|
245
|
+
for state, value in prefixed['t']:
|
|
246
|
+
if (duration := time_period(value, passthrough=None, zero_ok=True)) is not None:
|
|
247
|
+
self._t_duration[state] = duration
|
|
248
|
+
self._instance_hooks = {
|
|
249
|
+
hook_type: {name: to_tuple(value) for name, value in prefixed[hook_type]}
|
|
250
|
+
for hook_type in ['cond', 'enter', 'exit']}
|
|
251
|
+
|
|
252
|
+
self._state: str|redzed.UndefType = redzed.UNDEF # storage for FSM state
|
|
253
|
+
self.sdata: dict[str, t.Any] = {} # storage for additional internal state data
|
|
254
|
+
# Restoring state differs in details from setting a new state.
|
|
255
|
+
# _restore_timer values:
|
|
256
|
+
# UNDEF = no state to be restored
|
|
257
|
+
# None = restore a not-timed state
|
|
258
|
+
# float = restore a timed state and this is its expiration time (UNIX timestamp)
|
|
259
|
+
self._restore_timer: float|None|redzed.UndefType = redzed.UNDEF
|
|
260
|
+
self._active_timer: asyncio.Handle|None = None
|
|
261
|
+
self._event_handler_lock = False # detection of recursive calls
|
|
262
|
+
self._edata: Mapping[str, t.Any]|None = None
|
|
263
|
+
# read-only data of the currently processed event
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def state(self) -> str|redzed.UndefType:
|
|
267
|
+
"""Return the FSM state (string) or UNDEF if not initialized."""
|
|
268
|
+
return self._state
|
|
269
|
+
|
|
270
|
+
def rz_export_state(self) -> tuple[str, float|None, dict[str, t.Any]]:
|
|
271
|
+
"""
|
|
272
|
+
Return the block's internal state.
|
|
273
|
+
|
|
274
|
+
The internal state is a broader term than the FSM state.
|
|
275
|
+
Internal state consist of 3 items:
|
|
276
|
+
- FSM state (str)
|
|
277
|
+
- timer expiration timestamp or None if there is no timer.
|
|
278
|
+
The timestamp uses UNIX time (float).
|
|
279
|
+
- additional state data (sdata, a dict)
|
|
280
|
+
"""
|
|
281
|
+
assert self._state is not redzed.UNDEF # @mypy: contract
|
|
282
|
+
timer = self._active_timer
|
|
283
|
+
# we are using both plain Handle (call_soon) and TimerHandle (call_later)
|
|
284
|
+
if isinstance(timer, asyncio.TimerHandle) and not timer.cancelled():
|
|
285
|
+
timestamp = _loop_to_unixtime(timer.when())
|
|
286
|
+
else:
|
|
287
|
+
timestamp = None
|
|
288
|
+
return (self._state, timestamp, self.sdata)
|
|
289
|
+
|
|
290
|
+
def rz_restore_state(self, internal_state: Sequence, /) -> None:
|
|
291
|
+
"""
|
|
292
|
+
Restore the internal state created by rz_export_state().
|
|
293
|
+
|
|
294
|
+
cond_STATE and enter_STATE are not executed, because the state
|
|
295
|
+
was already entered in the past. Now it is only restored.
|
|
296
|
+
"""
|
|
297
|
+
assert self._state is redzed.UNDEF # this is the very first init function
|
|
298
|
+
state, timestamp, sdata = internal_state
|
|
299
|
+
self._check_state(state)
|
|
300
|
+
if timestamp is not None:
|
|
301
|
+
if state not in self._ct_timed_states:
|
|
302
|
+
self.log_debug2(
|
|
303
|
+
"Rejecting saved state: a timer was saved in FSM state '%s', "
|
|
304
|
+
+ "but this state is now not timed", state)
|
|
305
|
+
return
|
|
306
|
+
if timestamp <= time.time():
|
|
307
|
+
self.log_debug2("Rejecting saved timed state, because it has expired")
|
|
308
|
+
return
|
|
309
|
+
# state accepted
|
|
310
|
+
self._restore_timer = timestamp
|
|
311
|
+
self.sdata = sdata
|
|
312
|
+
self._state = state
|
|
313
|
+
self._set_output(self._state)
|
|
314
|
+
|
|
315
|
+
def rz_init(self, init_value: str, /) -> None:
|
|
316
|
+
"""Set the initial FSM state."""
|
|
317
|
+
# Do not call self.event() for initialization.
|
|
318
|
+
# It would try to initialize before processing the event.
|
|
319
|
+
self._check_state(init_value)
|
|
320
|
+
self.log_debug1("initial state: %s", init_value)
|
|
321
|
+
self._state = init_value
|
|
322
|
+
self._set_output(self._state)
|
|
323
|
+
|
|
324
|
+
def rz_init_default(self) -> None:
|
|
325
|
+
"""Initialize the internal state."""
|
|
326
|
+
self.rz_init(self._ct_default_state)
|
|
327
|
+
|
|
328
|
+
def rz_start(self) -> None:
|
|
329
|
+
"""
|
|
330
|
+
Start activities according to the initial state.
|
|
331
|
+
|
|
332
|
+
Run 'enter' hooks unless the initial state was restored
|
|
333
|
+
from the persistent storage, i.e. has been entered already
|
|
334
|
+
in the past.
|
|
335
|
+
|
|
336
|
+
Start the timer if the initial state is timed.
|
|
337
|
+
"""
|
|
338
|
+
assert self._state is not redzed.UNDEF
|
|
339
|
+
# restored state
|
|
340
|
+
if self._restore_timer is not redzed.UNDEF:
|
|
341
|
+
if self._restore_timer is not None:
|
|
342
|
+
remaining = max(self._restore_timer - time.time(), 0.0)
|
|
343
|
+
self._set_timer(remaining, self._ct_timed_states[self._state])
|
|
344
|
+
self._restore_timer = redzed.UNDEF # value no longer needed
|
|
345
|
+
return
|
|
346
|
+
# new state
|
|
347
|
+
self._start_now(self._state)
|
|
348
|
+
|
|
349
|
+
def rz_stop(self) -> None:
|
|
350
|
+
"""Cleanup."""
|
|
351
|
+
self._stop_timer()
|
|
352
|
+
|
|
353
|
+
def _set_timer(self, duration: float, following_state: str) -> None:
|
|
354
|
+
"""Start the timer (low-level)."""
|
|
355
|
+
if zero_delay := duration <= 0.0:
|
|
356
|
+
duration = 0.0
|
|
357
|
+
self.log_debug1("timer: %.3fs before entering '%s'", duration, following_state)
|
|
358
|
+
loop = asyncio.get_running_loop()
|
|
359
|
+
if zero_delay:
|
|
360
|
+
self.log_debug2("note: zero delay is not possible due to overhead")
|
|
361
|
+
self._active_timer = loop.call_soon(self._goto, following_state)
|
|
362
|
+
else:
|
|
363
|
+
self._active_timer = loop.call_later(duration, self._goto, following_state)
|
|
364
|
+
|
|
365
|
+
def _start_timer(
|
|
366
|
+
self, edata_duration: float|str|None, following_state: str) -> None:
|
|
367
|
+
"""Start the timer before enterint the following_state."""
|
|
368
|
+
state = self._state
|
|
369
|
+
assert state is not redzed.UNDEF # starting a timer implies a timed state
|
|
370
|
+
duration = time_period(edata_duration, passthrough=None, zero_ok=True)
|
|
371
|
+
if duration is None:
|
|
372
|
+
duration = self._run_hooks('duration', state)
|
|
373
|
+
if duration is None:
|
|
374
|
+
duration = self._t_duration.get(state)
|
|
375
|
+
if duration is None:
|
|
376
|
+
duration = type(self)._ct_duration.get(state)
|
|
377
|
+
if duration is None: # not found or explicitly set to None
|
|
378
|
+
raise RuntimeError(f"Timer duration for state '{state}' not set")
|
|
379
|
+
if duration == float('inf'):
|
|
380
|
+
return
|
|
381
|
+
self._set_timer(duration, following_state)
|
|
382
|
+
|
|
383
|
+
def _stop_timer(self) -> None:
|
|
384
|
+
"""Stop the timer, if any."""
|
|
385
|
+
if (timer := self._active_timer) is not None:
|
|
386
|
+
if not timer.cancelled():
|
|
387
|
+
timer.cancel()
|
|
388
|
+
# do not rely on the existence of the private attribute '_scheduled'
|
|
389
|
+
if getattr(timer, '_scheduled', True):
|
|
390
|
+
self.log_debug2("timer: cancelled")
|
|
391
|
+
self._active_timer = None
|
|
392
|
+
|
|
393
|
+
def _yield_hooks(
|
|
394
|
+
self, hook_type: t.Literal['cond', 'duration', 'enter', 'exit'], name: str
|
|
395
|
+
) -> t.Iterator[Callable[[], t.Any]|Callable[[Mapping[str, t.Any]], t.Any]]:
|
|
396
|
+
"""
|
|
397
|
+
Yield all hooks of given type for given state/event.
|
|
398
|
+
"""
|
|
399
|
+
try:
|
|
400
|
+
hook = self._ct_methods[hook_type][name]
|
|
401
|
+
except KeyError:
|
|
402
|
+
pass
|
|
403
|
+
else:
|
|
404
|
+
# class_hook is an unbound method -> bind self
|
|
405
|
+
# pylint: disable-next=unnecessary-dunder-call
|
|
406
|
+
yield hook.__get__(self, type(self))
|
|
407
|
+
try:
|
|
408
|
+
hooks = self._instance_hooks[hook_type][name]
|
|
409
|
+
except KeyError:
|
|
410
|
+
pass
|
|
411
|
+
else:
|
|
412
|
+
yield from hooks
|
|
413
|
+
|
|
414
|
+
@t.overload
|
|
415
|
+
def _run_hooks(self, hook_type: t.Literal['cond'], name: str) -> bool: ...
|
|
416
|
+
# Return the logical conjunction of return values using short-circuit evaluation
|
|
417
|
+
@t.overload
|
|
418
|
+
def _run_hooks(self, hook_type: t.Literal['duration'], name: str) -> float|None: ...
|
|
419
|
+
# There are no 'duration' instance hooks. Return the result of the class hook or None
|
|
420
|
+
@t.overload
|
|
421
|
+
def _run_hooks(self, hook_type: t.Literal['enter', 'exit'], name: str) -> list[t.Any]: ...
|
|
422
|
+
# Return individual values (they are currently ignored).
|
|
423
|
+
def _run_hooks(
|
|
424
|
+
self, hook_type: t.Literal['cond', 'duration', 'enter', 'exit'], name: str
|
|
425
|
+
) -> bool|float|None|list[t.Any]:
|
|
426
|
+
"""Run hooks 'cond', 'duration', 'enter' or 'exit'."""
|
|
427
|
+
called = False
|
|
428
|
+
retvals = []
|
|
429
|
+
for hook in self._yield_hooks(hook_type, name):
|
|
430
|
+
if not called:
|
|
431
|
+
self.log_debug2("Calling hooks '%s_%s'", hook_type, name)
|
|
432
|
+
called = True
|
|
433
|
+
assert self._edata is not None # @mypy
|
|
434
|
+
rv = hook() if _hook_args(hook) == 0 else hook(self._edata) # type: ignore[call-arg]
|
|
435
|
+
retvals.append(rv)
|
|
436
|
+
if not rv and hook_type == 'cond':
|
|
437
|
+
break
|
|
438
|
+
if not called:
|
|
439
|
+
self.log_debug2("No '%s_%s' hooks found", hook_type, name)
|
|
440
|
+
if hook_type == 'cond':
|
|
441
|
+
return not retvals or bool(retvals[-1])
|
|
442
|
+
if hook_type == 'duration':
|
|
443
|
+
assert len(retvals) <= 1
|
|
444
|
+
return time_period(retvals[0], passthrough=None, zero_ok=True) if retvals else None
|
|
445
|
+
return retvals
|
|
446
|
+
|
|
447
|
+
def _event__get_config(self, _edata: redzed.EventData) -> dict[str, t.Any]:
|
|
448
|
+
"""Debugging aid '_get_config'."""
|
|
449
|
+
cls = type(self)
|
|
450
|
+
# pylint: disable=protected-access
|
|
451
|
+
return {
|
|
452
|
+
'durations': cls._ct_duration | self._t_duration,
|
|
453
|
+
'events': cls._ct_events,
|
|
454
|
+
'states': cls._ct_states,
|
|
455
|
+
'timed_transitions': cls._ct_timed_states,
|
|
456
|
+
'transitions': cls._ct_transition,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
def _fsm_event_handler(self, etype: str) -> bool:
|
|
460
|
+
"""
|
|
461
|
+
Handle event. Check validity and conditions.
|
|
462
|
+
|
|
463
|
+
Timed states look for 'duration' key in 'edata'. If it is
|
|
464
|
+
present, the value overrides the default timer duration.
|
|
465
|
+
|
|
466
|
+
Return value:
|
|
467
|
+
True = transition accepted and executed
|
|
468
|
+
False = transition rejected
|
|
469
|
+
"""
|
|
470
|
+
assert self._state is not redzed.UNDEF # @mypy: tested in caller
|
|
471
|
+
start_event = False # special event used only when booting the FSM
|
|
472
|
+
next_state: str|None
|
|
473
|
+
if etype.startswith("Goto:"):
|
|
474
|
+
next_state = etype[5:] # strip "Goto:" prefix
|
|
475
|
+
self._check_state(next_state)
|
|
476
|
+
elif etype.startswith("Start:"):
|
|
477
|
+
next_state = etype[6:] # strip prefix
|
|
478
|
+
if next_state != self._state:
|
|
479
|
+
raise RuntimeError("Event 'Start:STATE' was used incorrectly")
|
|
480
|
+
start_event = True
|
|
481
|
+
else:
|
|
482
|
+
if etype not in self._ct_events:
|
|
483
|
+
raise redzed.UnknownEvent("Unknown event type")
|
|
484
|
+
# not using .get(key) because None and not found are different cases
|
|
485
|
+
key: tuple[str, str|None]
|
|
486
|
+
if (key := (etype, self._state)) not in self._ct_transition:
|
|
487
|
+
key = (etype, None)
|
|
488
|
+
next_state = self._ct_transition.get(key)
|
|
489
|
+
if next_state is None:
|
|
490
|
+
self.log_debug2(
|
|
491
|
+
"No transition defined for event '%s' in state '%s'", etype, self._state)
|
|
492
|
+
return False
|
|
493
|
+
if not start_event and not self._run_hooks('cond', etype):
|
|
494
|
+
self.log_debug2(
|
|
495
|
+
"event '%s' (%s -> %s) was rejected by cond_%s",
|
|
496
|
+
etype, self._state, next_state, etype)
|
|
497
|
+
return False
|
|
498
|
+
|
|
499
|
+
if not start_event:
|
|
500
|
+
self._run_hooks('exit', self._state)
|
|
501
|
+
self._stop_timer()
|
|
502
|
+
self.log_debug1("state: %s -> %s (event: %s)", self._state, next_state, etype)
|
|
503
|
+
self._state = next_state
|
|
504
|
+
self._set_output(self._state)
|
|
505
|
+
if (following_state := self._ct_timed_states.get(self._state)) is not None:
|
|
506
|
+
assert self._edata is not None # @mypy
|
|
507
|
+
self._start_timer(self._edata.get('duration'), following_state)
|
|
508
|
+
self._run_hooks('enter', self._state)
|
|
509
|
+
return True
|
|
510
|
+
|
|
511
|
+
def _default_event_handler(self, etype: str, edata: redzed.EventData) -> bool:
|
|
512
|
+
"""
|
|
513
|
+
Wrapper creating a separate context.
|
|
514
|
+
|
|
515
|
+
Refer to _fsm_event_handler().
|
|
516
|
+
"""
|
|
517
|
+
if self._state is redzed.UNDEF:
|
|
518
|
+
raise RuntimeError(f"{self}: Received event '{etype}' before initialization")
|
|
519
|
+
if self._event_handler_lock:
|
|
520
|
+
raise RuntimeError("Recursion error: Got an event while handling an event.")
|
|
521
|
+
self._event_handler_lock = True
|
|
522
|
+
self._edata = types.MappingProxyType(edata)
|
|
523
|
+
try:
|
|
524
|
+
return self._fsm_event_handler(etype)
|
|
525
|
+
except Exception as err:
|
|
526
|
+
err.add_note(f"{self}: Error occurred while handling event '{etype}'")
|
|
527
|
+
raise
|
|
528
|
+
finally:
|
|
529
|
+
self._event_handler_lock = False
|
|
530
|
+
self._edata = None
|
|
531
|
+
|
|
532
|
+
def _send_synthetic_event(self, etype: str, edata: redzed.EventData|None = None) -> None:
|
|
533
|
+
"""
|
|
534
|
+
Send an synthetic event (implementation detail).
|
|
535
|
+
|
|
536
|
+
To protect the FSM, the used event names are not
|
|
537
|
+
accepted by the .event() entry point.
|
|
538
|
+
"""
|
|
539
|
+
if edata:
|
|
540
|
+
self.log_debug2("Synthetic event '%s', edata: %s", etype, edata)
|
|
541
|
+
else:
|
|
542
|
+
self.log_debug2("Synthetic event '%s'", etype)
|
|
543
|
+
# we muss bypass .event()
|
|
544
|
+
self._default_event_handler(etype, {} if edata is None else edata)
|
|
545
|
+
|
|
546
|
+
def _goto(self, state: str) -> None:
|
|
547
|
+
"""Unconditionally go to 'state'. To be used by the FSM itself only!"""
|
|
548
|
+
self._send_synthetic_event(f"Goto:{state}")
|
|
549
|
+
if self.rz_persistence & redzed.PersistenceFlags.EVENT:
|
|
550
|
+
self.circuit.save_persistent_state(self)
|
|
551
|
+
|
|
552
|
+
def _start_now(self, state: str) -> None:
|
|
553
|
+
"""Start after initialization. To be used by the FSM itself only!"""
|
|
554
|
+
self._send_synthetic_event(f"Start:{state}")
|