vention-state-machine 0.1.0__tar.gz
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.
- vention_state_machine-0.1.0/PKG-INFO +295 -0
- vention_state_machine-0.1.0/README.md +275 -0
- vention_state_machine-0.1.0/pyproject.toml +26 -0
- vention_state_machine-0.1.0/src/state_machine/__init__.py +0 -0
- vention_state_machine-0.1.0/src/state_machine/core.py +303 -0
- vention_state_machine-0.1.0/src/state_machine/decorator_manager.py +102 -0
- vention_state_machine-0.1.0/src/state_machine/decorator_protocols.py +23 -0
- vention_state_machine-0.1.0/src/state_machine/decorators.py +116 -0
- vention_state_machine-0.1.0/src/state_machine/defs.py +100 -0
- vention_state_machine-0.1.0/src/state_machine/machine_protocols.py +33 -0
- vention_state_machine-0.1.0/src/state_machine/router.py +181 -0
- vention_state_machine-0.1.0/src/state_machine/utils.py +56 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: vention-state-machine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Declarative state machine framework for machine apps
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Author: VentionCo
|
|
7
|
+
Requires-Python: >=3.9,<3.11
|
|
8
|
+
Classifier: License :: Other/Proprietary License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Requires-Dist: asyncio (>=3.4.3,<4.0.0)
|
|
13
|
+
Requires-Dist: coverage (>=7.10.1,<8.0.0)
|
|
14
|
+
Requires-Dist: fastapi (>=0.116.1,<0.117.0)
|
|
15
|
+
Requires-Dist: graphviz (>=0.21,<0.22)
|
|
16
|
+
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
17
|
+
Requires-Dist: transitions (>=0.9.3,<0.10.0)
|
|
18
|
+
Requires-Dist: uvicorn (>=0.35.0,<0.36.0)
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# vention-state-machine
|
|
22
|
+
|
|
23
|
+
A lightweight wrapper around `transitions` for building async-safe, recoverable hierarchical state machines with minimal boilerplate.
|
|
24
|
+
|
|
25
|
+
## โจ Features
|
|
26
|
+
|
|
27
|
+
- Built-in `ready` / `fault` states
|
|
28
|
+
- Global transitions: `to_fault`, `reset`
|
|
29
|
+
- Optional state recovery (`recover__state`)
|
|
30
|
+
- Async task spawning and cancellation
|
|
31
|
+
- Timeouts and auto-fault handling
|
|
32
|
+
- Transition history recording with timestamps + durations
|
|
33
|
+
- Guard conditions for blocking transitions
|
|
34
|
+
- Global state change callbacks for logging/MQTT
|
|
35
|
+
|
|
36
|
+
## ๐ง Domain-Specific Language
|
|
37
|
+
|
|
38
|
+
This library uses a **declarative domain-specific language (DSL)** to define state machines in a readable and structured way. The key building blocks are:
|
|
39
|
+
|
|
40
|
+
- **`State`**: Represents a single leaf node in the state machine. Declared as a class attribute.
|
|
41
|
+
|
|
42
|
+
- **`StateGroup`**: A container for related states. It generates a hierarchical namespace for its child states (e.g. `MyGroup.my_state` becomes `"MyGroup_my_state"`).
|
|
43
|
+
|
|
44
|
+
- **`Trigger`**: Defines a named event that can cause state transitions. It is both callable (returns its name as a string) and composable (can generate transition dictionaries via `.transition(...)`).
|
|
45
|
+
|
|
46
|
+
This structure allows you to define states and transitions with strong typing and full IDE support โ without strings scattered across your codebase.
|
|
47
|
+
|
|
48
|
+
For Example:
|
|
49
|
+
```python
|
|
50
|
+
class MyStates(StateGroup):
|
|
51
|
+
idle: State = State()
|
|
52
|
+
working: State = State()
|
|
53
|
+
|
|
54
|
+
class Triggers:
|
|
55
|
+
begin = Trigger("begin")
|
|
56
|
+
finish = Trigger("finish")
|
|
57
|
+
```
|
|
58
|
+
You can then define transitions declaratively:
|
|
59
|
+
```python
|
|
60
|
+
TRANSITIONS = [
|
|
61
|
+
Triggers.finish.transition(MyStates.working, MyStates.idle),
|
|
62
|
+
]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## ๐งฑ Base States and Triggers
|
|
66
|
+
|
|
67
|
+
Every machine comes with built-in:
|
|
68
|
+
|
|
69
|
+
- **States**:
|
|
70
|
+
- `ready`: initial state
|
|
71
|
+
- `fault`: global error state
|
|
72
|
+
- **Triggers**:
|
|
73
|
+
- `start`: transition into the first defined state
|
|
74
|
+
- `to_fault`: jump to fault from any state
|
|
75
|
+
- `reset`: recover from fault back to ready
|
|
76
|
+
|
|
77
|
+
You can reference these via:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from state_machine.core import BaseStates, BaseTriggers
|
|
81
|
+
|
|
82
|
+
state_machine.trigger(BaseTriggers.RESET.value)
|
|
83
|
+
assert state_machine.state == BaseStates.READY.value
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## ๐ Quick Start
|
|
87
|
+
### 1. Define Your States and Triggers
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from state_machine.defs import StateGroup, State, Trigger
|
|
91
|
+
|
|
92
|
+
class Running(StateGroup):
|
|
93
|
+
picking: State = State()
|
|
94
|
+
placing: State = State()
|
|
95
|
+
homing: State = State()
|
|
96
|
+
|
|
97
|
+
class States:
|
|
98
|
+
running = Running()
|
|
99
|
+
|
|
100
|
+
class Triggers:
|
|
101
|
+
start = Trigger("start")
|
|
102
|
+
finished_picking = Trigger("finished_picking")
|
|
103
|
+
finished_placing = Trigger("finished_placing")
|
|
104
|
+
finished_homing = Trigger("finished_homing")
|
|
105
|
+
to_fault = Trigger("to_fault")
|
|
106
|
+
reset = Trigger("reset")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 2. Define Transitions
|
|
110
|
+
```python
|
|
111
|
+
TRANSITIONS = [
|
|
112
|
+
Triggers.start.transition("ready", States.running.picking),
|
|
113
|
+
Triggers.finished_picking.transition(States.running.picking, States.running.placing),
|
|
114
|
+
Triggers.finished_placing.transition(States.running.placing, States.running.homing),
|
|
115
|
+
Triggers.finished_homing.transition(States.running.homing, States.running.picking)
|
|
116
|
+
]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 3. Implement Your State Machine
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from state_machine.core import StateMachine
|
|
123
|
+
|
|
124
|
+
from state_machine.decorators import on_enter_state, auto_timeout, guard, on_state_change
|
|
125
|
+
|
|
126
|
+
class CustomMachine(StateMachine):
|
|
127
|
+
|
|
128
|
+
def __init__(self):
|
|
129
|
+
super().__init__(states=States, transitions=TRANSITIONS)
|
|
130
|
+
|
|
131
|
+
# Automatically trigger to_fault after 5s if no progress
|
|
132
|
+
@on_enter_state(States.running.picking)
|
|
133
|
+
@auto_timeout(5.0, Triggers.to_fault)
|
|
134
|
+
def enter_picking(self, _):
|
|
135
|
+
print("๐น Entering picking")
|
|
136
|
+
|
|
137
|
+
@on_enter_state(States.running.placing)
|
|
138
|
+
def enter_placing(self, _):
|
|
139
|
+
print("๐ธ Entering placing")
|
|
140
|
+
|
|
141
|
+
@on_enter_state(States.running.homing)
|
|
142
|
+
def enter_homing(self, _):
|
|
143
|
+
print("๐บ Entering homing")
|
|
144
|
+
|
|
145
|
+
# Guard condition - only allow reset when safety conditions are met
|
|
146
|
+
@guard(Triggers.reset)
|
|
147
|
+
def check_safety_conditions(self) -> bool:
|
|
148
|
+
"""Only allow reset when estop is not pressed."""
|
|
149
|
+
return not self.estop_pressed
|
|
150
|
+
|
|
151
|
+
# Global state change callback for MQTT publishing
|
|
152
|
+
@on_state_change
|
|
153
|
+
def publish_state_to_mqtt(self, old_state: str, new_state: str, trigger: str):
|
|
154
|
+
"""Publish state changes to MQTT."""
|
|
155
|
+
mqtt_client.publish("machine/state", {
|
|
156
|
+
"old_state": old_state,
|
|
157
|
+
"new_state": new_state,
|
|
158
|
+
"trigger": trigger
|
|
159
|
+
})
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### 4. Start It
|
|
163
|
+
```python
|
|
164
|
+
state_machine = StateMachine()
|
|
165
|
+
state_machine.start() # Enters last recorded state (if recovery enabled), else first state
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## ๐ Optional FastAPI Router
|
|
169
|
+
This library provides a FastAPI-compatible router that automatically exposes your state machine over HTTP. This is useful for:
|
|
170
|
+
|
|
171
|
+
- Triggering transitions via HTTP POST
|
|
172
|
+
- Inspecting current state and state history
|
|
173
|
+
#### Example
|
|
174
|
+
```python
|
|
175
|
+
from fastapi import FastAPI
|
|
176
|
+
from state_machine.router import build_router
|
|
177
|
+
from state_machine.core import StateMachine
|
|
178
|
+
|
|
179
|
+
state_machine = StateMachine(...)
|
|
180
|
+
state_machine.start()
|
|
181
|
+
|
|
182
|
+
app = FastAPI()
|
|
183
|
+
app.include_router(build_router(state_machine))
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Available Routes
|
|
187
|
+
* `GET /state`: Returns current and last known state
|
|
188
|
+
* `GET /history`: Returns list of recent state transitions
|
|
189
|
+
* `POST /<trigger_name>`: Triggers a transition by name
|
|
190
|
+
|
|
191
|
+
You can expose only a subset of triggers by passing them explicitly:
|
|
192
|
+
```python
|
|
193
|
+
from state_machine.defs import Trigger
|
|
194
|
+
# Only create endpoints for 'start' and 'reset'
|
|
195
|
+
router = build_router(state_machine, triggers=[Trigger("start"), Trigger("reset")])
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Diagram Visualization
|
|
199
|
+
|
|
200
|
+
- `GET /diagram.svg`: Returns a Graphviz-generated SVG of the state machine.
|
|
201
|
+
- Current state is highlighted in red.
|
|
202
|
+
- Previous state and the transition taken are highlighted in blue.
|
|
203
|
+
- Requires Graphviz installed on the system. If Graphviz is missing, the endpoint returns 503 Service Unavailable.
|
|
204
|
+
|
|
205
|
+
Example usage:
|
|
206
|
+
```bash
|
|
207
|
+
curl http://localhost:8000/diagram.svg > machine.svg
|
|
208
|
+
open machine.svg
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## ๐งช Testing & History
|
|
212
|
+
- `state_machine.history`: List of all transitions with timestamps and durations
|
|
213
|
+
- `state_machine.last(n)`: Last `n` transitions
|
|
214
|
+
- `state_machine.record_last_state()`: Manually record current state for later recovery
|
|
215
|
+
- `state_machine.get_last_state()`: Retrieve recorded state
|
|
216
|
+
|
|
217
|
+
## โฒ Timeout Example
|
|
218
|
+
Any `on_enter_state` method can be wrapped with `@auto_timeout(seconds, trigger_fn)`, e.g.:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
@auto_timeout(5.0, Triggers.to_fault)
|
|
222
|
+
```
|
|
223
|
+
This automatically triggers `to_fault()` if the state remains active after 5 seconds.
|
|
224
|
+
|
|
225
|
+
## ๐ Recovery Example
|
|
226
|
+
Enable `enable_last_state_recovery=True` and use:
|
|
227
|
+
```python
|
|
228
|
+
state_machine.start()
|
|
229
|
+
```
|
|
230
|
+
If a last state was recorded, it will trigger `recover__{last_state}` instead of `start`.
|
|
231
|
+
|
|
232
|
+
## ๐งฉ How Decorators Work
|
|
233
|
+
|
|
234
|
+
Decorators attach metadata to your methods:
|
|
235
|
+
|
|
236
|
+
- `@on_enter_state(state)` binds to the state's entry callback
|
|
237
|
+
- `@on_exit_state(state)` binds to the state's exit callback
|
|
238
|
+
- `@auto_timeout(seconds, trigger)` schedules a timeout once the state is entered
|
|
239
|
+
- `@guard(trigger)` adds a condition that must be true for the transition to proceed
|
|
240
|
+
- `@on_state_change` registers a global callback that fires on every state transition
|
|
241
|
+
|
|
242
|
+
The library automatically discovers and wires these up when your machine is initialized.
|
|
243
|
+
|
|
244
|
+
## ๐ก๏ธ Guard Conditions
|
|
245
|
+
|
|
246
|
+
Guard conditions allow you to block transitions based on runtime conditions. They can be applied to single or multiple triggers:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
# Single trigger
|
|
250
|
+
@guard(Triggers.reset)
|
|
251
|
+
def check_safety_conditions(self) -> bool:
|
|
252
|
+
"""Only allow reset when estop is not pressed."""
|
|
253
|
+
return not self.estop_pressed
|
|
254
|
+
|
|
255
|
+
# Multiple triggers - same guard applies to both
|
|
256
|
+
@guard(Triggers.reset, Triggers.start)
|
|
257
|
+
def check_safety_conditions(self) -> bool:
|
|
258
|
+
"""Check safety conditions for both reset and start."""
|
|
259
|
+
return not self.estop_pressed and self.safety_system_ok
|
|
260
|
+
|
|
261
|
+
# Multiple guard functions for the same trigger - ALL must pass
|
|
262
|
+
@guard(Triggers.reset)
|
|
263
|
+
def check_estop(self) -> bool:
|
|
264
|
+
return not self.estop_pressed
|
|
265
|
+
|
|
266
|
+
@guard(Triggers.reset)
|
|
267
|
+
def check_safety_system(self) -> bool:
|
|
268
|
+
return self.safety_system_ok
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
If any guard function returns `False`, the transition is blocked and the state machine remains in its current state. When multiple guard functions are applied to the same trigger, **ALL conditions must pass** for the transition to be allowed.
|
|
272
|
+
|
|
273
|
+
## ๐ก State Change Callbacks
|
|
274
|
+
|
|
275
|
+
Global state change callbacks are perfect for logging, MQTT publishing, or other side effects. They fire after every successful state transition:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
@on_state_change
|
|
279
|
+
def publish_to_mqtt(self, old_state: str, new_state: str, trigger: str) -> None:
|
|
280
|
+
"""Publish state changes to MQTT."""
|
|
281
|
+
mqtt_client.publish("machine/state", {
|
|
282
|
+
"old_state": old_state,
|
|
283
|
+
"new_state": new_state,
|
|
284
|
+
"trigger": trigger,
|
|
285
|
+
"timestamp": datetime.now().isoformat()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
@on_state_change
|
|
289
|
+
def log_transitions(self, old_state: str, new_state: str, trigger: str) -> None:
|
|
290
|
+
"""Log all state transitions."""
|
|
291
|
+
print(f"State change: {old_state} -> {new_state} (trigger: {trigger})")
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Multiple state change callbacks can be registered and they will all be called in the order they were defined. Callbacks only fire on **successful transitions** - blocked transitions (due to guard conditions) do not trigger callbacks.
|
|
295
|
+
```
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# vention-state-machine
|
|
2
|
+
|
|
3
|
+
A lightweight wrapper around `transitions` for building async-safe, recoverable hierarchical state machines with minimal boilerplate.
|
|
4
|
+
|
|
5
|
+
## โจ Features
|
|
6
|
+
|
|
7
|
+
- Built-in `ready` / `fault` states
|
|
8
|
+
- Global transitions: `to_fault`, `reset`
|
|
9
|
+
- Optional state recovery (`recover__state`)
|
|
10
|
+
- Async task spawning and cancellation
|
|
11
|
+
- Timeouts and auto-fault handling
|
|
12
|
+
- Transition history recording with timestamps + durations
|
|
13
|
+
- Guard conditions for blocking transitions
|
|
14
|
+
- Global state change callbacks for logging/MQTT
|
|
15
|
+
|
|
16
|
+
## ๐ง Domain-Specific Language
|
|
17
|
+
|
|
18
|
+
This library uses a **declarative domain-specific language (DSL)** to define state machines in a readable and structured way. The key building blocks are:
|
|
19
|
+
|
|
20
|
+
- **`State`**: Represents a single leaf node in the state machine. Declared as a class attribute.
|
|
21
|
+
|
|
22
|
+
- **`StateGroup`**: A container for related states. It generates a hierarchical namespace for its child states (e.g. `MyGroup.my_state` becomes `"MyGroup_my_state"`).
|
|
23
|
+
|
|
24
|
+
- **`Trigger`**: Defines a named event that can cause state transitions. It is both callable (returns its name as a string) and composable (can generate transition dictionaries via `.transition(...)`).
|
|
25
|
+
|
|
26
|
+
This structure allows you to define states and transitions with strong typing and full IDE support โ without strings scattered across your codebase.
|
|
27
|
+
|
|
28
|
+
For Example:
|
|
29
|
+
```python
|
|
30
|
+
class MyStates(StateGroup):
|
|
31
|
+
idle: State = State()
|
|
32
|
+
working: State = State()
|
|
33
|
+
|
|
34
|
+
class Triggers:
|
|
35
|
+
begin = Trigger("begin")
|
|
36
|
+
finish = Trigger("finish")
|
|
37
|
+
```
|
|
38
|
+
You can then define transitions declaratively:
|
|
39
|
+
```python
|
|
40
|
+
TRANSITIONS = [
|
|
41
|
+
Triggers.finish.transition(MyStates.working, MyStates.idle),
|
|
42
|
+
]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## ๐งฑ Base States and Triggers
|
|
46
|
+
|
|
47
|
+
Every machine comes with built-in:
|
|
48
|
+
|
|
49
|
+
- **States**:
|
|
50
|
+
- `ready`: initial state
|
|
51
|
+
- `fault`: global error state
|
|
52
|
+
- **Triggers**:
|
|
53
|
+
- `start`: transition into the first defined state
|
|
54
|
+
- `to_fault`: jump to fault from any state
|
|
55
|
+
- `reset`: recover from fault back to ready
|
|
56
|
+
|
|
57
|
+
You can reference these via:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from state_machine.core import BaseStates, BaseTriggers
|
|
61
|
+
|
|
62
|
+
state_machine.trigger(BaseTriggers.RESET.value)
|
|
63
|
+
assert state_machine.state == BaseStates.READY.value
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## ๐ Quick Start
|
|
67
|
+
### 1. Define Your States and Triggers
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from state_machine.defs import StateGroup, State, Trigger
|
|
71
|
+
|
|
72
|
+
class Running(StateGroup):
|
|
73
|
+
picking: State = State()
|
|
74
|
+
placing: State = State()
|
|
75
|
+
homing: State = State()
|
|
76
|
+
|
|
77
|
+
class States:
|
|
78
|
+
running = Running()
|
|
79
|
+
|
|
80
|
+
class Triggers:
|
|
81
|
+
start = Trigger("start")
|
|
82
|
+
finished_picking = Trigger("finished_picking")
|
|
83
|
+
finished_placing = Trigger("finished_placing")
|
|
84
|
+
finished_homing = Trigger("finished_homing")
|
|
85
|
+
to_fault = Trigger("to_fault")
|
|
86
|
+
reset = Trigger("reset")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 2. Define Transitions
|
|
90
|
+
```python
|
|
91
|
+
TRANSITIONS = [
|
|
92
|
+
Triggers.start.transition("ready", States.running.picking),
|
|
93
|
+
Triggers.finished_picking.transition(States.running.picking, States.running.placing),
|
|
94
|
+
Triggers.finished_placing.transition(States.running.placing, States.running.homing),
|
|
95
|
+
Triggers.finished_homing.transition(States.running.homing, States.running.picking)
|
|
96
|
+
]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 3. Implement Your State Machine
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from state_machine.core import StateMachine
|
|
103
|
+
|
|
104
|
+
from state_machine.decorators import on_enter_state, auto_timeout, guard, on_state_change
|
|
105
|
+
|
|
106
|
+
class CustomMachine(StateMachine):
|
|
107
|
+
|
|
108
|
+
def __init__(self):
|
|
109
|
+
super().__init__(states=States, transitions=TRANSITIONS)
|
|
110
|
+
|
|
111
|
+
# Automatically trigger to_fault after 5s if no progress
|
|
112
|
+
@on_enter_state(States.running.picking)
|
|
113
|
+
@auto_timeout(5.0, Triggers.to_fault)
|
|
114
|
+
def enter_picking(self, _):
|
|
115
|
+
print("๐น Entering picking")
|
|
116
|
+
|
|
117
|
+
@on_enter_state(States.running.placing)
|
|
118
|
+
def enter_placing(self, _):
|
|
119
|
+
print("๐ธ Entering placing")
|
|
120
|
+
|
|
121
|
+
@on_enter_state(States.running.homing)
|
|
122
|
+
def enter_homing(self, _):
|
|
123
|
+
print("๐บ Entering homing")
|
|
124
|
+
|
|
125
|
+
# Guard condition - only allow reset when safety conditions are met
|
|
126
|
+
@guard(Triggers.reset)
|
|
127
|
+
def check_safety_conditions(self) -> bool:
|
|
128
|
+
"""Only allow reset when estop is not pressed."""
|
|
129
|
+
return not self.estop_pressed
|
|
130
|
+
|
|
131
|
+
# Global state change callback for MQTT publishing
|
|
132
|
+
@on_state_change
|
|
133
|
+
def publish_state_to_mqtt(self, old_state: str, new_state: str, trigger: str):
|
|
134
|
+
"""Publish state changes to MQTT."""
|
|
135
|
+
mqtt_client.publish("machine/state", {
|
|
136
|
+
"old_state": old_state,
|
|
137
|
+
"new_state": new_state,
|
|
138
|
+
"trigger": trigger
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### 4. Start It
|
|
143
|
+
```python
|
|
144
|
+
state_machine = StateMachine()
|
|
145
|
+
state_machine.start() # Enters last recorded state (if recovery enabled), else first state
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## ๐ Optional FastAPI Router
|
|
149
|
+
This library provides a FastAPI-compatible router that automatically exposes your state machine over HTTP. This is useful for:
|
|
150
|
+
|
|
151
|
+
- Triggering transitions via HTTP POST
|
|
152
|
+
- Inspecting current state and state history
|
|
153
|
+
#### Example
|
|
154
|
+
```python
|
|
155
|
+
from fastapi import FastAPI
|
|
156
|
+
from state_machine.router import build_router
|
|
157
|
+
from state_machine.core import StateMachine
|
|
158
|
+
|
|
159
|
+
state_machine = StateMachine(...)
|
|
160
|
+
state_machine.start()
|
|
161
|
+
|
|
162
|
+
app = FastAPI()
|
|
163
|
+
app.include_router(build_router(state_machine))
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Available Routes
|
|
167
|
+
* `GET /state`: Returns current and last known state
|
|
168
|
+
* `GET /history`: Returns list of recent state transitions
|
|
169
|
+
* `POST /<trigger_name>`: Triggers a transition by name
|
|
170
|
+
|
|
171
|
+
You can expose only a subset of triggers by passing them explicitly:
|
|
172
|
+
```python
|
|
173
|
+
from state_machine.defs import Trigger
|
|
174
|
+
# Only create endpoints for 'start' and 'reset'
|
|
175
|
+
router = build_router(state_machine, triggers=[Trigger("start"), Trigger("reset")])
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Diagram Visualization
|
|
179
|
+
|
|
180
|
+
- `GET /diagram.svg`: Returns a Graphviz-generated SVG of the state machine.
|
|
181
|
+
- Current state is highlighted in red.
|
|
182
|
+
- Previous state and the transition taken are highlighted in blue.
|
|
183
|
+
- Requires Graphviz installed on the system. If Graphviz is missing, the endpoint returns 503 Service Unavailable.
|
|
184
|
+
|
|
185
|
+
Example usage:
|
|
186
|
+
```bash
|
|
187
|
+
curl http://localhost:8000/diagram.svg > machine.svg
|
|
188
|
+
open machine.svg
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## ๐งช Testing & History
|
|
192
|
+
- `state_machine.history`: List of all transitions with timestamps and durations
|
|
193
|
+
- `state_machine.last(n)`: Last `n` transitions
|
|
194
|
+
- `state_machine.record_last_state()`: Manually record current state for later recovery
|
|
195
|
+
- `state_machine.get_last_state()`: Retrieve recorded state
|
|
196
|
+
|
|
197
|
+
## โฒ Timeout Example
|
|
198
|
+
Any `on_enter_state` method can be wrapped with `@auto_timeout(seconds, trigger_fn)`, e.g.:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
@auto_timeout(5.0, Triggers.to_fault)
|
|
202
|
+
```
|
|
203
|
+
This automatically triggers `to_fault()` if the state remains active after 5 seconds.
|
|
204
|
+
|
|
205
|
+
## ๐ Recovery Example
|
|
206
|
+
Enable `enable_last_state_recovery=True` and use:
|
|
207
|
+
```python
|
|
208
|
+
state_machine.start()
|
|
209
|
+
```
|
|
210
|
+
If a last state was recorded, it will trigger `recover__{last_state}` instead of `start`.
|
|
211
|
+
|
|
212
|
+
## ๐งฉ How Decorators Work
|
|
213
|
+
|
|
214
|
+
Decorators attach metadata to your methods:
|
|
215
|
+
|
|
216
|
+
- `@on_enter_state(state)` binds to the state's entry callback
|
|
217
|
+
- `@on_exit_state(state)` binds to the state's exit callback
|
|
218
|
+
- `@auto_timeout(seconds, trigger)` schedules a timeout once the state is entered
|
|
219
|
+
- `@guard(trigger)` adds a condition that must be true for the transition to proceed
|
|
220
|
+
- `@on_state_change` registers a global callback that fires on every state transition
|
|
221
|
+
|
|
222
|
+
The library automatically discovers and wires these up when your machine is initialized.
|
|
223
|
+
|
|
224
|
+
## ๐ก๏ธ Guard Conditions
|
|
225
|
+
|
|
226
|
+
Guard conditions allow you to block transitions based on runtime conditions. They can be applied to single or multiple triggers:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
# Single trigger
|
|
230
|
+
@guard(Triggers.reset)
|
|
231
|
+
def check_safety_conditions(self) -> bool:
|
|
232
|
+
"""Only allow reset when estop is not pressed."""
|
|
233
|
+
return not self.estop_pressed
|
|
234
|
+
|
|
235
|
+
# Multiple triggers - same guard applies to both
|
|
236
|
+
@guard(Triggers.reset, Triggers.start)
|
|
237
|
+
def check_safety_conditions(self) -> bool:
|
|
238
|
+
"""Check safety conditions for both reset and start."""
|
|
239
|
+
return not self.estop_pressed and self.safety_system_ok
|
|
240
|
+
|
|
241
|
+
# Multiple guard functions for the same trigger - ALL must pass
|
|
242
|
+
@guard(Triggers.reset)
|
|
243
|
+
def check_estop(self) -> bool:
|
|
244
|
+
return not self.estop_pressed
|
|
245
|
+
|
|
246
|
+
@guard(Triggers.reset)
|
|
247
|
+
def check_safety_system(self) -> bool:
|
|
248
|
+
return self.safety_system_ok
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
If any guard function returns `False`, the transition is blocked and the state machine remains in its current state. When multiple guard functions are applied to the same trigger, **ALL conditions must pass** for the transition to be allowed.
|
|
252
|
+
|
|
253
|
+
## ๐ก State Change Callbacks
|
|
254
|
+
|
|
255
|
+
Global state change callbacks are perfect for logging, MQTT publishing, or other side effects. They fire after every successful state transition:
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
@on_state_change
|
|
259
|
+
def publish_to_mqtt(self, old_state: str, new_state: str, trigger: str) -> None:
|
|
260
|
+
"""Publish state changes to MQTT."""
|
|
261
|
+
mqtt_client.publish("machine/state", {
|
|
262
|
+
"old_state": old_state,
|
|
263
|
+
"new_state": new_state,
|
|
264
|
+
"trigger": trigger,
|
|
265
|
+
"timestamp": datetime.now().isoformat()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
@on_state_change
|
|
269
|
+
def log_transitions(self, old_state: str, new_state: str, trigger: str) -> None:
|
|
270
|
+
"""Log all state transitions."""
|
|
271
|
+
print(f"State change: {old_state} -> {new_state} (trigger: {trigger})")
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Multiple state change callbacks can be registered and they will all be called in the order they were defined. Callbacks only fire on **successful transitions** - blocked transitions (due to guard conditions) do not trigger callbacks.
|
|
275
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "vention-state-machine"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Declarative state machine framework for machine apps"
|
|
5
|
+
authors = [ "VentionCo" ]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "Proprietary"
|
|
8
|
+
packages = [{ include = "state_machine", from = "src" }]
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
asyncio = "^3.4.3"
|
|
12
|
+
python = ">=3.9,<3.11"
|
|
13
|
+
fastapi = "^0.116.1"
|
|
14
|
+
uvicorn = "^0.35.0"
|
|
15
|
+
transitions = "^0.9.3"
|
|
16
|
+
graphviz = "^0.21"
|
|
17
|
+
coverage = "^7.10.1"
|
|
18
|
+
httpx = "^0.28.1"
|
|
19
|
+
|
|
20
|
+
[tool.poetry.group.dev.dependencies]
|
|
21
|
+
pytest = "8.3.4"
|
|
22
|
+
ruff = "^0.8.0"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = [ "poetry-core>=1.0.0,<2.0.0" ]
|
|
26
|
+
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|