unaiverse 0.1.6__cp314-cp314-musllinux_1_2_aarch64.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.
Potentially problematic release.
This version of unaiverse might be problematic. Click here for more details.
- unaiverse/__init__.py +19 -0
- unaiverse/agent.py +2008 -0
- unaiverse/agent_basics.py +1846 -0
- unaiverse/clock.py +191 -0
- unaiverse/dataprops.py +1209 -0
- unaiverse/hsm.py +1880 -0
- unaiverse/modules/__init__.py +18 -0
- unaiverse/modules/cnu/__init__.py +17 -0
- unaiverse/modules/cnu/cnus.py +536 -0
- unaiverse/modules/cnu/layers.py +261 -0
- unaiverse/modules/cnu/psi.py +60 -0
- unaiverse/modules/hl/__init__.py +15 -0
- unaiverse/modules/hl/hl_utils.py +411 -0
- unaiverse/modules/networks.py +1509 -0
- unaiverse/modules/utils.py +680 -0
- unaiverse/networking/__init__.py +16 -0
- unaiverse/networking/node/__init__.py +18 -0
- unaiverse/networking/node/connpool.py +1261 -0
- unaiverse/networking/node/node.py +2223 -0
- unaiverse/networking/node/profile.py +446 -0
- unaiverse/networking/node/tokens.py +79 -0
- unaiverse/networking/p2p/__init__.py +198 -0
- unaiverse/networking/p2p/go.mod +127 -0
- unaiverse/networking/p2p/go.sum +548 -0
- unaiverse/networking/p2p/golibp2p.py +18 -0
- unaiverse/networking/p2p/golibp2p.pyi +135 -0
- unaiverse/networking/p2p/lib.go +2714 -0
- unaiverse/networking/p2p/lib.go.sha256 +1 -0
- unaiverse/networking/p2p/lib_types.py +312 -0
- unaiverse/networking/p2p/message_pb2.py +63 -0
- unaiverse/networking/p2p/messages.py +265 -0
- unaiverse/networking/p2p/mylogger.py +77 -0
- unaiverse/networking/p2p/p2p.py +929 -0
- unaiverse/networking/p2p/proto-go/message.pb.go +616 -0
- unaiverse/networking/p2p/unailib.cpython-314-aarch64-linux-musl.so +0 -0
- unaiverse/streamlib/__init__.py +15 -0
- unaiverse/streamlib/streamlib.py +210 -0
- unaiverse/streams.py +770 -0
- unaiverse/utils/__init__.py +16 -0
- unaiverse/utils/ask_lone_wolf.json +27 -0
- unaiverse/utils/lone_wolf.json +19 -0
- unaiverse/utils/misc.py +305 -0
- unaiverse/utils/sandbox.py +293 -0
- unaiverse/utils/server.py +435 -0
- unaiverse/world.py +175 -0
- unaiverse-0.1.6.dist-info/METADATA +365 -0
- unaiverse-0.1.6.dist-info/RECORD +50 -0
- unaiverse-0.1.6.dist-info/WHEEL +5 -0
- unaiverse-0.1.6.dist-info/licenses/LICENSE +43 -0
- unaiverse-0.1.6.dist-info/top_level.txt +1 -0
unaiverse/hsm.py
ADDED
|
@@ -0,0 +1,1880 @@
|
|
|
1
|
+
"""
|
|
2
|
+
█████ █████ ██████ █████ █████ █████ █████ ██████████ ███████████ █████████ ██████████
|
|
3
|
+
░░███ ░░███ ░░██████ ░░███ ░░███ ░░███ ░░███ ░░███░░░░░█░░███░░░░░███ ███░░░░░███░░███░░░░░█
|
|
4
|
+
░███ ░███ ░███░███ ░███ ██████ ░███ ░███ ░███ ░███ █ ░ ░███ ░███ ░███ ░░░ ░███ █ ░
|
|
5
|
+
░███ ░███ ░███░░███░███ ░░░░░███ ░███ ░███ ░███ ░██████ ░██████████ ░░█████████ ░██████
|
|
6
|
+
░███ ░███ ░███ ░░██████ ███████ ░███ ░░███ ███ ░███░░█ ░███░░░░░███ ░░░░░░░░███ ░███░░█
|
|
7
|
+
░███ ░███ ░███ ░░█████ ███░░███ ░███ ░░░█████░ ░███ ░ █ ░███ ░███ ███ ░███ ░███ ░ █
|
|
8
|
+
░░████████ █████ ░░█████░░████████ █████ ░░███ ██████████ █████ █████░░█████████ ██████████
|
|
9
|
+
░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░ ░░░░░ ░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░ ░░░░░░░░░░
|
|
10
|
+
A Collectionless AI Project (https://collectionless.ai)
|
|
11
|
+
Registration/Login: https://unaiverse.io
|
|
12
|
+
Code Repositories: https://github.com/collectionlessai/
|
|
13
|
+
Main Developers: Stefano Melacci (Project Leader), Christian Di Maio, Tommaso Guidi
|
|
14
|
+
"""
|
|
15
|
+
import io
|
|
16
|
+
import os
|
|
17
|
+
import json
|
|
18
|
+
import copy
|
|
19
|
+
import html
|
|
20
|
+
import time
|
|
21
|
+
import inspect
|
|
22
|
+
import graphviz
|
|
23
|
+
import importlib.resources
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Action:
|
|
28
|
+
|
|
29
|
+
# Candidate argument names (when calling an action) that tells that such an action is multi-steps
|
|
30
|
+
STEPS_ARG_NAMES = {'steps', 'samples'}
|
|
31
|
+
SECONDS_ARG_NAMES = {'time'}
|
|
32
|
+
TIMEOUT_ARG_NAMES = {'timeout'}
|
|
33
|
+
DELAY_ARG_NAMES = {'delay'}
|
|
34
|
+
COMPLETED_NAMES = {'_completed'}
|
|
35
|
+
REQUESTER_ARG_NAMES = {'_requester'}
|
|
36
|
+
REQUEST_TIME_NAMES = {'_request_time'}
|
|
37
|
+
REQUEST_UUID_NAMES = {'_request_uuid'}
|
|
38
|
+
NOT_READY_PREFIXES = ('get_', 'got_', 'do_', 'done_')
|
|
39
|
+
KNOWN_SINGLE_STEP_ACTION_PREFIXES = ('ask_',)
|
|
40
|
+
|
|
41
|
+
# Completion reasons
|
|
42
|
+
MAX_STEPS_REACHED = 0 # Single-step actions always complete due to this reason
|
|
43
|
+
MAX_TIME_REACHED = 1
|
|
44
|
+
MAX_TIMEOUT_DURING_ATTEMPTS_REACHED = 2
|
|
45
|
+
|
|
46
|
+
# Output print function
|
|
47
|
+
out_fcn = print
|
|
48
|
+
|
|
49
|
+
def __init__(self, name: str, args: dict, actionable: object,
|
|
50
|
+
idx: int = -1,
|
|
51
|
+
ready: bool = True,
|
|
52
|
+
wildcards: dict[str, str | float | int] | None = None,
|
|
53
|
+
msg: str | None = None):
|
|
54
|
+
"""Initializes an `Action` object, which encapsulates a method to be executed on a given object (`actionable`)
|
|
55
|
+
with specified arguments. It sets up various properties for managing multistep actions, including
|
|
56
|
+
`total_steps`, `total_time`, and `timeout`. It also handles wildcard argument replacement and checks for the
|
|
57
|
+
existence of required parameters. It identifies if the action is a 'not ready' type (e.g., `do_`, `get_`) and
|
|
58
|
+
sets its initial status accordingly.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
name: The name of the method to call.
|
|
62
|
+
args: A dictionary of arguments for the method.
|
|
63
|
+
actionable: The object on which the method will be executed.
|
|
64
|
+
idx: A unique ID for the action.
|
|
65
|
+
ready: A boolean indicating if the action is ready to be executed.
|
|
66
|
+
wildcards: A dictionary for replacing placeholder values in arguments.
|
|
67
|
+
msg: An optional human-readable message.
|
|
68
|
+
"""
|
|
69
|
+
# Basic properties
|
|
70
|
+
self.name = name # Name of the action (name of the corresponding method)
|
|
71
|
+
self.args = args # Dictionary of arguments to pass to the action
|
|
72
|
+
self.actionable = actionable # Object on which the method whose name is self.name is searched
|
|
73
|
+
self.ready = ready # Boolean flag telling if the action can considered ready to be executed
|
|
74
|
+
self.requests = {} # List of requests to make this action ready to be executed (customizable)
|
|
75
|
+
self.id = idx # Unique ID of the action (-1 if not needed)
|
|
76
|
+
self.msg = msg # Human-readable message associated to this instance of action
|
|
77
|
+
|
|
78
|
+
# Fix UNICODE chars
|
|
79
|
+
if self.msg is not None:
|
|
80
|
+
self.msg = html.unescape(self.msg)
|
|
81
|
+
|
|
82
|
+
# Reference elements
|
|
83
|
+
self.args_with_wildcards = copy.deepcopy(self.args) # Backup of the originally provided arguments
|
|
84
|
+
self.__fcn = self.__action_name_to_callable(name) # The real method to be called
|
|
85
|
+
self.__sig = inspect.signature(self.__fcn) # Signature of the method for argument inspection
|
|
86
|
+
|
|
87
|
+
# Parameter names and default values
|
|
88
|
+
self.param_list = [] # Full list of the parameters that the action supports
|
|
89
|
+
self.param_to_default_value = {} # From parameter to its default value, if any
|
|
90
|
+
self.__get_action_params() # This will fill the two attributes above
|
|
91
|
+
self.__check_if_args_exist(self.args, exception=True) # Checking arguments
|
|
92
|
+
|
|
93
|
+
# Argument values replaced by wildcards (commonly assumed to be in the format <value>)
|
|
94
|
+
self.wildcards = wildcards if wildcards is not None else {} # Value-to-value (es: <playlist> to this:and:this)
|
|
95
|
+
self.__replace_wildcard_values() # This will alter self.arg in function of the provided wildcards
|
|
96
|
+
|
|
97
|
+
# Number of steps of this function
|
|
98
|
+
self.__step = -1 # Default initial step index (remark: "step INDEX", so when it is 0 it means a step was done)
|
|
99
|
+
self.__total_steps = 1 # Total step of an action (a multi-steps action has != 1 steps)
|
|
100
|
+
self.__guess_total_steps(self.__get_actual_params({})) # This will "guess" the value of self.__total_steps
|
|
101
|
+
|
|
102
|
+
# Time-based metrics
|
|
103
|
+
self.__starting_time = 0
|
|
104
|
+
self.__total_time = 0 # A total time <= 0 means "no total time at all"
|
|
105
|
+
self.__guess_total_time(self.__get_actual_params({})) # This will "guess" the value of self.__total_time
|
|
106
|
+
|
|
107
|
+
# Time-based metrics
|
|
108
|
+
self.__timeout_starting_time = 0
|
|
109
|
+
self.__timeout = 0 # A timeout <= 0 means "no total time at all"
|
|
110
|
+
self.__guess_timeout(self.__get_actual_params({})) # This will "guess" the value of self.__timeout
|
|
111
|
+
|
|
112
|
+
# Time-based metrics
|
|
113
|
+
self.__delay = 0
|
|
114
|
+
self.__guess_delay(self.__get_actual_params({})) # This will "guess" the value of self.__delay
|
|
115
|
+
|
|
116
|
+
# Fixing (if no options are specified, assuming a single-step action)
|
|
117
|
+
if self.__total_steps <= 0 and self.__total_time <= 0:
|
|
118
|
+
self.__total_steps = 1
|
|
119
|
+
|
|
120
|
+
# Fixing (forcing NOT-ready on some actions)
|
|
121
|
+
for prefix in Action.NOT_READY_PREFIXES:
|
|
122
|
+
if self.name.startswith(prefix):
|
|
123
|
+
self.ready = False
|
|
124
|
+
|
|
125
|
+
self.__has_completion_step = False
|
|
126
|
+
for completed_name in Action.COMPLETED_NAMES:
|
|
127
|
+
if completed_name in self.param_list:
|
|
128
|
+
self.__has_completion_step = True
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
# Status
|
|
132
|
+
self.__cannot_be_run_anymore = False
|
|
133
|
+
|
|
134
|
+
def __call__(self, requester: object | None = None, requested_args: dict | None = None,
|
|
135
|
+
request_time: float = -1, request_uuid: str | None = None):
|
|
136
|
+
"""Executes the action's associated method. This is the main entry point for running an action. It handles
|
|
137
|
+
multistep logic by updating the step counter and checking for completion based on steps, time, or timeout.
|
|
138
|
+
It also injects dynamic arguments like the `requester`, `request_time`, and `request_uuid` into the method's
|
|
139
|
+
arguments before execution. If the action is a multistep action and has a completion step, it handles that
|
|
140
|
+
callback as well.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
requester: The object that requested the action.
|
|
144
|
+
requested_args: Additional arguments provided by the requester.
|
|
145
|
+
request_time: The time of the request.
|
|
146
|
+
request_uuid: A unique ID for the request.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
A boolean indicating whether the action was executed successfully.
|
|
150
|
+
"""
|
|
151
|
+
self.__check_if_args_exist(requested_args, exception=True)
|
|
152
|
+
actual_args = self.__get_actual_params(requested_args) # Getting the actual values of the arguments
|
|
153
|
+
|
|
154
|
+
if self.msg is not None:
|
|
155
|
+
Action.out_fcn(self.msg)
|
|
156
|
+
|
|
157
|
+
if actual_args is not None:
|
|
158
|
+
|
|
159
|
+
# Getting the values for the main involved measures: total steps, total time, timeout
|
|
160
|
+
self.__guess_total_steps(actual_args)
|
|
161
|
+
self.__guess_total_time(actual_args)
|
|
162
|
+
self.__guess_timeout(actual_args)
|
|
163
|
+
|
|
164
|
+
# Storing the time index that is related to the timeout (do this before calling self.is_timed_out())
|
|
165
|
+
if self.__timeout_starting_time <= 0:
|
|
166
|
+
self.__timeout_starting_time = time.perf_counter()
|
|
167
|
+
|
|
168
|
+
# Storing the starting time (do this before calling self.was_last_step_done())
|
|
169
|
+
if self.__starting_time <= 0:
|
|
170
|
+
self.__starting_time = time.perf_counter()
|
|
171
|
+
|
|
172
|
+
# Setting up the flag that tells if the action reached a point in which it cannot be run anymore
|
|
173
|
+
self.__cannot_be_run_anymore = self.is_timed_out() or self.was_last_step_done()
|
|
174
|
+
|
|
175
|
+
if HybridStateMachine.DEBUG:
|
|
176
|
+
if self.__cannot_be_run_anymore:
|
|
177
|
+
print(f"[DEBUG HSM] Cannot-be-run-anymore set to True, "
|
|
178
|
+
f"due to self.is_timed_out()={self.is_timed_out()} or "
|
|
179
|
+
f"self.was_last_step_done()={self.was_last_step_done()}")
|
|
180
|
+
|
|
181
|
+
if self.__cannot_be_run_anymore and not self.is_multi_steps():
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
# Setting up the information on whether a multistep action is completed
|
|
185
|
+
# (for example, to tell that now it is time for a callback)
|
|
186
|
+
calling_completion_step = False
|
|
187
|
+
for completed_name in Action.COMPLETED_NAMES:
|
|
188
|
+
if completed_name in actual_args:
|
|
189
|
+
calling_completion_step = self.__cannot_be_run_anymore and self.get_step() >= 0
|
|
190
|
+
actual_args[completed_name] = calling_completion_step
|
|
191
|
+
break
|
|
192
|
+
|
|
193
|
+
# We are done, no need to call the action again
|
|
194
|
+
if self.__cannot_be_run_anymore and not calling_completion_step:
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
# Setting up the requester
|
|
198
|
+
for req_arg_name in Action.REQUESTER_ARG_NAMES:
|
|
199
|
+
if req_arg_name in actual_args:
|
|
200
|
+
actual_args[req_arg_name] = requester
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
# Setting up the request time
|
|
204
|
+
for req_time_name in Action.REQUEST_TIME_NAMES:
|
|
205
|
+
if req_time_name in actual_args:
|
|
206
|
+
actual_args[req_time_name] = request_time
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
# Setting up the request uuid
|
|
210
|
+
for req_uuid_name in Action.REQUEST_UUID_NAMES:
|
|
211
|
+
if req_uuid_name in actual_args:
|
|
212
|
+
actual_args[req_uuid_name] = request_uuid
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
# Fixing (if no options are specified, assuming a single-step action)
|
|
216
|
+
if self.__total_steps == 0 and self.__total_time == 0:
|
|
217
|
+
self.__total_steps = 1
|
|
218
|
+
|
|
219
|
+
# Fixing the single step case: in this case, time does not matter, so we force it to zero
|
|
220
|
+
if self.__total_steps == 1:
|
|
221
|
+
self.__total_time = 0
|
|
222
|
+
|
|
223
|
+
# Increasing the step index
|
|
224
|
+
self.__step += 1 # This is a step index, so self.__step == 0 means "done 1 step"
|
|
225
|
+
|
|
226
|
+
if HybridStateMachine.DEBUG:
|
|
227
|
+
if requester is None:
|
|
228
|
+
requester_str = "nobody"
|
|
229
|
+
else:
|
|
230
|
+
requester_str = requester
|
|
231
|
+
print(f"[DEBUG HSM] Calling function {self.name} (multi_steps: {self.is_multi_steps()}), "
|
|
232
|
+
f"requested by {requester_str}, with actual params: {actual_args}")
|
|
233
|
+
|
|
234
|
+
# Calling the method here
|
|
235
|
+
ret = self.__fcn(**actual_args)
|
|
236
|
+
|
|
237
|
+
if HybridStateMachine.DEBUG:
|
|
238
|
+
print(f"[DEBUG HSM] Returned: {ret}")
|
|
239
|
+
|
|
240
|
+
# If action failed, be sure to reduce the step counter (only if it was actually incremented)
|
|
241
|
+
if not ret:
|
|
242
|
+
self.__step -= 1
|
|
243
|
+
|
|
244
|
+
# If it went OK, we reset the time counter that is related to the timeout
|
|
245
|
+
else:
|
|
246
|
+
self.__timeout_starting_time = 0
|
|
247
|
+
|
|
248
|
+
return ret
|
|
249
|
+
else:
|
|
250
|
+
if HybridStateMachine.DEBUG:
|
|
251
|
+
print(f"[DEBUG HSM] Tried and failed (missing actual param): {self}")
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
def __str__(self):
|
|
255
|
+
"""Provides a string representation of the `Action` instance.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
A string containing a formatted summary of the instance.
|
|
259
|
+
"""
|
|
260
|
+
return (f"[Action: {self.name}] id: {self.id}, args: {self.args}, param_list: {self.param_list}, "
|
|
261
|
+
f"total_steps: {self.__total_steps}, "
|
|
262
|
+
f"total_time: {self.__total_time}, timeout: {self.__timeout}, "
|
|
263
|
+
f"ready: {self.ready}, requests: {str(self.requests)}, msg: {str(self.msg)}]")
|
|
264
|
+
|
|
265
|
+
def set_as_ready(self):
|
|
266
|
+
"""Sets the action's ready flag to `True`, indicating it can now be executed.
|
|
267
|
+
"""
|
|
268
|
+
self.ready = True
|
|
269
|
+
|
|
270
|
+
def set_as_not_ready(self):
|
|
271
|
+
"""Sets the action's ready flag to `False`, preventing it from being executed.
|
|
272
|
+
"""
|
|
273
|
+
self.ready = False
|
|
274
|
+
|
|
275
|
+
def is_ready(self, consider_requests: bool = True):
|
|
276
|
+
"""Checks if the action is ready to be executed. It returns `True` if the `ready` flag is set or if there are
|
|
277
|
+
any pending requests.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
consider_requests: A boolean flag to include pending requests in the readiness check.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
A boolean indicating the action's readiness.
|
|
284
|
+
"""
|
|
285
|
+
return self.ready or (consider_requests and len(self.requests) > 0)
|
|
286
|
+
|
|
287
|
+
def was_last_step_done(self):
|
|
288
|
+
"""Determines if the action has reached its completion criteria, either by reaching the total number of steps
|
|
289
|
+
or by exceeding the maximum allowed execution time.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
True if the action is completed, False otherwise.
|
|
293
|
+
"""
|
|
294
|
+
return ((self.__total_steps > 0 and self.__step == self.__total_steps - 1) or
|
|
295
|
+
(self.__total_time > 0 and ((time.perf_counter() - self.__starting_time) >= self.__total_time)))
|
|
296
|
+
|
|
297
|
+
def cannot_be_run_anymore(self):
|
|
298
|
+
"""Checks if the action has reached a state where it cannot be executed further, for instance, due to
|
|
299
|
+
completion or a timeout.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
A boolean indicating if the action can no longer be run.
|
|
303
|
+
"""
|
|
304
|
+
return self.__cannot_be_run_anymore
|
|
305
|
+
|
|
306
|
+
def has_completion_step(self):
|
|
307
|
+
"""Checks if the action is designed to have a completion step, which is a final execution pass after the main
|
|
308
|
+
action logic has finished.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
A boolean indicating the presence of a completion step.
|
|
312
|
+
"""
|
|
313
|
+
return self.__has_completion_step
|
|
314
|
+
|
|
315
|
+
def is_multi_steps(self):
|
|
316
|
+
"""Determines if the action is configured to be a multistep action (i.e., not a single-step action).
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
A boolean indicating if the action is multistep.
|
|
320
|
+
"""
|
|
321
|
+
return self.__total_steps != 1
|
|
322
|
+
|
|
323
|
+
def has_a_timeout(self):
|
|
324
|
+
"""Checks if a timeout has been configured for the action.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
A boolean indicating if a timeout is set.
|
|
328
|
+
"""
|
|
329
|
+
return self.__timeout > 0
|
|
330
|
+
|
|
331
|
+
def is_delayed(self, starting_time: float):
|
|
332
|
+
"""Checks if the action is currently in a delayed state and cannot be executed yet, based on a defined delay
|
|
333
|
+
period.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
starting_time: The time the delay period began.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
True if the action is delayed, False otherwise.
|
|
340
|
+
"""
|
|
341
|
+
return self.__delay > 0 and (time.perf_counter() - starting_time) <= self.__delay
|
|
342
|
+
|
|
343
|
+
def is_timed_out(self):
|
|
344
|
+
"""Checks if the action has exceeded its configured timeout period since the last successful execution attempt.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
True if the action has timed out, False otherwise.
|
|
348
|
+
"""
|
|
349
|
+
if self.__timeout <= 0 or self.__timeout_starting_time <= 0:
|
|
350
|
+
return False
|
|
351
|
+
else:
|
|
352
|
+
if HybridStateMachine.DEBUG:
|
|
353
|
+
print(f"[DEBUG HSM] checking if {self.name} is timed out:"
|
|
354
|
+
f" {(time.perf_counter() - self.__timeout_starting_time)} >= {self.__timeout}")
|
|
355
|
+
if (time.perf_counter() - self.__timeout_starting_time) >= self.__timeout:
|
|
356
|
+
if HybridStateMachine.DEBUG:
|
|
357
|
+
print(f"[DEBUG HSM] Timeout for {self.name}!")
|
|
358
|
+
return True
|
|
359
|
+
else:
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
def to_list(self, minimal=False):
|
|
363
|
+
"""Converts the action's properties into a list for easy serialization. It can generate either a full or a
|
|
364
|
+
minimal representation.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
minimal: A boolean flag to return a minimal list representation.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
A list containing the action's properties.
|
|
371
|
+
"""
|
|
372
|
+
if not minimal:
|
|
373
|
+
if self.msg is not None:
|
|
374
|
+
msg = self.msg.encode("ascii", "xmlcharrefreplace").decode("ascii")
|
|
375
|
+
else:
|
|
376
|
+
msg = None
|
|
377
|
+
return [self.name, self.args, self.ready, self.id] + ([msg] if msg is not None else [])
|
|
378
|
+
else:
|
|
379
|
+
return [self.name, self.args]
|
|
380
|
+
|
|
381
|
+
def same_as(self, name: str, args: dict | None):
|
|
382
|
+
"""Compares the current action to a target action by name and arguments. It returns `True` if they are
|
|
383
|
+
considered the same, ignoring specific arguments like time or timeout.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
name: The name of the target action.
|
|
387
|
+
args: The arguments of the target action.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
A boolean indicating if the actions are a match.
|
|
391
|
+
"""
|
|
392
|
+
if args is None:
|
|
393
|
+
args = {}
|
|
394
|
+
|
|
395
|
+
# The current action is the same of another action called with some arguments "args" if:
|
|
396
|
+
# 1) it has the same name of the other action
|
|
397
|
+
# 2) the name of the arguments in "args" are known and valid
|
|
398
|
+
# 3) the values of the arguments in "args" matches the ones of the current action, being them default or not
|
|
399
|
+
# the values of those arguments that are not in "args" are assumed to the equivalent to the ones in the current
|
|
400
|
+
# action, so:
|
|
401
|
+
# - if the current action is act(a=3, b=4), then it is the same_as(name='act', args={'a': 3})
|
|
402
|
+
# - if the current action is act(a=3, b=4), then it is the same_as(name='act', args={'a': 3, 'b': 4, 'c': 5})
|
|
403
|
+
args_to_exclude = Action.SECONDS_ARG_NAMES | Action.TIMEOUT_ARG_NAMES | Action.DELAY_ARG_NAMES
|
|
404
|
+
return (name == self.name and
|
|
405
|
+
self.__check_if_args_exist(args) and
|
|
406
|
+
all(k in args_to_exclude or k not in self.args or self.args[k] == v for k, v in args.items()))
|
|
407
|
+
|
|
408
|
+
def __check_if_args_exist(self, args: dict, exception: bool = False):
|
|
409
|
+
"""A private helper method to validate that all provided arguments for an action exist in the action's
|
|
410
|
+
parameter list. It can either raise a `ValueError` or return a boolean.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
args: The dictionary of arguments to check.
|
|
414
|
+
exception: If `True`, a `ValueError` is raised on failure.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
True if all arguments are valid, False otherwise (if `exception` is `False`).
|
|
418
|
+
"""
|
|
419
|
+
if args is not None:
|
|
420
|
+
for param_name in args.keys():
|
|
421
|
+
if param_name not in self.param_list:
|
|
422
|
+
if exception:
|
|
423
|
+
raise ValueError(f"Unknown parameter {param_name} for action {self.name}")
|
|
424
|
+
else:
|
|
425
|
+
return False
|
|
426
|
+
return True
|
|
427
|
+
|
|
428
|
+
def set_wildcards(self, wildcards: dict[str, str | float | int] | None):
|
|
429
|
+
"""Replaces wildcard values in the action's arguments with actual values. This method is used to dynamically
|
|
430
|
+
configure actions with context-specific data.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
wildcards: A dictionary mapping wildcard placeholders to their concrete values.
|
|
434
|
+
"""
|
|
435
|
+
self.wildcards = wildcards if wildcards is not None else {}
|
|
436
|
+
self.__replace_wildcard_values()
|
|
437
|
+
|
|
438
|
+
def add_request(self, generic_request_obj: object, args: dict, timestamp: float, uuid: str):
|
|
439
|
+
"""Adds a new request to the action's internal list. This is used to track pending requests that might make the
|
|
440
|
+
action ready to be executed.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
generic_request_obj: The object making the request.
|
|
444
|
+
args: The arguments associated with the request.
|
|
445
|
+
timestamp: The time the request was made.
|
|
446
|
+
uuid: A unique ID for the request.
|
|
447
|
+
"""
|
|
448
|
+
if generic_request_obj not in self.requests:
|
|
449
|
+
self.requests[generic_request_obj] = (args, timestamp, uuid)
|
|
450
|
+
|
|
451
|
+
def clear_requests(self):
|
|
452
|
+
"""Clears all pending requests from the action's list.
|
|
453
|
+
"""
|
|
454
|
+
self.requests = {}
|
|
455
|
+
|
|
456
|
+
def get_requests(self):
|
|
457
|
+
"""Retrieves the dictionary of pending requests. Each entry in the dictionary maps a requester to its
|
|
458
|
+
arguments, timestamp, and UUID.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
A dictionary of pending requests.
|
|
462
|
+
"""
|
|
463
|
+
return self.requests
|
|
464
|
+
|
|
465
|
+
def reset_step(self):
|
|
466
|
+
"""Resets the action's state, including the step counter and timing metrics, allowing it to be re-run from the
|
|
467
|
+
beginning.
|
|
468
|
+
"""
|
|
469
|
+
self.__step = -1
|
|
470
|
+
self.__starting_time = 0.
|
|
471
|
+
self.__timeout_starting_time = 0.
|
|
472
|
+
self.__cannot_be_run_anymore = False
|
|
473
|
+
|
|
474
|
+
def get_step(self):
|
|
475
|
+
"""Retrieves the current step index of the multistep action.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
An integer representing the current step index.
|
|
479
|
+
"""
|
|
480
|
+
return self.__step
|
|
481
|
+
|
|
482
|
+
def get_total_steps(self):
|
|
483
|
+
"""Retrieves the total number of steps configured for the action.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
An integer representing the total steps.
|
|
487
|
+
"""
|
|
488
|
+
return self.__total_steps
|
|
489
|
+
|
|
490
|
+
def get_starting_time(self):
|
|
491
|
+
"""Retrieves the timestamp when the action's current execution started.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
A float representing the starting time.
|
|
495
|
+
"""
|
|
496
|
+
return self.__starting_time
|
|
497
|
+
|
|
498
|
+
def get_total_time(self):
|
|
499
|
+
"""Retrieves the total time configured for the action's execution.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
A float representing the total time.
|
|
503
|
+
"""
|
|
504
|
+
return self.__total_time
|
|
505
|
+
|
|
506
|
+
def __get_actual_params(self, additional_args: dict | None):
|
|
507
|
+
"""A private helper method that resolves all parameters for an action's execution. It combines the action's
|
|
508
|
+
default arguments, initial arguments, and any additional arguments provided during the call, ensuring all
|
|
509
|
+
necessary parameters have a value.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
additional_args: A dictionary of arguments to be combined with the action's defaults.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
A dictionary of all resolved arguments, or `None` if a required parameter is missing.
|
|
516
|
+
"""
|
|
517
|
+
actual_params = {}
|
|
518
|
+
params = self.param_list
|
|
519
|
+
defaults = self.param_to_default_value
|
|
520
|
+
for param_name in params:
|
|
521
|
+
if param_name in self.args:
|
|
522
|
+
actual_params[param_name] = self.args[param_name]
|
|
523
|
+
elif additional_args is not None and param_name in additional_args:
|
|
524
|
+
actual_params[param_name] = additional_args[param_name]
|
|
525
|
+
elif param_name in defaults:
|
|
526
|
+
actual_params[param_name] = defaults[param_name]
|
|
527
|
+
else:
|
|
528
|
+
if HybridStateMachine.DEBUG:
|
|
529
|
+
print(f"[DEBUG HSM] Getting actual params for {self.name}; missing param: {param_name}")
|
|
530
|
+
return None
|
|
531
|
+
return actual_params
|
|
532
|
+
|
|
533
|
+
def __action_name_to_callable(self, action_name: str):
|
|
534
|
+
"""A private helper method that resolves a string action name into a callable method on the `actionable`
|
|
535
|
+
object. It raises a `ValueError` if the method is not found.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
action_name: The name of the method to retrieve.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
A callable function or method.
|
|
542
|
+
"""
|
|
543
|
+
if self.actionable is not None:
|
|
544
|
+
action_fcn = getattr(self.actionable, action_name)
|
|
545
|
+
if action_fcn is None:
|
|
546
|
+
raise ValueError("Cannot find function/method: " + str(action_name))
|
|
547
|
+
return action_fcn
|
|
548
|
+
else:
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
def __get_action_params(self):
|
|
552
|
+
"""A private helper method that inspects the signature of the action's method to populate the list of
|
|
553
|
+
supported parameters and their default values.
|
|
554
|
+
"""
|
|
555
|
+
self.param_list = [param_name for param_name in self.__sig.parameters.keys()]
|
|
556
|
+
self.param_to_default_value = {param.name: param.default for param in self.__sig.parameters.values() if
|
|
557
|
+
param.default is not inspect.Parameter.empty}
|
|
558
|
+
|
|
559
|
+
def __replace_wildcard_values(self):
|
|
560
|
+
"""A private helper method that replaces placeholder values (wildcards) in the action's arguments with their
|
|
561
|
+
actual, concrete values. It handles both single-value and list-based wildcards.
|
|
562
|
+
"""
|
|
563
|
+
if self.args_with_wildcards is None:
|
|
564
|
+
self.args_with_wildcards = copy.deepcopy(self.args) # Backup before applying wildcards (first time only)
|
|
565
|
+
else:
|
|
566
|
+
self.args = copy.deepcopy(self.args_with_wildcards) # Restore a backup before applying wildcards
|
|
567
|
+
|
|
568
|
+
for k, v in self.args.items():
|
|
569
|
+
for wildcard_from, wildcard_to in self.wildcards.items():
|
|
570
|
+
if not isinstance(wildcard_to, str):
|
|
571
|
+
if wildcard_from == v:
|
|
572
|
+
self.args[k] = wildcard_to
|
|
573
|
+
else:
|
|
574
|
+
if isinstance(v, list):
|
|
575
|
+
for i, vv in enumerate(v):
|
|
576
|
+
if isinstance(vv, str) and wildcard_from in vv:
|
|
577
|
+
v[i] = vv.replace(wildcard_from, wildcard_to)
|
|
578
|
+
elif isinstance(v, str):
|
|
579
|
+
if wildcard_from in v:
|
|
580
|
+
self.args[k] = v.replace(wildcard_from, wildcard_to)
|
|
581
|
+
|
|
582
|
+
def __guess_total_steps(self, args):
|
|
583
|
+
"""A private helper method that attempts to determine the total number of steps for a multistep action by
|
|
584
|
+
looking for specific keyword arguments like 'steps' or 'samples'.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
args: The dictionary of arguments to inspect.
|
|
588
|
+
"""
|
|
589
|
+
for prefix in Action.KNOWN_SINGLE_STEP_ACTION_PREFIXES:
|
|
590
|
+
if self.name.startswith(prefix):
|
|
591
|
+
return
|
|
592
|
+
for arg_name in Action.STEPS_ARG_NAMES:
|
|
593
|
+
if arg_name in args:
|
|
594
|
+
if isinstance(args[arg_name], int):
|
|
595
|
+
self.__total_steps = max(float(args[arg_name]), 1.)
|
|
596
|
+
break
|
|
597
|
+
|
|
598
|
+
def __guess_total_time(self, args):
|
|
599
|
+
"""A private helper method that attempts to determine the total execution time for an action by looking for a
|
|
600
|
+
'time' or 'seconds' argument.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
args: The dictionary of arguments to inspect.
|
|
604
|
+
"""
|
|
605
|
+
for prefix in Action.KNOWN_SINGLE_STEP_ACTION_PREFIXES:
|
|
606
|
+
if self.name.startswith(prefix):
|
|
607
|
+
return
|
|
608
|
+
for arg_name in Action.SECONDS_ARG_NAMES:
|
|
609
|
+
if arg_name in args:
|
|
610
|
+
if isinstance(args[arg_name], int) or isinstance(args[arg_name], float):
|
|
611
|
+
try:
|
|
612
|
+
self.__total_time = max(float(args[arg_name]), 0.)
|
|
613
|
+
except ValueError:
|
|
614
|
+
self.__total_time = -1.
|
|
615
|
+
pass
|
|
616
|
+
break
|
|
617
|
+
|
|
618
|
+
def __guess_timeout(self, args):
|
|
619
|
+
"""A private helper method that attempts to determine the timeout duration for an action by looking for a
|
|
620
|
+
'timeout' argument.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
args: The dictionary of arguments to inspect.
|
|
624
|
+
"""
|
|
625
|
+
for prefix in Action.KNOWN_SINGLE_STEP_ACTION_PREFIXES:
|
|
626
|
+
if self.name.startswith(prefix):
|
|
627
|
+
return
|
|
628
|
+
for arg_name in Action.TIMEOUT_ARG_NAMES:
|
|
629
|
+
if arg_name in args:
|
|
630
|
+
try:
|
|
631
|
+
self.__timeout = max(float(args[arg_name]), 0.)
|
|
632
|
+
except ValueError:
|
|
633
|
+
self.__timeout = -1.
|
|
634
|
+
pass
|
|
635
|
+
break
|
|
636
|
+
|
|
637
|
+
def __guess_delay(self, args):
|
|
638
|
+
"""A private helper method that attempts to determine a delay duration for an action by looking for a 'delay'
|
|
639
|
+
argument.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
args: The dictionary of arguments to inspect.
|
|
643
|
+
"""
|
|
644
|
+
for arg_name in Action.DELAY_ARG_NAMES:
|
|
645
|
+
if arg_name in args:
|
|
646
|
+
try:
|
|
647
|
+
self.__delay = max(float(args[arg_name]), 0.)
|
|
648
|
+
except ValueError:
|
|
649
|
+
self.__delay = -1.
|
|
650
|
+
pass
|
|
651
|
+
break
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
class State:
|
|
655
|
+
# Output print function
|
|
656
|
+
out_fcn = print
|
|
657
|
+
|
|
658
|
+
def __init__(self, name: str, idx: int = -1, action: Action | None = None, waiting_time: float = 0.,
|
|
659
|
+
blocking: bool = True, msg: str | None = None):
|
|
660
|
+
"""Initializes a `State` object, which is a fundamental component of a Hybrid State Machine. A state can be
|
|
661
|
+
associated with an optional `Action` to be performed, a unique name, and various properties like waiting time
|
|
662
|
+
and blocking behavior. It also stores a human-readable message.
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
name: The unique name of the state.
|
|
666
|
+
idx: A unique ID for the state.
|
|
667
|
+
action: An optional `Action` object to be executed when the state is entered.
|
|
668
|
+
waiting_time: The number of seconds to wait before the state can transition.
|
|
669
|
+
blocking: A boolean indicating if the state blocks execution until a condition is met.
|
|
670
|
+
msg: An optional message associated with the state.
|
|
671
|
+
"""
|
|
672
|
+
self.name = name # Name of the state (must be unique)
|
|
673
|
+
self.action = action # Inner state action (it can be None)
|
|
674
|
+
self.id = idx # Unique ID of the state (-1 if not needed)
|
|
675
|
+
self.waiting_time = waiting_time # Number of seconds to wait in the current state before acting
|
|
676
|
+
self.starting_time = 0.
|
|
677
|
+
self.blocking = blocking
|
|
678
|
+
self.msg = msg # Human-readable message associated to this instance of action
|
|
679
|
+
|
|
680
|
+
# Fix UNICODE chars
|
|
681
|
+
if self.msg is not None:
|
|
682
|
+
self.msg = html.unescape(self.msg)
|
|
683
|
+
|
|
684
|
+
def __call__(self, *args, **kwargs):
|
|
685
|
+
"""Executes the state's logic. If a `waiting_time` is set, it starts a timer. If an `action` is associated with
|
|
686
|
+
the state, it resets the action's step counter and then executes the action by calling it. It returns the
|
|
687
|
+
result of the action's execution.
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
*args: Positional arguments to pass to the action's `__call__` method.
|
|
691
|
+
**kwargs: Keyword arguments to pass to the action's `__call__` method.
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
The return value of the action's `__call__` method, or `None` if no action is set.
|
|
695
|
+
"""
|
|
696
|
+
if self.starting_time <= 0.:
|
|
697
|
+
self.starting_time = time.perf_counter()
|
|
698
|
+
|
|
699
|
+
if self.msg is not None:
|
|
700
|
+
State.out_fcn(self.msg)
|
|
701
|
+
|
|
702
|
+
if self.action is not None:
|
|
703
|
+
if HybridStateMachine.DEBUG:
|
|
704
|
+
print("[DEBUG HSM] Running action on state: " + self.action.name)
|
|
705
|
+
self.action.reset_step()
|
|
706
|
+
return self.action(*args, **kwargs)
|
|
707
|
+
else:
|
|
708
|
+
return None
|
|
709
|
+
|
|
710
|
+
def __str__(self):
|
|
711
|
+
"""Provides a string representation of the `State` object. This is useful for debugging and logging, as it
|
|
712
|
+
summarizes the state's properties, including its name, ID, waiting time, blocking status, and its associated
|
|
713
|
+
action (if any).
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
A string containing a formatted summary of the state's instance.
|
|
717
|
+
"""
|
|
718
|
+
return (f"[State: {self.name}] id: {self.id}, waiting_time: {self.waiting_time}, blocking: {self.blocking}, "
|
|
719
|
+
f"action -> {self.action if self.action is not None else 'none'}, msg: {self.msg}")
|
|
720
|
+
|
|
721
|
+
def must_wait(self):
|
|
722
|
+
"""Checks if the state needs to wait before it can transition. It compares the current elapsed time since
|
|
723
|
+
entering the state with the configured `waiting_time`. If the elapsed time is less than the waiting time,
|
|
724
|
+
it returns `True`, indicating the state is still in a waiting period.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
A boolean indicating whether the state is currently waiting.
|
|
728
|
+
"""
|
|
729
|
+
if self.waiting_time > 0.:
|
|
730
|
+
if (time.perf_counter() - self.starting_time) >= self.waiting_time:
|
|
731
|
+
if HybridStateMachine.DEBUG:
|
|
732
|
+
print(f"[DEBUG HSM] Time passing: {(time.perf_counter() - self.starting_time)} seconds")
|
|
733
|
+
return False
|
|
734
|
+
else:
|
|
735
|
+
return True
|
|
736
|
+
else:
|
|
737
|
+
return False
|
|
738
|
+
|
|
739
|
+
def to_list(self):
|
|
740
|
+
"""Converts the state's properties into a list. This method is useful for serialization, allowing the state to
|
|
741
|
+
be easily stored or transmitted. It includes the action's minimal list representation, the state's ID,
|
|
742
|
+
blocking status, waiting time, and message.
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
A list containing the state's properties.
|
|
746
|
+
"""
|
|
747
|
+
if self.msg is not None:
|
|
748
|
+
msg = self.msg.encode("ascii", "xmlcharrefreplace").decode("ascii")
|
|
749
|
+
else:
|
|
750
|
+
msg = None
|
|
751
|
+
return ((self.action.to_list(minimal=True) if self.action is not None else [None, None]) +
|
|
752
|
+
([self.id, self.blocking, self.waiting_time] + ([msg] if msg is not None else [])))
|
|
753
|
+
|
|
754
|
+
def has_action(self):
|
|
755
|
+
"""A simple getter that checks if an action is associated with the state.
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
True if an action is set, False otherwise.
|
|
759
|
+
"""
|
|
760
|
+
return self.action is not None
|
|
761
|
+
|
|
762
|
+
def get_starting_time(self):
|
|
763
|
+
"""Retrieves the timestamp when the state's execution began. This is used to calculate the elapsed waiting time.
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
A float representing the starting time.
|
|
767
|
+
"""
|
|
768
|
+
return self.starting_time
|
|
769
|
+
|
|
770
|
+
def reset(self):
|
|
771
|
+
"""Resets the state's internal counters. This method is typically called when re-entering a state. It sets the
|
|
772
|
+
`starting_time` to zero and also resets the associated action's step counter if an action exists.
|
|
773
|
+
"""
|
|
774
|
+
self.starting_time = 0.
|
|
775
|
+
if self.action is not None:
|
|
776
|
+
self.action.reset_step()
|
|
777
|
+
|
|
778
|
+
def set_blocking(self, blocking: bool):
|
|
779
|
+
"""Sets the blocking status of the state. A blocking state will prevent the state machine from transitioning to
|
|
780
|
+
the next state until the action is fully completed.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
blocking: A boolean value to set the blocking status.
|
|
784
|
+
"""
|
|
785
|
+
self.blocking = blocking
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
class HybridStateMachine:
|
|
789
|
+
DEBUG = True
|
|
790
|
+
DEFAULT_WILDCARDS = {'<world>': '<world>', '<agent>': '<agent>'}
|
|
791
|
+
|
|
792
|
+
def __init__(self, actionable: object, wildcards: dict[str, str | float | int] | None = None,
|
|
793
|
+
request_signature_checker: Callable[[object], bool] | None = None,
|
|
794
|
+
policy: Callable[[list[Action]], int] | None = None):
|
|
795
|
+
"""Initializes a `HybridStateMachine` object, which orchestrates states and transitions. It manages a set of
|
|
796
|
+
states and actions, and handles the logic for transitions between states based on conditions and a defined
|
|
797
|
+
policy. It sets up initial and current states, wildcards for dynamic arguments, and references to an
|
|
798
|
+
`actionable` object whose methods are the actions to be called. It also includes debug and output settings.
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
actionable: The object on which actions (methods) are to be executed.
|
|
802
|
+
wildcards: A dictionary of key-value pairs for dynamic argument substitution.
|
|
803
|
+
request_signature_checker: An optional callable to validate incoming action requests.
|
|
804
|
+
policy: An optional callable that determines which action to execute from a list of feasible actions.
|
|
805
|
+
"""
|
|
806
|
+
|
|
807
|
+
# States are identified by strings, and then handled as State object with possibly and integer ID and action
|
|
808
|
+
self.initial_state: str | None = None # Initial state of the machine
|
|
809
|
+
self.prev_state: str | None = None # Previous state
|
|
810
|
+
self.limbo_state: str | None = None # When an action takes more than a step to complete, we are in "limbo"
|
|
811
|
+
self.state: str | None = None # Current state
|
|
812
|
+
self.role: str | None = None # Role of the agent in the state machine (e.g., teacher, student, etc.)
|
|
813
|
+
self.enabled: bool = True
|
|
814
|
+
self.states: dict[str, State] = {} # State name to State object
|
|
815
|
+
|
|
816
|
+
# Actions (transitions) are handled as Action objects in-between state strings
|
|
817
|
+
self.transitions: dict[str, dict[str, list[Action]]] = {} # Pair-of-states to the actions between them
|
|
818
|
+
self.actionable: object = actionable # The object on whose methods are actions that the machine calls
|
|
819
|
+
self.wildcards: dict[str, str | float | int] | None = wildcards \
|
|
820
|
+
if wildcards is not None else {} # From a wildcards string to a specific value (used in action arguments)
|
|
821
|
+
self.policy = policy if policy is not None else self.__policy_first_requested_or_first_ready
|
|
822
|
+
|
|
823
|
+
# Actions can be requested from the "outside": each request if checked by this function, if any
|
|
824
|
+
self.request_signature_checker: Callable[[object], bool] | None = request_signature_checker
|
|
825
|
+
|
|
826
|
+
# Running data
|
|
827
|
+
self.__action: Action | None = None # Action that is being executed (could take more than a step to complete)
|
|
828
|
+
self.__last_completed_action: Action | None = None
|
|
829
|
+
self.__cur_feasible_actions_status: dict | None = None # Store info of the executed action (for multi-steps)
|
|
830
|
+
self.__id_to_state: list[State] = [] # Map from state ID to State object
|
|
831
|
+
self.__id_to_action: list[Action] = [] # Map from action ID to Action object
|
|
832
|
+
self.__state_changed = False # Internal flag
|
|
833
|
+
|
|
834
|
+
# Forcing default wildcards
|
|
835
|
+
self.add_wildcards(HybridStateMachine.DEFAULT_WILDCARDS)
|
|
836
|
+
|
|
837
|
+
# Forcing output function
|
|
838
|
+
self.__last_printed_msg = None
|
|
839
|
+
|
|
840
|
+
def wrapped_out_fcn(msg: str):
|
|
841
|
+
if msg is not None:
|
|
842
|
+
if msg != self.__last_printed_msg:
|
|
843
|
+
print(msg)
|
|
844
|
+
self.__last_printed_msg = msg
|
|
845
|
+
|
|
846
|
+
State.out_fcn = wrapped_out_fcn
|
|
847
|
+
Action.out_fcn = wrapped_out_fcn
|
|
848
|
+
|
|
849
|
+
def to_dict(self):
|
|
850
|
+
"""Serializes the state machine's current configuration into a dictionary. This includes its states,
|
|
851
|
+
transitions, roles, and the current action being executed. It is useful for saving the state of the machine or
|
|
852
|
+
for logging its status in a structured format.
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
A dictionary representation of the state machine's properties.
|
|
856
|
+
"""
|
|
857
|
+
return {
|
|
858
|
+
'initial_state': self.initial_state,
|
|
859
|
+
'state': self.state,
|
|
860
|
+
'role': self.role,
|
|
861
|
+
'prev_state': self.prev_state,
|
|
862
|
+
'limbo_state': self.limbo_state,
|
|
863
|
+
'state_actions': {
|
|
864
|
+
state.name: state.to_list() for state in self.__id_to_state
|
|
865
|
+
},
|
|
866
|
+
'transitions': {
|
|
867
|
+
from_state: {
|
|
868
|
+
to_state: [act.to_list() for act in action_list] for to_state, action_list in to_states.items()
|
|
869
|
+
}
|
|
870
|
+
for from_state, to_states in self.transitions.items() if len(to_states) > 0
|
|
871
|
+
},
|
|
872
|
+
'cur_action': self.__action.to_list() if self.__action is not None else None
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
def __str__(self):
|
|
876
|
+
"""Generates a human-readable string representation of the state machine. It uses the `to_dict` method to get
|
|
877
|
+
the machine's data and then formats it as a compact JSON string, making it easy to inspect for debugging
|
|
878
|
+
purposes.
|
|
879
|
+
|
|
880
|
+
Returns:
|
|
881
|
+
A formatted JSON string representing the state machine.
|
|
882
|
+
"""
|
|
883
|
+
hsm_data = self.to_dict()
|
|
884
|
+
|
|
885
|
+
def custom_serializer(obj):
|
|
886
|
+
if not isinstance(obj, (int, str, float, bool, list, tuple, dict, set)):
|
|
887
|
+
return "_non_basic_type_removed_"
|
|
888
|
+
else:
|
|
889
|
+
return obj
|
|
890
|
+
|
|
891
|
+
json_str = json.dumps(hsm_data, indent=4, default=custom_serializer)
|
|
892
|
+
|
|
893
|
+
# Compacting lists
|
|
894
|
+
def remove_newlines_in_lists(json_string):
|
|
895
|
+
stack = []
|
|
896
|
+
output = []
|
|
897
|
+
i = 0
|
|
898
|
+
while i < len(json_string):
|
|
899
|
+
char = json_string[i]
|
|
900
|
+
if char == '[':
|
|
901
|
+
stack.append('[')
|
|
902
|
+
output.append(char)
|
|
903
|
+
elif char == ']':
|
|
904
|
+
stack.pop()
|
|
905
|
+
output.append(char)
|
|
906
|
+
elif char == '\n' and stack: # Skipping newline
|
|
907
|
+
i += 1
|
|
908
|
+
while i < len(json_string) and json_string[i] in ' \t':
|
|
909
|
+
i += 1
|
|
910
|
+
if output[-1] == ",":
|
|
911
|
+
output.append(" ")
|
|
912
|
+
continue # Do not output newline or following spaces
|
|
913
|
+
else:
|
|
914
|
+
output.append(char)
|
|
915
|
+
i += 1
|
|
916
|
+
return ''.join(output)
|
|
917
|
+
|
|
918
|
+
return remove_newlines_in_lists(json_str)
|
|
919
|
+
|
|
920
|
+
def set_actionable(self, obj: object):
|
|
921
|
+
"""Sets the object on which the state machine's actions will be performed. This allows the same state machine
|
|
922
|
+
logic to be applied to different objects. It updates the `actionable` reference for all states and actions
|
|
923
|
+
within the machine.
|
|
924
|
+
|
|
925
|
+
Args:
|
|
926
|
+
obj: The object instance to be set as the new `actionable`.
|
|
927
|
+
"""
|
|
928
|
+
self.actionable = obj
|
|
929
|
+
|
|
930
|
+
for state_obj in self.states.values():
|
|
931
|
+
if state_obj.action is not None:
|
|
932
|
+
state_obj.action.actionable = obj
|
|
933
|
+
|
|
934
|
+
def set_wildcards(self, wildcards: dict[str, str | float | int] | None):
|
|
935
|
+
"""Sets the dictionary of wildcards that are used to dynamically replace placeholder values in action
|
|
936
|
+
arguments. It updates all actions with the new wildcard dictionary.
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
wildcards: A dictionary containing wildcard key-value pairs.
|
|
940
|
+
"""
|
|
941
|
+
self.wildcards = wildcards if wildcards is not None else {}
|
|
942
|
+
for action in self.__id_to_action:
|
|
943
|
+
action.set_wildcards(self.wildcards)
|
|
944
|
+
|
|
945
|
+
def set_role(self, role: str):
|
|
946
|
+
"""Sets the role of the agent associated with this state machine. This can be used to influence state machine
|
|
947
|
+
behavior based on the agent's role (e.g., 'teacher', 'student').
|
|
948
|
+
|
|
949
|
+
Args:
|
|
950
|
+
role: The string representation of the new role.
|
|
951
|
+
"""
|
|
952
|
+
self.role = role
|
|
953
|
+
|
|
954
|
+
def get_wildcards(self):
|
|
955
|
+
"""Retrieves the dictionary of wildcards currently used by the state machine.
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
A dictionary of the wildcards.
|
|
959
|
+
"""
|
|
960
|
+
return self.wildcards
|
|
961
|
+
|
|
962
|
+
def add_wildcards(self, wildcards: dict[str, str | float | int | list[str]]):
|
|
963
|
+
"""Adds new key-value pairs to the existing wildcard dictionary. It also triggers an update to all actions with
|
|
964
|
+
the new combined dictionary.
|
|
965
|
+
|
|
966
|
+
Args:
|
|
967
|
+
wildcards: A dictionary of new wildcards to add.
|
|
968
|
+
"""
|
|
969
|
+
self.wildcards.update(wildcards)
|
|
970
|
+
self.set_wildcards(self.wildcards)
|
|
971
|
+
|
|
972
|
+
def update_wildcard(self, wildcard_key: str, wildcard_value: str | float | int):
|
|
973
|
+
"""Updates the value of a single existing wildcard. It raises an error if the key does not exist. This method
|
|
974
|
+
is useful for changing a single dynamic value without redefining all wildcards.
|
|
975
|
+
|
|
976
|
+
Args:
|
|
977
|
+
wildcard_key: The key of the wildcard to update.
|
|
978
|
+
wildcard_value: The new value for the wildcard.
|
|
979
|
+
"""
|
|
980
|
+
assert wildcard_key in self.wildcards, f"{wildcard_key} is not a valid wildcard"
|
|
981
|
+
self.wildcards[wildcard_key] = wildcard_value
|
|
982
|
+
self.set_wildcards(self.wildcards)
|
|
983
|
+
|
|
984
|
+
def get_action_step(self):
|
|
985
|
+
"""Retrieves the current step index of the action being executed. This is particularly useful for tracking the
|
|
986
|
+
progress of multistep actions.
|
|
987
|
+
|
|
988
|
+
Returns:
|
|
989
|
+
An integer representing the current step, or -1 if no action is running.
|
|
990
|
+
"""
|
|
991
|
+
return self.__action.get_step() if self.__action is not None else -1
|
|
992
|
+
|
|
993
|
+
def is_busy_acting(self):
|
|
994
|
+
"""Checks if the state machine is currently executing an action. This is determined by checking if the action
|
|
995
|
+
step index is greater than or equal to 0.
|
|
996
|
+
|
|
997
|
+
Returns:
|
|
998
|
+
True if an action is running, False otherwise.
|
|
999
|
+
"""
|
|
1000
|
+
return self.get_action_step() >= 0
|
|
1001
|
+
|
|
1002
|
+
def add_state(self, state: str, action: str = None, args: dict | None = None, state_id: int | None = None,
|
|
1003
|
+
waiting_time: float | None = None, blocking: bool | None = None, msg: str | None = None):
|
|
1004
|
+
"""Adds a new state to the state machine. This method can create a new state with an optional inner action or
|
|
1005
|
+
update an existing state. It assigns a unique ID to the state and its action.
|
|
1006
|
+
|
|
1007
|
+
Args:
|
|
1008
|
+
state: The name of the state to add.
|
|
1009
|
+
action: The name of the action to associate with the state.
|
|
1010
|
+
args: A dictionary of arguments for the action.
|
|
1011
|
+
state_id: An optional unique ID for the state.
|
|
1012
|
+
waiting_time: A float representing a delay before the state can transition.
|
|
1013
|
+
blocking: A boolean indicating if the state is blocking.
|
|
1014
|
+
msg: A human-readable message for the state.
|
|
1015
|
+
"""
|
|
1016
|
+
if args is None:
|
|
1017
|
+
args = {}
|
|
1018
|
+
sta_obj = None
|
|
1019
|
+
if state_id is None:
|
|
1020
|
+
if state not in self.states:
|
|
1021
|
+
state_id = len(self.__id_to_state)
|
|
1022
|
+
else:
|
|
1023
|
+
sta_obj = self.states[state]
|
|
1024
|
+
state_id = sta_obj.id
|
|
1025
|
+
if action is None:
|
|
1026
|
+
act = sta_obj.action if sta_obj is not None else None
|
|
1027
|
+
else:
|
|
1028
|
+
act = Action(name=action, args=args, idx=len(self.__id_to_action),
|
|
1029
|
+
actionable=self.actionable, wildcards=self.wildcards)
|
|
1030
|
+
self.__id_to_action.append(act)
|
|
1031
|
+
if waiting_time is None:
|
|
1032
|
+
waiting_time = sta_obj.waiting_time if sta_obj is not None else 0. # Default waiting time
|
|
1033
|
+
if blocking is None:
|
|
1034
|
+
blocking = sta_obj.blocking if sta_obj is not None else True # Default blocking
|
|
1035
|
+
if msg is None:
|
|
1036
|
+
msg = sta_obj.msg if sta_obj is not None else None
|
|
1037
|
+
|
|
1038
|
+
sta = State(name=state, idx=state_id, action=act, waiting_time=waiting_time, blocking=blocking, msg=msg)
|
|
1039
|
+
if state not in self.states:
|
|
1040
|
+
self.__id_to_state.append(sta)
|
|
1041
|
+
else:
|
|
1042
|
+
self.__id_to_state[state_id] = sta
|
|
1043
|
+
self.states[state] = sta
|
|
1044
|
+
|
|
1045
|
+
if len(self.__id_to_state) == 1 and self.state is None:
|
|
1046
|
+
self.set_state(sta.name)
|
|
1047
|
+
|
|
1048
|
+
def get_state_name(self):
|
|
1049
|
+
"""Retrieves the name of the current state of the state machine.
|
|
1050
|
+
|
|
1051
|
+
Returns:
|
|
1052
|
+
A string with the state's name, or `None` if no state is set.
|
|
1053
|
+
"""
|
|
1054
|
+
|
|
1055
|
+
return self.state
|
|
1056
|
+
|
|
1057
|
+
def get_state(self):
|
|
1058
|
+
"""Retrieves the current `State` object of the state machine.
|
|
1059
|
+
|
|
1060
|
+
Returns:
|
|
1061
|
+
A `State` object or `None`.
|
|
1062
|
+
"""
|
|
1063
|
+
return self.states[self.state] if self.state is not None else None
|
|
1064
|
+
|
|
1065
|
+
def get_action(self):
|
|
1066
|
+
"""Retrieves the `Action` object that is currently being executed.
|
|
1067
|
+
|
|
1068
|
+
Returns:
|
|
1069
|
+
An `Action` object or `None`.
|
|
1070
|
+
"""
|
|
1071
|
+
return self.__action
|
|
1072
|
+
|
|
1073
|
+
def get_action_name(self):
|
|
1074
|
+
"""Retrieves the name of the action currently being executed.
|
|
1075
|
+
|
|
1076
|
+
Returns:
|
|
1077
|
+
A string with the action's name, or `None` if no action is running.
|
|
1078
|
+
"""
|
|
1079
|
+
return self.__action.name if self.__action is not None else None
|
|
1080
|
+
|
|
1081
|
+
def reset_state(self):
|
|
1082
|
+
"""Resets the state machine to its initial state. This clears the current action, the previous state, and
|
|
1083
|
+
the limbo state. It also resets the step counters for all actions within the machine.
|
|
1084
|
+
"""
|
|
1085
|
+
self.state = self.initial_state
|
|
1086
|
+
self.limbo_state = None
|
|
1087
|
+
self.prev_state = None
|
|
1088
|
+
self.__action = None
|
|
1089
|
+
for act in self.__id_to_action:
|
|
1090
|
+
act.reset_step()
|
|
1091
|
+
for s in self.__id_to_state:
|
|
1092
|
+
if s.action is not None:
|
|
1093
|
+
s.action.reset_step()
|
|
1094
|
+
|
|
1095
|
+
def get_states(self):
|
|
1096
|
+
"""Returns an iterable of all state names defined in the state machine.
|
|
1097
|
+
|
|
1098
|
+
Returns:
|
|
1099
|
+
An iterable of state names.
|
|
1100
|
+
"""
|
|
1101
|
+
return list(set(list(self.transitions.keys()) + self.__id_to_state))
|
|
1102
|
+
|
|
1103
|
+
def set_state(self, state: str):
|
|
1104
|
+
"""Sets the current state of the state machine to a new, specified state. It also handles the transition logic
|
|
1105
|
+
by resetting the current action and updating the previous state. Raises an error if the new state is not known
|
|
1106
|
+
to the machine.
|
|
1107
|
+
|
|
1108
|
+
Args:
|
|
1109
|
+
state: The name of the state to transition to.
|
|
1110
|
+
"""
|
|
1111
|
+
if state in self.transitions or state in self.states:
|
|
1112
|
+
self.prev_state = self.state
|
|
1113
|
+
self.state = state
|
|
1114
|
+
if self.__action is not None:
|
|
1115
|
+
self.__action.reset_step()
|
|
1116
|
+
self.__action = None
|
|
1117
|
+
if self.initial_state is None:
|
|
1118
|
+
self.initial_state = state
|
|
1119
|
+
else:
|
|
1120
|
+
raise ValueError("Unknown state: " + str(state))
|
|
1121
|
+
|
|
1122
|
+
def add_transit(self, from_state: str, to_state: str,
|
|
1123
|
+
action: str, args: dict | None = None, ready: bool = True,
|
|
1124
|
+
act_id: int | None = None, msg: str | None = None):
|
|
1125
|
+
"""Defines a transition between two states with an associated action. This method is central to building the
|
|
1126
|
+
state machine's logic. It can also handle loading and integrating a complete state machine from a file,
|
|
1127
|
+
resolving any state name clashes.
|
|
1128
|
+
|
|
1129
|
+
Args:
|
|
1130
|
+
from_state: The name of the starting state.
|
|
1131
|
+
to_state: The name of the destination state (can be a file path to load another HSM).
|
|
1132
|
+
action: The name of the action to trigger the transition.
|
|
1133
|
+
args: A dictionary of arguments for the action.
|
|
1134
|
+
ready: A boolean indicating if the action is ready by default.
|
|
1135
|
+
act_id: An optional unique ID for the action.
|
|
1136
|
+
msg: An optional human-readable message for the action.
|
|
1137
|
+
"""
|
|
1138
|
+
|
|
1139
|
+
# Plugging a previously loaded HSM
|
|
1140
|
+
if to_state.lower().endswith(".json"):
|
|
1141
|
+
if not os.path.exists(to_state):
|
|
1142
|
+
raise FileNotFoundError(f"Cannot find {to_state}")
|
|
1143
|
+
|
|
1144
|
+
file_name = to_state
|
|
1145
|
+
hsm = HybridStateMachine(self.actionable).load(file_name)
|
|
1146
|
+
|
|
1147
|
+
# First, we avoid name clashes, renaming already-used-state-names in original_name~1 (or ~2, or ~3, ...)
|
|
1148
|
+
hsm_states = list(hsm.states.keys()) # Keep the list(...) thing, since we need a copy here (it will change)
|
|
1149
|
+
for state in hsm_states:
|
|
1150
|
+
renamed_state = state
|
|
1151
|
+
i = 1
|
|
1152
|
+
while renamed_state in self.states or (i > 1 and renamed_state in hsm.states):
|
|
1153
|
+
renamed_state = state + "." + str(i)
|
|
1154
|
+
i += 1
|
|
1155
|
+
|
|
1156
|
+
if hsm.initial_state == state:
|
|
1157
|
+
hsm.initial_state = renamed_state
|
|
1158
|
+
if hsm.prev_state == state:
|
|
1159
|
+
hsm.prev_state = renamed_state
|
|
1160
|
+
if hsm.state == state:
|
|
1161
|
+
hsm.state = renamed_state
|
|
1162
|
+
if hsm.limbo_state == state:
|
|
1163
|
+
hsm.limbo_state = renamed_state
|
|
1164
|
+
|
|
1165
|
+
hsm.states[renamed_state] = hsm.states[state]
|
|
1166
|
+
if renamed_state != state:
|
|
1167
|
+
del hsm.states[state]
|
|
1168
|
+
hsm.transitions[renamed_state] = hsm.transitions[state]
|
|
1169
|
+
if renamed_state != state:
|
|
1170
|
+
del hsm.transitions[state]
|
|
1171
|
+
|
|
1172
|
+
for to_states in hsm.transitions.values():
|
|
1173
|
+
if state in to_states:
|
|
1174
|
+
to_states[renamed_state] = to_states[state]
|
|
1175
|
+
if renamed_state != state:
|
|
1176
|
+
del to_states[state]
|
|
1177
|
+
|
|
1178
|
+
# Saving
|
|
1179
|
+
initial_state_was_set = self.initial_state is not None
|
|
1180
|
+
state_was_set = self.state is not None
|
|
1181
|
+
|
|
1182
|
+
# Include actions/states from another HSM
|
|
1183
|
+
self.include(hsm)
|
|
1184
|
+
|
|
1185
|
+
# Adding a transition to the initial state of the given HSM
|
|
1186
|
+
self.add_transit(from_state=from_state, to_state=hsm.initial_state, action=action, args=args,
|
|
1187
|
+
ready=ready, act_id=None, msg=msg)
|
|
1188
|
+
|
|
1189
|
+
# Restoring
|
|
1190
|
+
self.initial_state = from_state if not initial_state_was_set else self.initial_state
|
|
1191
|
+
self.state = from_state if not state_was_set else self.state
|
|
1192
|
+
return
|
|
1193
|
+
|
|
1194
|
+
# Adding a new transition
|
|
1195
|
+
if from_state not in self.transitions:
|
|
1196
|
+
if from_state not in self.states:
|
|
1197
|
+
self.add_state(from_state, action=None)
|
|
1198
|
+
self.transitions[from_state] = {}
|
|
1199
|
+
if to_state not in self.transitions:
|
|
1200
|
+
if to_state not in self.states:
|
|
1201
|
+
self.add_state(to_state, action=None)
|
|
1202
|
+
self.transitions[to_state] = {}
|
|
1203
|
+
if args is None:
|
|
1204
|
+
args = {}
|
|
1205
|
+
if act_id is None:
|
|
1206
|
+
act_id = len(self.__id_to_action)
|
|
1207
|
+
|
|
1208
|
+
# Clearing
|
|
1209
|
+
if to_state not in self.transitions[from_state]:
|
|
1210
|
+
self.transitions[from_state][to_state] = []
|
|
1211
|
+
|
|
1212
|
+
# Checking
|
|
1213
|
+
existing_action_list = self.transitions[from_state][to_state]
|
|
1214
|
+
for existing_action in existing_action_list:
|
|
1215
|
+
if existing_action.same_as(name=action, args=args):
|
|
1216
|
+
raise ValueError(f"Repeated transition from {from_state} to {to_state}: "
|
|
1217
|
+
f"{existing_action.to_list()}")
|
|
1218
|
+
|
|
1219
|
+
# Adding the new action
|
|
1220
|
+
new_action = Action(name=action, args=args, idx=act_id, actionable=self.actionable, ready=ready, msg=msg)
|
|
1221
|
+
self.transitions[from_state][to_state].append(new_action)
|
|
1222
|
+
self.__id_to_action.append(new_action)
|
|
1223
|
+
|
|
1224
|
+
def include(self, hsm, make_a_copy=False):
|
|
1225
|
+
"""Integrates the states and transitions of another state machine (`hsm`) into the current one. This is a
|
|
1226
|
+
crucial method for composing complex state machines from smaller, reusable components. It copies wildcards,
|
|
1227
|
+
states, and transitions, ensuring that all actions and states are properly added and linked. This method also
|
|
1228
|
+
handles an optional `make_a_copy` flag to completely replicate the source machine's state (e.g., current state,
|
|
1229
|
+
initial state).
|
|
1230
|
+
|
|
1231
|
+
Args:
|
|
1232
|
+
hsm: The `HybridStateMachine` object to include.
|
|
1233
|
+
make_a_copy: A boolean to indicate whether the current state machine should adopt the state (e.g.,
|
|
1234
|
+
current state, initial state) of the included one.
|
|
1235
|
+
"""
|
|
1236
|
+
|
|
1237
|
+
# Copying wildcards
|
|
1238
|
+
self.add_wildcards(hsm.get_wildcards())
|
|
1239
|
+
|
|
1240
|
+
# Adding states before adding transitions, so that we also add inner state actions, if any
|
|
1241
|
+
for _state in hsm.states.values():
|
|
1242
|
+
self.add_state(state=_state.name,
|
|
1243
|
+
action=_state.action.name if _state.action is not None else None,
|
|
1244
|
+
waiting_time=_state.waiting_time,
|
|
1245
|
+
args=copy.deepcopy(_state.action.args_with_wildcards) if _state.action is not None else None,
|
|
1246
|
+
state_id=None,
|
|
1247
|
+
blocking=_state.blocking,
|
|
1248
|
+
msg=_state.msg)
|
|
1249
|
+
|
|
1250
|
+
# Copy all the transitions of the HSM
|
|
1251
|
+
for _from_state, _to_states in hsm.transitions.items():
|
|
1252
|
+
for _to_state, _action_list in _to_states.items():
|
|
1253
|
+
for _action in _action_list:
|
|
1254
|
+
self.add_transit(from_state=_from_state, to_state=_to_state, action=_action.name,
|
|
1255
|
+
args=copy.deepcopy(_action.args_with_wildcards), ready=_action.ready,
|
|
1256
|
+
act_id=None, msg=_action.msg)
|
|
1257
|
+
|
|
1258
|
+
if make_a_copy:
|
|
1259
|
+
self.state = hsm.state
|
|
1260
|
+
self.prev_state = hsm.state
|
|
1261
|
+
self.initial_state = hsm.initial_state
|
|
1262
|
+
self.limbo_state = hsm.limbo_state
|
|
1263
|
+
|
|
1264
|
+
def must_wait(self):
|
|
1265
|
+
"""Checks if the current state is in a waiting period before any transitions can occur.
|
|
1266
|
+
|
|
1267
|
+
Returns:
|
|
1268
|
+
A boolean indicating if the state machine must wait.
|
|
1269
|
+
"""
|
|
1270
|
+
if self.state is not None:
|
|
1271
|
+
return self.states[self.state].must_wait()
|
|
1272
|
+
else:
|
|
1273
|
+
return False
|
|
1274
|
+
|
|
1275
|
+
def is_enabled(self):
|
|
1276
|
+
"""A simple getter to check if the state machine is currently enabled to run.
|
|
1277
|
+
|
|
1278
|
+
Returns:
|
|
1279
|
+
True if the state machine is enabled, False otherwise.
|
|
1280
|
+
"""
|
|
1281
|
+
return self.enabled
|
|
1282
|
+
|
|
1283
|
+
def enable(self, yes_or_not: bool):
|
|
1284
|
+
"""Enables or disables the state machine. When disabled, the `act_states` and `act_transitions` methods will
|
|
1285
|
+
not perform any actions.
|
|
1286
|
+
|
|
1287
|
+
Args:
|
|
1288
|
+
yes_or_not: A boolean to enable (`True`) or disable (`False`) the state machine.
|
|
1289
|
+
"""
|
|
1290
|
+
self.enabled = yes_or_not
|
|
1291
|
+
|
|
1292
|
+
def act_states(self):
|
|
1293
|
+
"""Executes the inner action of the current state, if one exists. This method is for actions that occur upon
|
|
1294
|
+
entering a state but do not cause an immediate transition. It only runs if the state machine is enabled.
|
|
1295
|
+
"""
|
|
1296
|
+
if not self.enabled:
|
|
1297
|
+
return
|
|
1298
|
+
|
|
1299
|
+
if self.state is not None: # When in the middle of an action, the state is Nones
|
|
1300
|
+
self.states[self.state]() # Run the action (if any)
|
|
1301
|
+
|
|
1302
|
+
def act_transitions(self, requested_only: bool = False):
|
|
1303
|
+
"""This is the core execution loop for transitions. It finds all feasible actions from the current state and,
|
|
1304
|
+
using a policy, selects and executes one. It handles single-step and multistep actions, managing state changes,
|
|
1305
|
+
timeouts, and failed executions. It returns an integer status code indicating the outcome (e.g., transition
|
|
1306
|
+
done, try again, move to next action).
|
|
1307
|
+
|
|
1308
|
+
Args:
|
|
1309
|
+
requested_only: A boolean to consider only actions that have pending requests.
|
|
1310
|
+
|
|
1311
|
+
Returns:
|
|
1312
|
+
An integer status code: `0` for a successful transition, `1` to retry the same action, `2` to move to the
|
|
1313
|
+
next action, or `-1` if no actions were found.
|
|
1314
|
+
"""
|
|
1315
|
+
if not self.enabled:
|
|
1316
|
+
return -1
|
|
1317
|
+
|
|
1318
|
+
# Collecting list of feasible actions, wait flags, etc. (from the current state)
|
|
1319
|
+
if self.__cur_feasible_actions_status is None:
|
|
1320
|
+
if self.state is None:
|
|
1321
|
+
return -1
|
|
1322
|
+
|
|
1323
|
+
actions_list = []
|
|
1324
|
+
to_state_list = []
|
|
1325
|
+
|
|
1326
|
+
for to_state, action_list in self.transitions[self.state].items():
|
|
1327
|
+
for i, action in enumerate(action_list):
|
|
1328
|
+
if (action.is_ready() and (not requested_only or len(action.requests) > 0) and
|
|
1329
|
+
not action.is_delayed(self.states[self.state].starting_time)):
|
|
1330
|
+
actions_list.append(action)
|
|
1331
|
+
to_state_list.append(to_state)
|
|
1332
|
+
|
|
1333
|
+
if len(actions_list) > 0:
|
|
1334
|
+
self.__cur_feasible_actions_status = {
|
|
1335
|
+
'actions_list': actions_list,
|
|
1336
|
+
'to_state_list': to_state_list,
|
|
1337
|
+
'selected_idx': 0,
|
|
1338
|
+
'selected_requester': None,
|
|
1339
|
+
'selected_requested_args': {},
|
|
1340
|
+
'selected_request_time': -1.,
|
|
1341
|
+
'selected_request_uuid': None
|
|
1342
|
+
}
|
|
1343
|
+
else:
|
|
1344
|
+
|
|
1345
|
+
# Reloading the already computed set of actions, wait flags, etc. (when in the middle of an action)
|
|
1346
|
+
actions_list = self.__cur_feasible_actions_status['actions_list']
|
|
1347
|
+
to_state_list = self.__cur_feasible_actions_status['to_state_list']
|
|
1348
|
+
|
|
1349
|
+
# Using the selected policy to decide what action to apply
|
|
1350
|
+
while len(actions_list) > 0:
|
|
1351
|
+
|
|
1352
|
+
# It there was an already selected action (for example a multistep action), then continue with it,
|
|
1353
|
+
# otherwise, select a new one following a certain policy (actually, first-come first-served)
|
|
1354
|
+
if self.__action is None:
|
|
1355
|
+
|
|
1356
|
+
# Naive policy: take the first action that is ready
|
|
1357
|
+
_idx, (_requester, (_requested_args, _request_time, _request_uuid)) = self.policy(actions_list)
|
|
1358
|
+
|
|
1359
|
+
# Saving current action
|
|
1360
|
+
self.limbo_state = self.state
|
|
1361
|
+
self.state = None
|
|
1362
|
+
self.__action = actions_list[_idx]
|
|
1363
|
+
self.__action.reset_step() # Resetting
|
|
1364
|
+
self.__cur_feasible_actions_status['selected_idx'] = _idx
|
|
1365
|
+
self.__cur_feasible_actions_status['selected_requester'] = _requester
|
|
1366
|
+
self.__cur_feasible_actions_status['selected_requested_args'] = _requested_args
|
|
1367
|
+
self.__cur_feasible_actions_status['selected_request_time'] = _request_time
|
|
1368
|
+
self.__cur_feasible_actions_status['selected_request_uuid'] = _request_uuid
|
|
1369
|
+
|
|
1370
|
+
if HybridStateMachine.DEBUG:
|
|
1371
|
+
print(f"[DEBUG HSM] Policy selected {self.__action.__str__()} whose requester is {_requester}")
|
|
1372
|
+
|
|
1373
|
+
# References
|
|
1374
|
+
action = self.__action
|
|
1375
|
+
idx = self.__cur_feasible_actions_status['selected_idx']
|
|
1376
|
+
requester = self.__cur_feasible_actions_status['selected_requester']
|
|
1377
|
+
requested_args = self.__cur_feasible_actions_status['selected_requested_args']
|
|
1378
|
+
request_time = self.__cur_feasible_actions_status['selected_request_time']
|
|
1379
|
+
request_uuid = self.__cur_feasible_actions_status['selected_request_uuid']
|
|
1380
|
+
|
|
1381
|
+
# Call action
|
|
1382
|
+
action_call_returned_true = action(requester=requester,
|
|
1383
|
+
requested_args=requested_args,
|
|
1384
|
+
request_time=request_time, request_uuid=request_uuid)
|
|
1385
|
+
|
|
1386
|
+
# Status can be one of these:
|
|
1387
|
+
# 0: action fully done;
|
|
1388
|
+
# 1: try again this action;
|
|
1389
|
+
# 2: move to next action.
|
|
1390
|
+
if action_call_returned_true:
|
|
1391
|
+
if not action.is_multi_steps():
|
|
1392
|
+
|
|
1393
|
+
# Single-step actions
|
|
1394
|
+
status = 0 # Done
|
|
1395
|
+
else:
|
|
1396
|
+
|
|
1397
|
+
# multistep actions
|
|
1398
|
+
if action.cannot_be_run_anymore(): # Timeout, max time reached, max steps reached
|
|
1399
|
+
if HybridStateMachine.DEBUG:
|
|
1400
|
+
print(f"[DEBUG HSM] multistep action {self.__action.name} returned True and "
|
|
1401
|
+
f"cannot-be-run-anymore "
|
|
1402
|
+
f"(step: {action.get_step()}, "
|
|
1403
|
+
f"has_completion_step: {action.has_completion_step()})")
|
|
1404
|
+
if self.__action.has_completion_step() and action.get_step() == 0:
|
|
1405
|
+
status = 1 # Try again (next step, it will trigger the completion step)
|
|
1406
|
+
else:
|
|
1407
|
+
if action.get_step() >= 0:
|
|
1408
|
+
status = 0 # Done, the action is fully completed
|
|
1409
|
+
else:
|
|
1410
|
+
status = 2 # Move to the next action
|
|
1411
|
+
else:
|
|
1412
|
+
if HybridStateMachine.DEBUG:
|
|
1413
|
+
print(f"[DEBUG HSM] multistep action {self.__action.name} can still be run")
|
|
1414
|
+
status = 1 # Try again (next step)
|
|
1415
|
+
else:
|
|
1416
|
+
if not action.is_multi_steps():
|
|
1417
|
+
|
|
1418
|
+
# Single-step actions
|
|
1419
|
+
if not action.has_a_timeout() or action.is_timed_out():
|
|
1420
|
+
status = 2 # Move to the next action
|
|
1421
|
+
else:
|
|
1422
|
+
status = 1 # Try again (one more time, until timeout is reached)
|
|
1423
|
+
else:
|
|
1424
|
+
|
|
1425
|
+
# multistep actions
|
|
1426
|
+
if action.cannot_be_run_anymore(): # Timeout, max time reached, max steps reached
|
|
1427
|
+
if HybridStateMachine.DEBUG:
|
|
1428
|
+
print(f"[DEBUG HSM] multistep action {self.__action.name} returned False and "
|
|
1429
|
+
f"cannot-be-run-anymore "
|
|
1430
|
+
f"(step: {action.get_step()}, "
|
|
1431
|
+
f"has_completion_step: {self.__action.has_completion_step()})")
|
|
1432
|
+
status = 2 # Move to the next action, since the final communication failed
|
|
1433
|
+
else:
|
|
1434
|
+
status = 1 # Try again (same step)
|
|
1435
|
+
|
|
1436
|
+
if HybridStateMachine.DEBUG:
|
|
1437
|
+
print(f"[DEBUG HSM] Action {self.__action.name}, after being called, leaded to status: {status}")
|
|
1438
|
+
|
|
1439
|
+
# Post-call operations
|
|
1440
|
+
if status == 0: # Done
|
|
1441
|
+
|
|
1442
|
+
# Clearing request
|
|
1443
|
+
requests = self.__action.get_requests()
|
|
1444
|
+
if requester is not None and requester in requests:
|
|
1445
|
+
del requests[requester]
|
|
1446
|
+
|
|
1447
|
+
# State transition
|
|
1448
|
+
self.prev_state = self.limbo_state
|
|
1449
|
+
self.state = to_state_list[idx]
|
|
1450
|
+
self.limbo_state = None
|
|
1451
|
+
|
|
1452
|
+
# Update status
|
|
1453
|
+
self.__state_changed = self.state != self.prev_state # Checking if we are on a self-loop or not
|
|
1454
|
+
|
|
1455
|
+
# If we moved to another state, clearing all the pending annotations for the next possible actions
|
|
1456
|
+
if self.__state_changed:
|
|
1457
|
+
if HybridStateMachine.DEBUG:
|
|
1458
|
+
print(f"[DEBUG HSM] Moving to state: {self.state}")
|
|
1459
|
+
for to_state, action_list in self.transitions[self.state].items():
|
|
1460
|
+
for i, act in enumerate(action_list):
|
|
1461
|
+
act.clear_requests()
|
|
1462
|
+
|
|
1463
|
+
# Propagating (trying to propagate forward the residual requests)
|
|
1464
|
+
residual_requests = self.__action.get_requests()
|
|
1465
|
+
for _requester, (_requested_args, _request_time, _request_uuid) in residual_requests.items():
|
|
1466
|
+
self.request_action(_requester, action_name=self.__action.name, args=_requested_args,
|
|
1467
|
+
from_state=None, to_state=None, timestamp=_request_time, uuid=_request_uuid)
|
|
1468
|
+
|
|
1469
|
+
if HybridStateMachine.DEBUG:
|
|
1470
|
+
print(f"[DEBUG HSM] Correctly completed action: {self.__action.name}")
|
|
1471
|
+
|
|
1472
|
+
self.states[self.prev_state].reset() # Reset starting time
|
|
1473
|
+
self.__action.reset_step()
|
|
1474
|
+
self.__action = None # Clearing
|
|
1475
|
+
self.__cur_feasible_actions_status = None
|
|
1476
|
+
|
|
1477
|
+
return 0 # Transition done, no need to check other actions!
|
|
1478
|
+
|
|
1479
|
+
elif status == 1: # Try again the same action (either a new step or an already done-and-failed one)
|
|
1480
|
+
|
|
1481
|
+
# Update status
|
|
1482
|
+
self.__state_changed = False
|
|
1483
|
+
if self.prev_state is not None:
|
|
1484
|
+
self.states[self.prev_state].reset() # Reset starting time
|
|
1485
|
+
|
|
1486
|
+
return 1 # Transition not-done: no need to check other actions, the current one will be run again
|
|
1487
|
+
|
|
1488
|
+
elif status == 2: # Move to the next action
|
|
1489
|
+
|
|
1490
|
+
# Clearing request
|
|
1491
|
+
requests = self.__action.get_requests()
|
|
1492
|
+
if requester is not None and requester in requests:
|
|
1493
|
+
del requests[requester]
|
|
1494
|
+
|
|
1495
|
+
# Back to the original state
|
|
1496
|
+
self.state = self.limbo_state
|
|
1497
|
+
self.limbo_state = None
|
|
1498
|
+
if HybridStateMachine.DEBUG:
|
|
1499
|
+
print(f"[DEBUG HSM] Tried and failed (failed execution): {action.name}")
|
|
1500
|
+
|
|
1501
|
+
# Purging action from the current list
|
|
1502
|
+
del actions_list[idx]
|
|
1503
|
+
del to_state_list[idx]
|
|
1504
|
+
|
|
1505
|
+
# Update status
|
|
1506
|
+
self.__state_changed = False
|
|
1507
|
+
self.__action.reset_step()
|
|
1508
|
+
self.__action = None # Clearing
|
|
1509
|
+
|
|
1510
|
+
continue # Move to the next action
|
|
1511
|
+
else:
|
|
1512
|
+
raise ValueError("Unexpected status: " + str(status))
|
|
1513
|
+
|
|
1514
|
+
# No actions were applied
|
|
1515
|
+
self.__cur_feasible_actions_status = None
|
|
1516
|
+
self.__state_changed = False
|
|
1517
|
+
return -1
|
|
1518
|
+
|
|
1519
|
+
def act(self):
|
|
1520
|
+
"""A high-level method that combines `act_states` and `act_transitions` to run the state machine. It repeatedly
|
|
1521
|
+
processes states and transitions until a blocking state is reached or all feasible actions have been tried,
|
|
1522
|
+
thus ensuring a complete processing cycle in one call.
|
|
1523
|
+
"""
|
|
1524
|
+
|
|
1525
|
+
# It keeps processing states and actions, until all the current feasible actions fail
|
|
1526
|
+
# (also when a step of a multistep action is executed) or a blocking state is reached
|
|
1527
|
+
while True:
|
|
1528
|
+
self.act_states()
|
|
1529
|
+
ret = self.act_transitions(self.must_wait())
|
|
1530
|
+
if ret != 0 or (self.state is not None and self.states[self.state].blocking):
|
|
1531
|
+
break
|
|
1532
|
+
|
|
1533
|
+
def get_state_changed(self):
|
|
1534
|
+
"""Returns an internal flag that indicates if a state transition has occurred in the last execution cycle.
|
|
1535
|
+
This can be used by an external loop to know when to re-evaluate the state machine's context.
|
|
1536
|
+
|
|
1537
|
+
Returns:
|
|
1538
|
+
True if the state has changed, False otherwise.
|
|
1539
|
+
"""
|
|
1540
|
+
return self.__state_changed
|
|
1541
|
+
|
|
1542
|
+
def request_action(self, signature: object, action_name: str, args: dict | None = None,
|
|
1543
|
+
from_state: str | None = None, to_state: str | None = None,
|
|
1544
|
+
timestamp: float | None = None, uuid: str | None = None):
|
|
1545
|
+
"""Allows an external entity to request a specific action. The request is validated by a signature checker
|
|
1546
|
+
(if one exists) and then queued on the corresponding action. This method enables dynamic, external triggers for
|
|
1547
|
+
state machine transitions.
|
|
1548
|
+
|
|
1549
|
+
Args:
|
|
1550
|
+
signature: An object used for validating the request's origin.
|
|
1551
|
+
action_name: The name of the requested action.
|
|
1552
|
+
args: Arguments for the requested action.
|
|
1553
|
+
from_state: The optional starting state for the requested transition.
|
|
1554
|
+
to_state: The optional destination state for the requested transition.
|
|
1555
|
+
timestamp: The time the request was made.
|
|
1556
|
+
uuid: A unique identifier for the request.
|
|
1557
|
+
|
|
1558
|
+
Returns:
|
|
1559
|
+
True if the request was accepted and queued, False otherwise.
|
|
1560
|
+
"""
|
|
1561
|
+
if HybridStateMachine.DEBUG:
|
|
1562
|
+
print(f"[DEBUG HSM] Received a request signed as {signature}, "
|
|
1563
|
+
f"asking for action {action_name}, with args: {args}, "
|
|
1564
|
+
f"from_state: {from_state}, to_state: {to_state}, uuid: {uuid}")
|
|
1565
|
+
|
|
1566
|
+
# Discard suggestions if they are not trusted
|
|
1567
|
+
if self.request_signature_checker is not None and not self.request_signature_checker(signature):
|
|
1568
|
+
if HybridStateMachine.DEBUG:
|
|
1569
|
+
print("[DEBUG HSM] Request signature check failed")
|
|
1570
|
+
return False
|
|
1571
|
+
|
|
1572
|
+
# If state is not provided, the current state is assumed
|
|
1573
|
+
if from_state is None:
|
|
1574
|
+
from_state = self.state
|
|
1575
|
+
if from_state not in self.transitions:
|
|
1576
|
+
if HybridStateMachine.DEBUG:
|
|
1577
|
+
print(f"[DEBUG HSM] Request not accepted: not valid source state ({from_state})")
|
|
1578
|
+
return False
|
|
1579
|
+
|
|
1580
|
+
# If the destination state is not provided, all the possible destination from the current state are considered
|
|
1581
|
+
if to_state is not None and to_state not in self.transitions[from_state]:
|
|
1582
|
+
if HybridStateMachine.DEBUG:
|
|
1583
|
+
print(f"[DEBUG HSM] Request not accepted: not valid destination state ({to_state})")
|
|
1584
|
+
return False
|
|
1585
|
+
to_states = self.transitions[from_state].keys() if to_state is None else [to_state]
|
|
1586
|
+
|
|
1587
|
+
for to_state in to_states:
|
|
1588
|
+
action_list = self.transitions[from_state][to_state]
|
|
1589
|
+
for i, action in enumerate(action_list):
|
|
1590
|
+
if HybridStateMachine.DEBUG:
|
|
1591
|
+
print(f"[DEBUG HSM] Comparing with action: {str(action)}")
|
|
1592
|
+
if action.same_as(name=action_name, args=args):
|
|
1593
|
+
if HybridStateMachine.DEBUG:
|
|
1594
|
+
print("[DEBUG HSM] Requested action found, adding request to the queue")
|
|
1595
|
+
|
|
1596
|
+
# Action found, let's save the suggestion
|
|
1597
|
+
action.add_request(signature, args, timestamp=timestamp, uuid=uuid)
|
|
1598
|
+
return True
|
|
1599
|
+
|
|
1600
|
+
# If the action was not found
|
|
1601
|
+
if HybridStateMachine.DEBUG:
|
|
1602
|
+
print("[DEBUG HSM] Requested action not found")
|
|
1603
|
+
return False
|
|
1604
|
+
|
|
1605
|
+
def wait_for_all_actions_that_start_with(self, prefix):
|
|
1606
|
+
"""Sets the `ready` flag to `False` for all actions whose name begins with a given prefix. This method is used
|
|
1607
|
+
to programmatically disable a group of actions, effectively pausing them.
|
|
1608
|
+
|
|
1609
|
+
Args:
|
|
1610
|
+
prefix: The string prefix to match against action names.
|
|
1611
|
+
"""
|
|
1612
|
+
for state, to_states in self.transitions.items():
|
|
1613
|
+
for to_state, action_list in to_states.items():
|
|
1614
|
+
for i, action in enumerate(action_list):
|
|
1615
|
+
if action.name.startswith(prefix):
|
|
1616
|
+
action.set_as_not_ready()
|
|
1617
|
+
|
|
1618
|
+
def wait_for_all_actions_that_include_an_arg(self, arg_name):
|
|
1619
|
+
"""Sets the `ready` flag to `False` for all actions that include a specific argument name in their signature.
|
|
1620
|
+
This provides another way to programmatically disable actions.
|
|
1621
|
+
|
|
1622
|
+
Args:
|
|
1623
|
+
arg_name: The name of the argument to look for.
|
|
1624
|
+
"""
|
|
1625
|
+
for state, to_states in self.transitions.items():
|
|
1626
|
+
for to_state, action_list in to_states.items():
|
|
1627
|
+
for i, action in enumerate(action_list):
|
|
1628
|
+
if arg_name in action.args:
|
|
1629
|
+
action.set_as_not_ready()
|
|
1630
|
+
|
|
1631
|
+
def wait_for_actions(self, from_state: str, to_state: str, wait: bool = True):
|
|
1632
|
+
"""Sets the `ready` flag for a specific action (or group of actions) between two states. This allows for
|
|
1633
|
+
fine-grained control over which transitions are active.
|
|
1634
|
+
|
|
1635
|
+
Args:
|
|
1636
|
+
from_state: The name of the starting state.
|
|
1637
|
+
to_state: The name of the destination state.
|
|
1638
|
+
wait: A boolean flag to either set the action as not ready (`True`) or ready (`False`).
|
|
1639
|
+
|
|
1640
|
+
Returns:
|
|
1641
|
+
True if the specified action was found, False otherwise.
|
|
1642
|
+
"""
|
|
1643
|
+
if from_state not in self.transitions or to_state not in self.transitions[from_state]:
|
|
1644
|
+
return False
|
|
1645
|
+
|
|
1646
|
+
for action in self.transitions[from_state][to_state]:
|
|
1647
|
+
if wait:
|
|
1648
|
+
action.set_as_not_ready()
|
|
1649
|
+
else:
|
|
1650
|
+
action.set_as_ready()
|
|
1651
|
+
return True
|
|
1652
|
+
|
|
1653
|
+
def save(self, filename: str, only_if_changed: object | None = None):
|
|
1654
|
+
"""Saves the state machine's current configuration to a JSON file. It can optionally check if the configuration
|
|
1655
|
+
has changed before saving to avoid redundant file writes.
|
|
1656
|
+
|
|
1657
|
+
Args:
|
|
1658
|
+
filename: The path to the file to save to.
|
|
1659
|
+
only_if_changed: An optional object to compare against for changes. If a change is not detected, the file
|
|
1660
|
+
is not written.
|
|
1661
|
+
|
|
1662
|
+
Returns:
|
|
1663
|
+
True if the file was written, False otherwise.
|
|
1664
|
+
"""
|
|
1665
|
+
if only_if_changed is not None and os.path.exists(filename):
|
|
1666
|
+
existing = HybridStateMachine(actionable=only_if_changed).load(filename)
|
|
1667
|
+
if str(existing) == str(self):
|
|
1668
|
+
return False
|
|
1669
|
+
|
|
1670
|
+
with (open(filename, 'w') as file):
|
|
1671
|
+
file.write(str(self))
|
|
1672
|
+
return True
|
|
1673
|
+
|
|
1674
|
+
def load(self, filename_or_hsm_as_string: str | io.TextIOWrapper):
|
|
1675
|
+
"""Loads a state machine's configuration from a JSON file or a JSON string. It reconstructs the states,
|
|
1676
|
+
actions, and transitions from the serialized data. This method is critical for persistence and for loading
|
|
1677
|
+
pre-defined state machine models.
|
|
1678
|
+
|
|
1679
|
+
Args:
|
|
1680
|
+
filename_or_hsm_as_string: The path to the JSON file or a JSON string representation of the state machine.
|
|
1681
|
+
|
|
1682
|
+
Returns:
|
|
1683
|
+
The loaded `HybridStateMachine` object (self).
|
|
1684
|
+
"""
|
|
1685
|
+
|
|
1686
|
+
# Loading the whole file
|
|
1687
|
+
if (isinstance(filename_or_hsm_as_string, importlib.resources.abc.Traversable) or
|
|
1688
|
+
isinstance(filename_or_hsm_as_string, io.TextIOWrapper)):
|
|
1689
|
+
|
|
1690
|
+
# Safe way to load when this file is packed in a pip package
|
|
1691
|
+
hsm_data = json.load(filename_or_hsm_as_string)
|
|
1692
|
+
else:
|
|
1693
|
+
|
|
1694
|
+
# Ordinary case
|
|
1695
|
+
if os.path.exists(filename_or_hsm_as_string) and os.path.isfile(filename_or_hsm_as_string):
|
|
1696
|
+
with open(filename_or_hsm_as_string, 'r') as file:
|
|
1697
|
+
hsm_data = json.load(file)
|
|
1698
|
+
else:
|
|
1699
|
+
assert not filename_or_hsm_as_string.endswith(".json"), \
|
|
1700
|
+
f"File {filename_or_hsm_as_string} does not exist"
|
|
1701
|
+
hsm_data = json.loads(filename_or_hsm_as_string)
|
|
1702
|
+
|
|
1703
|
+
# Getting state info
|
|
1704
|
+
self.initial_state = hsm_data['initial_state']
|
|
1705
|
+
self.state = hsm_data['state']
|
|
1706
|
+
self.prev_state = hsm_data['prev_state']
|
|
1707
|
+
self.limbo_state = hsm_data['limbo_state']
|
|
1708
|
+
self.role = hsm_data.get('role', None)
|
|
1709
|
+
|
|
1710
|
+
# Getting states
|
|
1711
|
+
self.states = {}
|
|
1712
|
+
if 'state_actions' in hsm_data:
|
|
1713
|
+
for state, state_action_list in hsm_data['state_actions'].items():
|
|
1714
|
+
if len(state_action_list) == 3: # Backward compatibility
|
|
1715
|
+
act_name, act_args, state_id = state_action_list
|
|
1716
|
+
waiting_time = 0.
|
|
1717
|
+
blocking = True
|
|
1718
|
+
msg = None
|
|
1719
|
+
elif len(state_action_list) == 4: # Backward compatibility
|
|
1720
|
+
act_name, act_args, state_id, waiting_time = state_action_list
|
|
1721
|
+
blocking = True
|
|
1722
|
+
msg = None
|
|
1723
|
+
elif len(state_action_list) == 5: # Backward compatibility
|
|
1724
|
+
act_name, act_args, state_id, blocking, waiting_time = state_action_list
|
|
1725
|
+
msg = None
|
|
1726
|
+
else:
|
|
1727
|
+
act_name, act_args, state_id, blocking, waiting_time, msg = state_action_list
|
|
1728
|
+
|
|
1729
|
+
# Recall that state_id can be set to -1 in the original file, meaning "automatically set the state_id"
|
|
1730
|
+
self.add_state(state, action=act_name, args=act_args,
|
|
1731
|
+
state_id=state_id if state_id >= 0 else None,
|
|
1732
|
+
waiting_time=waiting_time, blocking=blocking, msg=msg)
|
|
1733
|
+
|
|
1734
|
+
# Getting transitions
|
|
1735
|
+
self.transitions = {}
|
|
1736
|
+
for from_state, to_states in hsm_data['transitions'].items():
|
|
1737
|
+
for to_state, action_list in to_states.items():
|
|
1738
|
+
for action_list_tuple in action_list:
|
|
1739
|
+
if len(action_list_tuple) == 4:
|
|
1740
|
+
act_name, act_args, act_ready, act_id = action_list_tuple
|
|
1741
|
+
msg = None
|
|
1742
|
+
else:
|
|
1743
|
+
act_name, act_args, act_ready, act_id, msg = action_list_tuple
|
|
1744
|
+
|
|
1745
|
+
# Recall that act_id can be set to -1 in the original file, meaning "automatically set the act_id"
|
|
1746
|
+
self.add_transit(from_state, to_state,
|
|
1747
|
+
action=act_name, args=act_args, ready=act_ready,
|
|
1748
|
+
act_id=act_id if act_id >= 0 else None, msg=msg)
|
|
1749
|
+
|
|
1750
|
+
return self
|
|
1751
|
+
|
|
1752
|
+
def to_graphviz(self):
|
|
1753
|
+
"""Generates a Graphviz `Digraph` object representing the state machine's structure. This method visualizes
|
|
1754
|
+
states as nodes and transitions as edges. It includes details such as node shapes (diamond for initial state,
|
|
1755
|
+
oval for others), styles (filled for blocking states), and labels for both states and transitions. The labels
|
|
1756
|
+
for actions include their names and arguments, formatted to wrap lines for readability.
|
|
1757
|
+
|
|
1758
|
+
Returns:
|
|
1759
|
+
A `graphviz.Digraph` object ready for rendering.
|
|
1760
|
+
"""
|
|
1761
|
+
graph = graphviz.Digraph()
|
|
1762
|
+
graph.attr('node', fontsize='8')
|
|
1763
|
+
for state, state_obj in self.states.items():
|
|
1764
|
+
action = state_obj.action
|
|
1765
|
+
if action is not None:
|
|
1766
|
+
s = "("
|
|
1767
|
+
for i, (k, v) in enumerate(action.args.items()):
|
|
1768
|
+
s += str(k) + "=" + (str(v) if not isinstance(v, str) else ("'" + v + "'"))
|
|
1769
|
+
if i < len(action.args) - 1:
|
|
1770
|
+
s += ", "
|
|
1771
|
+
s += ")"
|
|
1772
|
+
label = action.name + s
|
|
1773
|
+
if len(label) > 40:
|
|
1774
|
+
tokens = label.split(" ")
|
|
1775
|
+
z = ""
|
|
1776
|
+
i = 0
|
|
1777
|
+
done = False
|
|
1778
|
+
while i < len(tokens):
|
|
1779
|
+
z += (" " if i > 0 else "") + tokens[i]
|
|
1780
|
+
if not done and i < (len(tokens) - 1) and len(z + tokens[i + 1]) > 40:
|
|
1781
|
+
z += "\n "
|
|
1782
|
+
done = True
|
|
1783
|
+
i += 1
|
|
1784
|
+
label = z
|
|
1785
|
+
suffix = "\n" + label
|
|
1786
|
+
else:
|
|
1787
|
+
suffix = ""
|
|
1788
|
+
if state == self.initial_state:
|
|
1789
|
+
graph.attr('node', shape='diamond')
|
|
1790
|
+
else:
|
|
1791
|
+
graph.attr('node', shape='oval')
|
|
1792
|
+
if self.states[state].blocking:
|
|
1793
|
+
graph.attr('node', style='filled')
|
|
1794
|
+
else:
|
|
1795
|
+
graph.attr('node', style='solid')
|
|
1796
|
+
graph.node(state, state + suffix, _attributes={'id': "node" + str(state_obj.id)})
|
|
1797
|
+
|
|
1798
|
+
for from_state, to_states in self.transitions.items():
|
|
1799
|
+
for to_state, action_list in to_states.items():
|
|
1800
|
+
for action in action_list:
|
|
1801
|
+
s = "("
|
|
1802
|
+
for i, (k, v) in enumerate(action.args.items()):
|
|
1803
|
+
s += str(k) + "=" + (str(v) if not isinstance(v, str) else ("'" + v + "'"))
|
|
1804
|
+
if i < len(action.args) - 1:
|
|
1805
|
+
s += ", "
|
|
1806
|
+
s += ")"
|
|
1807
|
+
label = action.name + s
|
|
1808
|
+
if len(label) > 40:
|
|
1809
|
+
tokens = label.split(" ")
|
|
1810
|
+
z = ""
|
|
1811
|
+
i = 0
|
|
1812
|
+
done = False
|
|
1813
|
+
while i < len(tokens):
|
|
1814
|
+
z += (" " if i > 0 else "") + tokens[i]
|
|
1815
|
+
if not done and i < (len(tokens) - 1) and len(z + tokens[i + 1]) > 40:
|
|
1816
|
+
z += "\n"
|
|
1817
|
+
done = True
|
|
1818
|
+
i += 1
|
|
1819
|
+
label = z
|
|
1820
|
+
graph.edge(from_state, to_state, label=" " + label + " ", fontsize='8',
|
|
1821
|
+
style='dashed' if not action.is_ready() else 'solid',
|
|
1822
|
+
_attributes={'id': "edge" + str(action.id)})
|
|
1823
|
+
return graph
|
|
1824
|
+
|
|
1825
|
+
def save_pdf(self, filename: str):
|
|
1826
|
+
"""Saves the state machine's Graphviz representation as a PDF file. It calls `to_graphviz()` to create the
|
|
1827
|
+
graph and then uses the Graphviz library's `render` method to generate the PDF.
|
|
1828
|
+
|
|
1829
|
+
Args:
|
|
1830
|
+
filename: The path and name of the PDF file to save.
|
|
1831
|
+
|
|
1832
|
+
Returns:
|
|
1833
|
+
True if the file was successfully saved, False otherwise.
|
|
1834
|
+
"""
|
|
1835
|
+
if filename.lower().endswith(".pdf"):
|
|
1836
|
+
filename = filename[0:-4]
|
|
1837
|
+
|
|
1838
|
+
try:
|
|
1839
|
+
self.to_graphviz().render(filename, format='pdf', cleanup=True)
|
|
1840
|
+
return True
|
|
1841
|
+
except Exception:
|
|
1842
|
+
return False
|
|
1843
|
+
|
|
1844
|
+
def print_actions(self, state: str | None = None):
|
|
1845
|
+
"""Prints a list of all transitions and their associated actions from a given state. If no state is provided,
|
|
1846
|
+
it defaults to the current state. This method is useful for quickly inspecting the available transitions from
|
|
1847
|
+
a specific point in the state machine's flow.
|
|
1848
|
+
|
|
1849
|
+
Args:
|
|
1850
|
+
state: The name of the state from which to print actions. Defaults to the current state.
|
|
1851
|
+
"""
|
|
1852
|
+
state = (self.state if self.state is not None else self.limbo_state) if state is None else state
|
|
1853
|
+
for to_state, action_list in self.transitions[state].items():
|
|
1854
|
+
if action_list is None or len(action_list) == 0:
|
|
1855
|
+
print(f"{state}, no actions")
|
|
1856
|
+
for action in action_list:
|
|
1857
|
+
print(f"{state} --> {to_state} {action}")
|
|
1858
|
+
|
|
1859
|
+
# Noinspection PyMethodMayBeStatic
|
|
1860
|
+
def __policy_first_requested_or_first_ready(self, actions_list: list[Action]) \
|
|
1861
|
+
-> tuple[int, tuple[object | None, tuple[dict, float, str | None]]]:
|
|
1862
|
+
"""This is the default policy for selecting which action to execute from a list of feasible actions.
|
|
1863
|
+
It prioritizes actions that have been explicitly requested (i.e., have pending requests) on a first-come,
|
|
1864
|
+
first-served basis. If no requested actions are found, it then selects the first action in the list that is
|
|
1865
|
+
marked as `ready`.
|
|
1866
|
+
|
|
1867
|
+
Args:
|
|
1868
|
+
actions_list: A list of `Action` objects that are candidates for execution.
|
|
1869
|
+
|
|
1870
|
+
Returns:
|
|
1871
|
+
A tuple containing the index of the selected action and a tuple of the requester details (object,
|
|
1872
|
+
arguments, time, and UUID), or -1 and `None` if no action is selected.
|
|
1873
|
+
"""
|
|
1874
|
+
for i, action in enumerate(actions_list):
|
|
1875
|
+
if len(action.get_requests()) > 0:
|
|
1876
|
+
return i, next(iter(action.get_requests().items()))
|
|
1877
|
+
for i, action in enumerate(actions_list):
|
|
1878
|
+
if action.is_ready(consider_requests=False):
|
|
1879
|
+
return i, (None, ({}, -1., None))
|
|
1880
|
+
return -1, (None, ({}, -1., None))
|