vention-state-machine 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
1
+ Metadata-Version: 2.1
2
+ Name: vention-state-machine
3
+ Version: 0.3.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`.
@@ -7,6 +7,6 @@ state_machine/defs.py,sha256=eYtkTqRMm89ZHFKs3p7H3HyGNZbrOnoCzAJbSeMxSDg,2836
7
7
  state_machine/machine_protocols.py,sha256=Yxs4aw2-NJ1RluygbthsquVKn35-xMYae7PB7xlqQmE,826
8
8
  state_machine/router.py,sha256=tRJyigrGpQo99_dWGdZ-ZirN50s0CbQF_v4kjAXlpWk,5560
9
9
  state_machine/utils.py,sha256=z58CBQCsFWwJzPn8ZhoB2y1K2BH8UP7OZ8XaUx9etN8,1833
10
- vention_state_machine-0.2.0.dist-info/METADATA,sha256=sScnkkjOabVty6PQWAtsfyaRI8WoN5Fw2rj_jmVr7XM,9972
11
- vention_state_machine-0.2.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
12
- vention_state_machine-0.2.0.dist-info/RECORD,,
10
+ vention_state_machine-0.3.1.dist-info/METADATA,sha256=EVMmxDmwb67RVbY6N_Ohig5hDLQCCSnOL2M7tSf12DU,10812
11
+ vention_state_machine-0.3.1.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
12
+ vention_state_machine-0.3.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.9.0
2
+ Generator: poetry-core 1.9.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,295 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: vention-state-machine
3
- Version: 0.2.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
- ```