vention-state-machine 0.1.0__tar.gz → 0.2.1__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.2.1/PKG-INFO +392 -0
- vention_state_machine-0.2.1/README.md +373 -0
- {vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/pyproject.toml +4 -4
- vention_state_machine-0.1.0/PKG-INFO +0 -295
- vention_state_machine-0.1.0/README.md +0 -275
- {vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/__init__.py +0 -0
- {vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/core.py +0 -0
- {vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/decorator_manager.py +0 -0
- {vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/decorator_protocols.py +0 -0
- {vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/decorators.py +0 -0
- {vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/defs.py +0 -0
- {vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/machine_protocols.py +0 -0
- {vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/router.py +0 -0
- {vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/utils.py +0 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: vention-state-machine
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Declarative state machine framework for machine apps
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Author: VentionCo
|
|
7
|
+
Requires-Python: >=3.10,<3.11
|
|
8
|
+
Classifier: License :: Other/Proprietary License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Requires-Dist: asyncio (>=3.4.3,<4.0.0)
|
|
12
|
+
Requires-Dist: coverage (>=7.10.1,<8.0.0)
|
|
13
|
+
Requires-Dist: fastapi (==0.121.1)
|
|
14
|
+
Requires-Dist: graphviz (>=0.21,<0.22)
|
|
15
|
+
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
16
|
+
Requires-Dist: transitions (>=0.9.3,<0.10.0)
|
|
17
|
+
Requires-Dist: uvicorn (>=0.35.0,<0.36.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# vention-state-machine
|
|
21
|
+
|
|
22
|
+
A lightweight wrapper around `transitions` for building async-safe, recoverable hierarchical state machines with minimal boilerplate.
|
|
23
|
+
|
|
24
|
+
## Table of Contents
|
|
25
|
+
|
|
26
|
+
- [✨ Features](#-features)
|
|
27
|
+
- [🧠 Concepts & Overview](#-concepts--overview)
|
|
28
|
+
- [⚙️ Installation & Setup](#️-installation--setup)
|
|
29
|
+
- [🚀 Quickstart Tutorial](#-quickstart-tutorial)
|
|
30
|
+
- [🛠 How-to Guides](#-how-to-guides)
|
|
31
|
+
- [📖 API Reference](#-api-reference)
|
|
32
|
+
- [🔍 Troubleshooting & FAQ](#-troubleshooting--faq)
|
|
33
|
+
|
|
34
|
+
## ✨ Features
|
|
35
|
+
|
|
36
|
+
- Built-in `ready` / `fault` states
|
|
37
|
+
- Global transitions: `to_fault`, `reset`
|
|
38
|
+
- Optional state recovery (`recover__state`)
|
|
39
|
+
- Async task spawning and cancellation
|
|
40
|
+
- Timeouts and auto-fault handling
|
|
41
|
+
- Transition history recording with timestamps + durations
|
|
42
|
+
- Guard conditions for blocking transitions
|
|
43
|
+
- Global state change callbacks for logging/MQTT
|
|
44
|
+
- Optional FastAPI router for HTTP access and visualization
|
|
45
|
+
|
|
46
|
+
## 🧠 Concepts & Overview
|
|
47
|
+
|
|
48
|
+
This library uses a **declarative domain-specific language (DSL)** to define state machines in a readable, strongly typed way.
|
|
49
|
+
|
|
50
|
+
- **State** → A leaf node in the state machine
|
|
51
|
+
- **StateGroup** → Groups related states, creating hierarchical namespaces
|
|
52
|
+
- **Trigger** → Named events that initiate transitions
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
class MyStates(StateGroup):
|
|
58
|
+
idle: State = State()
|
|
59
|
+
working: State = State()
|
|
60
|
+
|
|
61
|
+
class Triggers:
|
|
62
|
+
begin = Trigger("begin")
|
|
63
|
+
finish = Trigger("finish")
|
|
64
|
+
|
|
65
|
+
TRANSITIONS = [
|
|
66
|
+
Triggers.finish.transition(MyStates.working, MyStates.idle),
|
|
67
|
+
]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
### Base States and Triggers
|
|
72
|
+
|
|
73
|
+
All machines include:
|
|
74
|
+
|
|
75
|
+
**States:**
|
|
76
|
+
- `ready` (initial)
|
|
77
|
+
- `fault` (global error)
|
|
78
|
+
|
|
79
|
+
**Triggers:**
|
|
80
|
+
- `start`, `to_fault`, `reset`
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from state_machine.core import BaseStates, BaseTriggers
|
|
84
|
+
|
|
85
|
+
state_machine.trigger(BaseTriggers.RESET.value)
|
|
86
|
+
assert state_machine.state == BaseStates.READY.value
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## ⚙️ Installation & Setup
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install vention-state-machine
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Optional dependencies:**
|
|
96
|
+
- Graphviz (required for diagram generation)
|
|
97
|
+
- FastAPI (for HTTP exposure of state machine)
|
|
98
|
+
|
|
99
|
+
**Install optional tools:**
|
|
100
|
+
|
|
101
|
+
MacOS:
|
|
102
|
+
```bash
|
|
103
|
+
brew install graphviz
|
|
104
|
+
pip install fastapi
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Linux (Debian/Ubuntu)
|
|
108
|
+
```bash
|
|
109
|
+
sudo apt-get install graphviz
|
|
110
|
+
pip install fastapi
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
## 🚀 Quickstart Tutorial
|
|
115
|
+
|
|
116
|
+
### 1. Define States and Triggers
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from state_machine.defs import StateGroup, State, Trigger
|
|
120
|
+
|
|
121
|
+
class Running(StateGroup):
|
|
122
|
+
picking: State = State()
|
|
123
|
+
placing: State = State()
|
|
124
|
+
homing: State = State()
|
|
125
|
+
|
|
126
|
+
class States:
|
|
127
|
+
running = Running()
|
|
128
|
+
|
|
129
|
+
class Triggers:
|
|
130
|
+
start = Trigger("start")
|
|
131
|
+
finished_picking = Trigger("finished_picking")
|
|
132
|
+
finished_placing = Trigger("finished_placing")
|
|
133
|
+
finished_homing = Trigger("finished_homing")
|
|
134
|
+
to_fault = Trigger("to_fault")
|
|
135
|
+
reset = Trigger("reset")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### 2. Define Transitions
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
TRANSITIONS = [
|
|
142
|
+
Triggers.start.transition("ready", States.running.picking),
|
|
143
|
+
Triggers.finished_picking.transition(States.running.picking, States.running.placing),
|
|
144
|
+
Triggers.finished_placing.transition(States.running.placing, States.running.homing),
|
|
145
|
+
Triggers.finished_homing.transition(States.running.homing, States.running.picking),
|
|
146
|
+
]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### 3. Implement Your State Machine
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from state_machine.core import StateMachine
|
|
153
|
+
from state_machine.decorators import on_enter_state, auto_timeout, guard, on_state_change
|
|
154
|
+
|
|
155
|
+
class CustomMachine(StateMachine):
|
|
156
|
+
def __init__(self):
|
|
157
|
+
super().__init__(states=States, transitions=TRANSITIONS)
|
|
158
|
+
|
|
159
|
+
@on_enter_state(States.running.picking)
|
|
160
|
+
@auto_timeout(5.0, Triggers.to_fault)
|
|
161
|
+
def enter_picking(self, _):
|
|
162
|
+
print("🔹 Entering picking")
|
|
163
|
+
|
|
164
|
+
@on_enter_state(States.running.placing)
|
|
165
|
+
def enter_placing(self, _):
|
|
166
|
+
print("🔸 Entering placing")
|
|
167
|
+
|
|
168
|
+
@on_enter_state(States.running.homing)
|
|
169
|
+
def enter_homing(self, _):
|
|
170
|
+
print("🔺 Entering homing")
|
|
171
|
+
|
|
172
|
+
@guard(Triggers.reset)
|
|
173
|
+
def check_safety_conditions(self) -> bool:
|
|
174
|
+
return not self.estop_pressed
|
|
175
|
+
|
|
176
|
+
@on_state_change
|
|
177
|
+
def publish_state_to_mqtt(self, old_state: str, new_state: str, trigger: str):
|
|
178
|
+
mqtt_client.publish("machine/state", {
|
|
179
|
+
"old_state": old_state,
|
|
180
|
+
"new_state": new_state,
|
|
181
|
+
"trigger": trigger
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### 4. Start It
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
state_machine = StateMachine()
|
|
189
|
+
state_machine.start()
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## 🛠 How-to Guides
|
|
193
|
+
|
|
194
|
+
### Expose Over HTTP with FastAPI
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from fastapi import FastAPI
|
|
198
|
+
from state_machine.router import build_router
|
|
199
|
+
from state_machine.core import StateMachine
|
|
200
|
+
|
|
201
|
+
state_machine = StateMachine(...)
|
|
202
|
+
state_machine.start()
|
|
203
|
+
|
|
204
|
+
app = FastAPI()
|
|
205
|
+
app.include_router(build_router(state_machine))
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Endpoints:**
|
|
209
|
+
- `GET /state` → Current state
|
|
210
|
+
- `GET /history` → Transition history
|
|
211
|
+
- `POST /<trigger>` → Trigger a transition
|
|
212
|
+
- `GET /diagram.svg` → Graphviz diagram
|
|
213
|
+
|
|
214
|
+
### Timeout Example
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
@auto_timeout(5.0, Triggers.to_fault)
|
|
218
|
+
def enter_state(self, _):
|
|
219
|
+
...
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Recovery Example
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
state_machine = StateMachine(enable_last_state_recovery=True)
|
|
226
|
+
state_machine.start() # will attempt recover__{last_state}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Triggering state transitions via I/O
|
|
230
|
+
|
|
231
|
+
Here's an example of hooking up state transitions to I/O events via MQTT
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
import asyncio
|
|
235
|
+
import paho.mqtt.client as mqtt
|
|
236
|
+
from state_machine.core import StateMachine
|
|
237
|
+
from state_machine.defs import State, StateGroup, Trigger
|
|
238
|
+
from state_machine.decorators import on_enter_state
|
|
239
|
+
|
|
240
|
+
class MachineStates(StateGroup):
|
|
241
|
+
idle: State = State()
|
|
242
|
+
running: State = State()
|
|
243
|
+
|
|
244
|
+
class States:
|
|
245
|
+
machine = MachineStates()
|
|
246
|
+
|
|
247
|
+
class Triggers:
|
|
248
|
+
start_button = Trigger("start_button")
|
|
249
|
+
box_missing = Trigger("box_missing")
|
|
250
|
+
|
|
251
|
+
TRANSITIONS = [
|
|
252
|
+
Triggers.start_button.transition(States.machine.idle, States.machine.running),
|
|
253
|
+
Triggers.box_missing.transition(States.machine.running, States.machine.idle),
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
class MachineController(StateMachine):
|
|
257
|
+
def __init__(self):
|
|
258
|
+
super().__init__(states=States, transitions=TRANSITIONS)
|
|
259
|
+
self.mqtt_client = mqtt.Client()
|
|
260
|
+
self.setup_mqtt()
|
|
261
|
+
|
|
262
|
+
def setup_mqtt(self):
|
|
263
|
+
"""Configure MQTT client to listen for I/O signals."""
|
|
264
|
+
self.mqtt_client.on_connect = self.on_mqtt_connect
|
|
265
|
+
self.mqtt_client.on_message = self.on_mqtt_message
|
|
266
|
+
self.mqtt_client.connect("localhost", 1883, 60)
|
|
267
|
+
|
|
268
|
+
# Start MQTT loop in background
|
|
269
|
+
self.spawn(self.mqtt_loop())
|
|
270
|
+
|
|
271
|
+
async def mqtt_loop(self):
|
|
272
|
+
"""Background task to handle MQTT messages."""
|
|
273
|
+
self.mqtt_client.loop_start()
|
|
274
|
+
while True:
|
|
275
|
+
await asyncio.sleep(0.1)
|
|
276
|
+
|
|
277
|
+
def on_mqtt_connect(self, client, userdata, flags, rc):
|
|
278
|
+
"""Subscribe to I/O topics when connected."""
|
|
279
|
+
client.subscribe("machine/io/start_button")
|
|
280
|
+
client.subscribe("machine/sensors/box_sensor")
|
|
281
|
+
|
|
282
|
+
def on_mqtt_message(self, client, userdata, msg):
|
|
283
|
+
"""Handle incoming MQTT messages and trigger state transitions."""
|
|
284
|
+
topic = msg.topic
|
|
285
|
+
payload = msg.payload.decode()
|
|
286
|
+
|
|
287
|
+
# Map MQTT topics to state machine triggers
|
|
288
|
+
if topic == "machine/io/start_button" and payload == "pressed":
|
|
289
|
+
self.trigger(Triggers.start_button.value)
|
|
290
|
+
elif topic == "machine/sensors/box_sensor" and payload == "0":
|
|
291
|
+
self.trigger(Triggers.box_missing.value)
|
|
292
|
+
|
|
293
|
+
@on_enter_state(States.machine.running)
|
|
294
|
+
def enter_running(self, _):
|
|
295
|
+
print("🔧 Machine started - processing parts")
|
|
296
|
+
self.mqtt_client.publish("machine/status", "running")
|
|
297
|
+
|
|
298
|
+
@on_enter_state(States.machine.idle)
|
|
299
|
+
def enter_idle(self, _):
|
|
300
|
+
print("⏸️ Machine idle - ready for start")
|
|
301
|
+
self.mqtt_client.publish("machine/status", "idle")
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## 📖 API Reference
|
|
305
|
+
|
|
306
|
+
### StateMachine
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
class StateMachine(HierarchicalGraphMachine):
|
|
310
|
+
def __init__(
|
|
311
|
+
self,
|
|
312
|
+
states: Union[object, list[dict[str, Any]], None],
|
|
313
|
+
*,
|
|
314
|
+
transitions: Optional[list[dict[str, str]]] = None,
|
|
315
|
+
history_size: Optional[int] = None,
|
|
316
|
+
enable_last_state_recovery: bool = True,
|
|
317
|
+
**kw: Any,
|
|
318
|
+
)
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Parameters:**
|
|
322
|
+
- `states`: Either a container of StateGroups or a list of state dicts.
|
|
323
|
+
- `transitions`: List of transition dictionaries, or `[]`.
|
|
324
|
+
- `history_size`: Max number of entries in transition history (default 1000).
|
|
325
|
+
- `enable_last_state_recovery`: If True, machine can resume from last recorded state.
|
|
326
|
+
|
|
327
|
+
### Methods
|
|
328
|
+
|
|
329
|
+
**`spawn(coro: Coroutine) -> asyncio.Task`**
|
|
330
|
+
Start a background coroutine and track it. Auto-cancelled on fault/reset.
|
|
331
|
+
|
|
332
|
+
**`cancel_tasks() -> None`**
|
|
333
|
+
Cancel all tracked tasks and timeouts.
|
|
334
|
+
|
|
335
|
+
**`set_timeout(state_name: str, seconds: float, trigger_fn: Callable[[], str]) -> None`**
|
|
336
|
+
Schedule a trigger if state_name stays active too long.
|
|
337
|
+
|
|
338
|
+
**`record_last_state() -> None`**
|
|
339
|
+
Save current state for recovery.
|
|
340
|
+
|
|
341
|
+
**`get_last_state() -> Optional[str]`**
|
|
342
|
+
Return most recently recorded state.
|
|
343
|
+
|
|
344
|
+
**`start() -> None`**
|
|
345
|
+
Enter machine (recover__... if applicable, else start).
|
|
346
|
+
|
|
347
|
+
### Properties
|
|
348
|
+
|
|
349
|
+
**`history -> list[dict[str, Any]]`**
|
|
350
|
+
Full transition history with timestamps/durations.
|
|
351
|
+
|
|
352
|
+
**`get_last_history_entries(n: int) -> list[dict[str, Any]]`**
|
|
353
|
+
Return last n transitions.
|
|
354
|
+
|
|
355
|
+
### Decorators
|
|
356
|
+
|
|
357
|
+
**`@on_enter_state(state: State)`**
|
|
358
|
+
Bind function to run on entry.
|
|
359
|
+
|
|
360
|
+
**`@on_exit_state(state: State)`**
|
|
361
|
+
Bind function to run on exit.
|
|
362
|
+
|
|
363
|
+
**`@auto_timeout(seconds: float, trigger: Trigger)`**
|
|
364
|
+
Auto-trigger if timeout expires.
|
|
365
|
+
|
|
366
|
+
**`@guard(*triggers: Trigger)`**
|
|
367
|
+
Guard transition; blocks if function returns False.
|
|
368
|
+
|
|
369
|
+
**`@on_state_change`**
|
|
370
|
+
Global callback `(old_state, new_state, trigger)` fired after each transition.
|
|
371
|
+
|
|
372
|
+
### Router
|
|
373
|
+
|
|
374
|
+
```python
|
|
375
|
+
def build_router(
|
|
376
|
+
machine: StateMachine,
|
|
377
|
+
triggers: Optional[list[Trigger]] = None
|
|
378
|
+
) -> fastapi.APIRouter
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Exposes endpoints:
|
|
382
|
+
- `GET /state`
|
|
383
|
+
- `GET /history`
|
|
384
|
+
- `POST /<trigger>`
|
|
385
|
+
- `GET /diagram.svg`
|
|
386
|
+
|
|
387
|
+
## 🔍 Troubleshooting & FAQ
|
|
388
|
+
|
|
389
|
+
- **Diagram endpoint returns 503** → Graphviz not installed.
|
|
390
|
+
- **Transitions blocked unexpectedly** → Check guard conditions.
|
|
391
|
+
- **Callbacks not firing** → Only successful transitions trigger them.
|
|
392
|
+
- **State not restored after restart** → Ensure `enable_last_state_recovery=True`.
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# vention-state-machine
|
|
2
|
+
|
|
3
|
+
A lightweight wrapper around `transitions` for building async-safe, recoverable hierarchical state machines with minimal boilerplate.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [✨ Features](#-features)
|
|
8
|
+
- [🧠 Concepts & Overview](#-concepts--overview)
|
|
9
|
+
- [⚙️ Installation & Setup](#️-installation--setup)
|
|
10
|
+
- [🚀 Quickstart Tutorial](#-quickstart-tutorial)
|
|
11
|
+
- [🛠 How-to Guides](#-how-to-guides)
|
|
12
|
+
- [📖 API Reference](#-api-reference)
|
|
13
|
+
- [🔍 Troubleshooting & FAQ](#-troubleshooting--faq)
|
|
14
|
+
|
|
15
|
+
## ✨ Features
|
|
16
|
+
|
|
17
|
+
- Built-in `ready` / `fault` states
|
|
18
|
+
- Global transitions: `to_fault`, `reset`
|
|
19
|
+
- Optional state recovery (`recover__state`)
|
|
20
|
+
- Async task spawning and cancellation
|
|
21
|
+
- Timeouts and auto-fault handling
|
|
22
|
+
- Transition history recording with timestamps + durations
|
|
23
|
+
- Guard conditions for blocking transitions
|
|
24
|
+
- Global state change callbacks for logging/MQTT
|
|
25
|
+
- Optional FastAPI router for HTTP access and visualization
|
|
26
|
+
|
|
27
|
+
## 🧠 Concepts & Overview
|
|
28
|
+
|
|
29
|
+
This library uses a **declarative domain-specific language (DSL)** to define state machines in a readable, strongly typed way.
|
|
30
|
+
|
|
31
|
+
- **State** → A leaf node in the state machine
|
|
32
|
+
- **StateGroup** → Groups related states, creating hierarchical namespaces
|
|
33
|
+
- **Trigger** → Named events that initiate transitions
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
class MyStates(StateGroup):
|
|
39
|
+
idle: State = State()
|
|
40
|
+
working: State = State()
|
|
41
|
+
|
|
42
|
+
class Triggers:
|
|
43
|
+
begin = Trigger("begin")
|
|
44
|
+
finish = Trigger("finish")
|
|
45
|
+
|
|
46
|
+
TRANSITIONS = [
|
|
47
|
+
Triggers.finish.transition(MyStates.working, MyStates.idle),
|
|
48
|
+
]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
### Base States and Triggers
|
|
53
|
+
|
|
54
|
+
All machines include:
|
|
55
|
+
|
|
56
|
+
**States:**
|
|
57
|
+
- `ready` (initial)
|
|
58
|
+
- `fault` (global error)
|
|
59
|
+
|
|
60
|
+
**Triggers:**
|
|
61
|
+
- `start`, `to_fault`, `reset`
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from state_machine.core import BaseStates, BaseTriggers
|
|
65
|
+
|
|
66
|
+
state_machine.trigger(BaseTriggers.RESET.value)
|
|
67
|
+
assert state_machine.state == BaseStates.READY.value
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## ⚙️ Installation & Setup
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install vention-state-machine
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Optional dependencies:**
|
|
77
|
+
- Graphviz (required for diagram generation)
|
|
78
|
+
- FastAPI (for HTTP exposure of state machine)
|
|
79
|
+
|
|
80
|
+
**Install optional tools:**
|
|
81
|
+
|
|
82
|
+
MacOS:
|
|
83
|
+
```bash
|
|
84
|
+
brew install graphviz
|
|
85
|
+
pip install fastapi
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Linux (Debian/Ubuntu)
|
|
89
|
+
```bash
|
|
90
|
+
sudo apt-get install graphviz
|
|
91
|
+
pip install fastapi
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
## 🚀 Quickstart Tutorial
|
|
96
|
+
|
|
97
|
+
### 1. Define States and Triggers
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from state_machine.defs import StateGroup, State, Trigger
|
|
101
|
+
|
|
102
|
+
class Running(StateGroup):
|
|
103
|
+
picking: State = State()
|
|
104
|
+
placing: State = State()
|
|
105
|
+
homing: State = State()
|
|
106
|
+
|
|
107
|
+
class States:
|
|
108
|
+
running = Running()
|
|
109
|
+
|
|
110
|
+
class Triggers:
|
|
111
|
+
start = Trigger("start")
|
|
112
|
+
finished_picking = Trigger("finished_picking")
|
|
113
|
+
finished_placing = Trigger("finished_placing")
|
|
114
|
+
finished_homing = Trigger("finished_homing")
|
|
115
|
+
to_fault = Trigger("to_fault")
|
|
116
|
+
reset = Trigger("reset")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 2. Define Transitions
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
TRANSITIONS = [
|
|
123
|
+
Triggers.start.transition("ready", States.running.picking),
|
|
124
|
+
Triggers.finished_picking.transition(States.running.picking, States.running.placing),
|
|
125
|
+
Triggers.finished_placing.transition(States.running.placing, States.running.homing),
|
|
126
|
+
Triggers.finished_homing.transition(States.running.homing, States.running.picking),
|
|
127
|
+
]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 3. Implement Your State Machine
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from state_machine.core import StateMachine
|
|
134
|
+
from state_machine.decorators import on_enter_state, auto_timeout, guard, on_state_change
|
|
135
|
+
|
|
136
|
+
class CustomMachine(StateMachine):
|
|
137
|
+
def __init__(self):
|
|
138
|
+
super().__init__(states=States, transitions=TRANSITIONS)
|
|
139
|
+
|
|
140
|
+
@on_enter_state(States.running.picking)
|
|
141
|
+
@auto_timeout(5.0, Triggers.to_fault)
|
|
142
|
+
def enter_picking(self, _):
|
|
143
|
+
print("🔹 Entering picking")
|
|
144
|
+
|
|
145
|
+
@on_enter_state(States.running.placing)
|
|
146
|
+
def enter_placing(self, _):
|
|
147
|
+
print("🔸 Entering placing")
|
|
148
|
+
|
|
149
|
+
@on_enter_state(States.running.homing)
|
|
150
|
+
def enter_homing(self, _):
|
|
151
|
+
print("🔺 Entering homing")
|
|
152
|
+
|
|
153
|
+
@guard(Triggers.reset)
|
|
154
|
+
def check_safety_conditions(self) -> bool:
|
|
155
|
+
return not self.estop_pressed
|
|
156
|
+
|
|
157
|
+
@on_state_change
|
|
158
|
+
def publish_state_to_mqtt(self, old_state: str, new_state: str, trigger: str):
|
|
159
|
+
mqtt_client.publish("machine/state", {
|
|
160
|
+
"old_state": old_state,
|
|
161
|
+
"new_state": new_state,
|
|
162
|
+
"trigger": trigger
|
|
163
|
+
})
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### 4. Start It
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
state_machine = StateMachine()
|
|
170
|
+
state_machine.start()
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## 🛠 How-to Guides
|
|
174
|
+
|
|
175
|
+
### Expose Over HTTP with FastAPI
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from fastapi import FastAPI
|
|
179
|
+
from state_machine.router import build_router
|
|
180
|
+
from state_machine.core import StateMachine
|
|
181
|
+
|
|
182
|
+
state_machine = StateMachine(...)
|
|
183
|
+
state_machine.start()
|
|
184
|
+
|
|
185
|
+
app = FastAPI()
|
|
186
|
+
app.include_router(build_router(state_machine))
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Endpoints:**
|
|
190
|
+
- `GET /state` → Current state
|
|
191
|
+
- `GET /history` → Transition history
|
|
192
|
+
- `POST /<trigger>` → Trigger a transition
|
|
193
|
+
- `GET /diagram.svg` → Graphviz diagram
|
|
194
|
+
|
|
195
|
+
### Timeout Example
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
@auto_timeout(5.0, Triggers.to_fault)
|
|
199
|
+
def enter_state(self, _):
|
|
200
|
+
...
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Recovery Example
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
state_machine = StateMachine(enable_last_state_recovery=True)
|
|
207
|
+
state_machine.start() # will attempt recover__{last_state}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Triggering state transitions via I/O
|
|
211
|
+
|
|
212
|
+
Here's an example of hooking up state transitions to I/O events via MQTT
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
import asyncio
|
|
216
|
+
import paho.mqtt.client as mqtt
|
|
217
|
+
from state_machine.core import StateMachine
|
|
218
|
+
from state_machine.defs import State, StateGroup, Trigger
|
|
219
|
+
from state_machine.decorators import on_enter_state
|
|
220
|
+
|
|
221
|
+
class MachineStates(StateGroup):
|
|
222
|
+
idle: State = State()
|
|
223
|
+
running: State = State()
|
|
224
|
+
|
|
225
|
+
class States:
|
|
226
|
+
machine = MachineStates()
|
|
227
|
+
|
|
228
|
+
class Triggers:
|
|
229
|
+
start_button = Trigger("start_button")
|
|
230
|
+
box_missing = Trigger("box_missing")
|
|
231
|
+
|
|
232
|
+
TRANSITIONS = [
|
|
233
|
+
Triggers.start_button.transition(States.machine.idle, States.machine.running),
|
|
234
|
+
Triggers.box_missing.transition(States.machine.running, States.machine.idle),
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
class MachineController(StateMachine):
|
|
238
|
+
def __init__(self):
|
|
239
|
+
super().__init__(states=States, transitions=TRANSITIONS)
|
|
240
|
+
self.mqtt_client = mqtt.Client()
|
|
241
|
+
self.setup_mqtt()
|
|
242
|
+
|
|
243
|
+
def setup_mqtt(self):
|
|
244
|
+
"""Configure MQTT client to listen for I/O signals."""
|
|
245
|
+
self.mqtt_client.on_connect = self.on_mqtt_connect
|
|
246
|
+
self.mqtt_client.on_message = self.on_mqtt_message
|
|
247
|
+
self.mqtt_client.connect("localhost", 1883, 60)
|
|
248
|
+
|
|
249
|
+
# Start MQTT loop in background
|
|
250
|
+
self.spawn(self.mqtt_loop())
|
|
251
|
+
|
|
252
|
+
async def mqtt_loop(self):
|
|
253
|
+
"""Background task to handle MQTT messages."""
|
|
254
|
+
self.mqtt_client.loop_start()
|
|
255
|
+
while True:
|
|
256
|
+
await asyncio.sleep(0.1)
|
|
257
|
+
|
|
258
|
+
def on_mqtt_connect(self, client, userdata, flags, rc):
|
|
259
|
+
"""Subscribe to I/O topics when connected."""
|
|
260
|
+
client.subscribe("machine/io/start_button")
|
|
261
|
+
client.subscribe("machine/sensors/box_sensor")
|
|
262
|
+
|
|
263
|
+
def on_mqtt_message(self, client, userdata, msg):
|
|
264
|
+
"""Handle incoming MQTT messages and trigger state transitions."""
|
|
265
|
+
topic = msg.topic
|
|
266
|
+
payload = msg.payload.decode()
|
|
267
|
+
|
|
268
|
+
# Map MQTT topics to state machine triggers
|
|
269
|
+
if topic == "machine/io/start_button" and payload == "pressed":
|
|
270
|
+
self.trigger(Triggers.start_button.value)
|
|
271
|
+
elif topic == "machine/sensors/box_sensor" and payload == "0":
|
|
272
|
+
self.trigger(Triggers.box_missing.value)
|
|
273
|
+
|
|
274
|
+
@on_enter_state(States.machine.running)
|
|
275
|
+
def enter_running(self, _):
|
|
276
|
+
print("🔧 Machine started - processing parts")
|
|
277
|
+
self.mqtt_client.publish("machine/status", "running")
|
|
278
|
+
|
|
279
|
+
@on_enter_state(States.machine.idle)
|
|
280
|
+
def enter_idle(self, _):
|
|
281
|
+
print("⏸️ Machine idle - ready for start")
|
|
282
|
+
self.mqtt_client.publish("machine/status", "idle")
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## 📖 API Reference
|
|
286
|
+
|
|
287
|
+
### StateMachine
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
class StateMachine(HierarchicalGraphMachine):
|
|
291
|
+
def __init__(
|
|
292
|
+
self,
|
|
293
|
+
states: Union[object, list[dict[str, Any]], None],
|
|
294
|
+
*,
|
|
295
|
+
transitions: Optional[list[dict[str, str]]] = None,
|
|
296
|
+
history_size: Optional[int] = None,
|
|
297
|
+
enable_last_state_recovery: bool = True,
|
|
298
|
+
**kw: Any,
|
|
299
|
+
)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**Parameters:**
|
|
303
|
+
- `states`: Either a container of StateGroups or a list of state dicts.
|
|
304
|
+
- `transitions`: List of transition dictionaries, or `[]`.
|
|
305
|
+
- `history_size`: Max number of entries in transition history (default 1000).
|
|
306
|
+
- `enable_last_state_recovery`: If True, machine can resume from last recorded state.
|
|
307
|
+
|
|
308
|
+
### Methods
|
|
309
|
+
|
|
310
|
+
**`spawn(coro: Coroutine) -> asyncio.Task`**
|
|
311
|
+
Start a background coroutine and track it. Auto-cancelled on fault/reset.
|
|
312
|
+
|
|
313
|
+
**`cancel_tasks() -> None`**
|
|
314
|
+
Cancel all tracked tasks and timeouts.
|
|
315
|
+
|
|
316
|
+
**`set_timeout(state_name: str, seconds: float, trigger_fn: Callable[[], str]) -> None`**
|
|
317
|
+
Schedule a trigger if state_name stays active too long.
|
|
318
|
+
|
|
319
|
+
**`record_last_state() -> None`**
|
|
320
|
+
Save current state for recovery.
|
|
321
|
+
|
|
322
|
+
**`get_last_state() -> Optional[str]`**
|
|
323
|
+
Return most recently recorded state.
|
|
324
|
+
|
|
325
|
+
**`start() -> None`**
|
|
326
|
+
Enter machine (recover__... if applicable, else start).
|
|
327
|
+
|
|
328
|
+
### Properties
|
|
329
|
+
|
|
330
|
+
**`history -> list[dict[str, Any]]`**
|
|
331
|
+
Full transition history with timestamps/durations.
|
|
332
|
+
|
|
333
|
+
**`get_last_history_entries(n: int) -> list[dict[str, Any]]`**
|
|
334
|
+
Return last n transitions.
|
|
335
|
+
|
|
336
|
+
### Decorators
|
|
337
|
+
|
|
338
|
+
**`@on_enter_state(state: State)`**
|
|
339
|
+
Bind function to run on entry.
|
|
340
|
+
|
|
341
|
+
**`@on_exit_state(state: State)`**
|
|
342
|
+
Bind function to run on exit.
|
|
343
|
+
|
|
344
|
+
**`@auto_timeout(seconds: float, trigger: Trigger)`**
|
|
345
|
+
Auto-trigger if timeout expires.
|
|
346
|
+
|
|
347
|
+
**`@guard(*triggers: Trigger)`**
|
|
348
|
+
Guard transition; blocks if function returns False.
|
|
349
|
+
|
|
350
|
+
**`@on_state_change`**
|
|
351
|
+
Global callback `(old_state, new_state, trigger)` fired after each transition.
|
|
352
|
+
|
|
353
|
+
### Router
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
def build_router(
|
|
357
|
+
machine: StateMachine,
|
|
358
|
+
triggers: Optional[list[Trigger]] = None
|
|
359
|
+
) -> fastapi.APIRouter
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Exposes endpoints:
|
|
363
|
+
- `GET /state`
|
|
364
|
+
- `GET /history`
|
|
365
|
+
- `POST /<trigger>`
|
|
366
|
+
- `GET /diagram.svg`
|
|
367
|
+
|
|
368
|
+
## 🔍 Troubleshooting & FAQ
|
|
369
|
+
|
|
370
|
+
- **Diagram endpoint returns 503** → Graphviz not installed.
|
|
371
|
+
- **Transitions blocked unexpectedly** → Check guard conditions.
|
|
372
|
+
- **Callbacks not firing** → Only successful transitions trigger them.
|
|
373
|
+
- **State not restored after restart** → Ensure `enable_last_state_recovery=True`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "vention-state-machine"
|
|
3
|
-
version = "0.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Declarative state machine framework for machine apps"
|
|
5
5
|
authors = [ "VentionCo" ]
|
|
6
6
|
readme = "README.md"
|
|
@@ -9,8 +9,8 @@ packages = [{ include = "state_machine", from = "src" }]
|
|
|
9
9
|
|
|
10
10
|
[tool.poetry.dependencies]
|
|
11
11
|
asyncio = "^3.4.3"
|
|
12
|
-
python = ">=3.
|
|
13
|
-
fastapi = "
|
|
12
|
+
python = ">=3.10,<3.11"
|
|
13
|
+
fastapi = "0.121.1"
|
|
14
14
|
uvicorn = "^0.35.0"
|
|
15
15
|
transitions = "^0.9.3"
|
|
16
16
|
graphviz = "^0.21"
|
|
@@ -18,7 +18,7 @@ coverage = "^7.10.1"
|
|
|
18
18
|
httpx = "^0.28.1"
|
|
19
19
|
|
|
20
20
|
[tool.poetry.group.dev.dependencies]
|
|
21
|
-
pytest = "8.3.4"
|
|
21
|
+
pytest = "^8.3.4"
|
|
22
22
|
ruff = "^0.8.0"
|
|
23
23
|
|
|
24
24
|
[build-system]
|
|
@@ -1,295 +0,0 @@
|
|
|
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
|
-
```
|
|
@@ -1,275 +0,0 @@
|
|
|
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
|
-
```
|
|
File without changes
|
|
File without changes
|
{vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/decorator_manager.py
RENAMED
|
File without changes
|
{vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/decorator_protocols.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vention_state_machine-0.1.0 → vention_state_machine-0.2.1}/src/state_machine/machine_protocols.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|