timed-fsm
A timed finite state machine framework where timer commands are declarative transition outputs.
Zero dependencies. No async runtime. No platform coupling.
The problem
A regular FSM transitions on (State, Event) → (State, Action). It has no way to express
"if no event arrives within 100 ms, do X" — the absence of an event is not an input.
You need a timer. But who manages it?
| Approach | Drawback |
|---|---|
FSM calls set_timer() directly |
Side effects inside the FSM; hard to unit-test |
| Caller manages timers based on FSM output | Timer logic leaks out; grammar split across two places |
FSM returns timer commands in Response |
Declarative; pure; testable without mocks |
timed-fsm takes the third approach. The state machine returns a Response containing
actions, timer commands, and a consumed flag. The runtime reads the Response and executes
the side effects. The state machine itself is pure.
Quick start
use Duration;
use ;
/// Debounce: ignore rapid signal changes; confirm a level after 20 ms of silence.
Testing without platform dependencies
The key advantage: test timer logic by calling on_event and on_timeout directly —
no OS timers, no mock clock, no sleep.
let mut d = Debounce ;
// Noisy signal: true → false → true in quick succession.
let r = d.on_event;
r.assert_consumed; // event was absorbed
r.assert_timer_set; // settle timer was requested
let r = d.on_event; // overwrite pending
let r = d.on_event; // overwrite again
// Simulate the runtime calling on_timeout() when the timer fires.
let r = d.on_timeout;
assert_eq!; // last pending level wins
No SetTimer. No sleep. No mock clock. Just call on_event / on_timeout and inspect the Response.
Connecting to a runtime
Implement TimerRuntime and ActionExecutor for your platform, then call dispatch after
every transition:
use Duration;
use ;
;
// In your event loop:
let response = state_machine.on_event;
let consumed = dispatch;
// If consumed is false, pass the event to the next handler in the chain.
Multiple timers
Use an enum (or any Copy + Eq + Debug type) as TimerId when you need more than one
concurrent timer:
use Duration;
use ;
Shift-reduce parser extension
When the decision about a token depends on tokens that arrive after it — for example,
detecting whether two keys were pressed simultaneously (chord) or in sequence — a plain
TimedStateMachine is not enough.
The parser module provides a ShiftReduceParser trait and a parse driver that buffer
tokens until a pattern is recognized or a timer forces a decision. See the
API docs for details and a worked example.
API overview
| Type | Role |
|---|---|
TimedStateMachine |
Core trait: on_event + on_timeout → Response |
Response<A, T> |
Transition result: actions + timer commands + consumed flag |
TimerCommand<T> |
Set { id, duration } or Kill { id } |
dispatch() |
Execute a Response against a runtime |
TimerRuntime |
Trait for platform timer operations |
ActionExecutor |
Trait for platform action execution |
ShiftReduceParser |
Extension: shift-reduce grammar with timer support |
parse() |
Main loop for a ShiftReduceParser |
Response builder
// Consume the event, emit one action, set a timer, kill another
emit_one
.with_timer
.with_kill_timer
// Consume the event, no output yet (pending state)
consume
.with_timer
// Don't consume — let the event propagate to the next handler
pass_through
Test assertion helpers
response.assert_consumed;
response.assert_pass_through;
response.assert_timer_set;
response.assert_timer_kill;
response.assert_action_count;
All assertion methods use #[track_caller] for clear error locations.
Use cases
timed-fsm is useful whenever a state transition depends on the absence of an event
within a time window:
| Domain | Event | Timer role |
|---|---|---|
| Keyboard firmware | Key press / release | Chord disambiguation timeout |
| Keyboard input (thumb shift) | Key press | Simultaneous key detection window |
| UI gestures | Mouse / touch | Double-click / long-press threshold |
| GPIO debounce | Signal edge | Bounce settling period |
| Network protocols | Packet received | Retransmission timeout |
| Protocol framing | Byte received | Inter-frame gap detection |
| Game input | Button press | Input combo window |
| IME / input method | Composition key | Commit-after-idle timeout |
Design principles
- Zero dependencies — only
std::time::Durationfrom the standard library - No side effects — the state machine never calls platform APIs
consumedflag — supports event interception (keyboard hooks, MIDI filters, etc.)- Multiple timer IDs — use
()for one timer, an enum for many - Infallible transitions —
on_event/on_timeoutalways return aResponse
License
Licensed under either of
at your option.