debounce-button-eng 0.2.0

Debounced button state machine built on embedded-hal InputPin
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
# debounce-button-eng


`debounce-button-eng` is a `no_std` crate for handling a mechanical button
through `embedded-hal::digital::InputPin`.

The crate is not tied to a specific microcontroller or timer. The application
passes monotonic time in milliseconds, while the crate filters contact bounce
and returns the stable button state plus short event flags.



## Features


- configurable debounce time;
- `active-low` and `active-high` buttons;
- stable `Pressed` / `Released` state;
- `pressed` and `released` events;
- single click;
- double click;
- configurable multi-click sequence, for example 5 short clicks;
- hold event;
- current and final hold duration;
- tests based on `embedded-hal-mock`.

## Documentation

API documentation is available on docs.rs:
<https://docs.rs/debounce-button-eng>.

## Installation

For local use as a path dependency:

```toml
[dependencies]
debounce-button-eng = { path = "../debounce-button-eng" }
```

If the crate is published to crates.io, the dependency can be written by
version:

```toml
[dependencies]
debounce-button-eng = "0.2"
```

## Quick Start


A typical button is wired between a GPIO pin and ground while the input uses a
pull-up resistor. In that case the pressed state is logical `Low`, so use
`ButtonConfig::active_low()`.

```rust,ignore
use debounce_button_eng::{Button, ButtonConfig};

let config = ButtonConfig::active_low()
    .with_debounce_ms(20)
    .with_double_click_ms(300)
    .with_hold_ms(1_000)
    .with_multi_click(5, 3_000);

let mut button = Button::with_config(input_pin, config);

loop {
    let now_ms = monotonic_millis();
    let update = button.update(now_ms)?;

    if update.events.pressed {
        // The button was pressed.
    }

    if update.events.released {
        // The button was released.
    }

    if update.events.click {
        // A single click was confirmed.
    }

    if update.events.double_click {
        // A double click was completed.
    }

    if update.events.multi_click {
        // The configured number of short clicks was completed.
    }

    if update.events.hold {
        // The button was held for at least hold_ms.
    }
}
```

`input_pin` must implement `embedded_hal::digital::InputPin`.

## Time


`Button::update(now_ms)` accepts monotonic time in milliseconds. Monotonic time
is a counter that only moves forward, for example milliseconds since MCU boot.

Correct:

```rust,ignore
button.update(100)?;
button.update(105)?;
button.update(120)?;
```

Incorrect:

```rust,ignore
button.update(100)?;
button.update(80)?; // time moved backwards
```

The crate uses differences between timestamps to determine:

- how long the input has been stable after a raw change;
- when a new state can be accepted after debounce;
- how long the button has been held;
- whether the second click landed inside the double-click window;
- whether the next short click arrived before the multi-click timeout expired.

## Configuration


```rust,ignore
let config = ButtonConfig::active_low()
    .with_debounce_ms(20)
    .with_double_click_ms(300)
    .with_hold_ms(1_000)
    .with_multi_click(5, 3_000);
```

Configuration fields:

- `debounce_ms` - how many milliseconds the raw pin state must remain stable
  before it becomes the debounced state;
- `double_click_ms` - maximum interval between two short clicks;
- `hold_ms` - hold threshold;
- `multi_click_count` - how many short clicks are required for
  `events.multi_click`;
- `multi_click_timeout_ms` - maximum pause between short clicks in one
  multi-click sequence;
- `active_level` - electrical level that represents a pressed button.

`multi_click_count` values `0` and `1` disable the multi-click detector. The
default is disabled, so existing `click` and `double_click` behavior does not
change unless you opt in.

For a button that is pressed when the input is high:

```rust,ignore
let config = ButtonConfig::active_high();
```

Timings can be changed at runtime:

```rust,ignore
button.set_debounce_ms(30);
button.set_double_click_ms(250);
button.set_hold_ms(1_500);
button.set_multi_click_count(5);
button.set_multi_click_timeout_ms(3_000);
```

## Events


`Button::update` returns `ButtonUpdate`:

```rust,ignore
let update = button.update(now_ms)?;
```

Main fields:

- `update.state` - current stable button state;
- `update.events.pressed` - the stable state changed to `Pressed`;
- `update.events.released` - the stable state changed to `Released`;
- `update.events.click` - a single short click was confirmed after the
  `double_click_ms` window expired;
- `update.events.double_click` - two short clicks completed inside the
  `double_click_ms` window;
- `update.events.multi_click` - the configured number of short clicks
  completed before the pause between clicks exceeded `multi_click_timeout_ms`;
- `update.events.hold` - the hold threshold was reached;
- `update.held_ms` - how many milliseconds the button is currently held;
- `update.released_after_ms` - how many milliseconds the button was held before
  it was released.
- `update.multi_click_count` - current short-click count in the active
  multi-click sequence. When `events.multi_click` is true, this value is the
  configured target count that fired the event.

`events.hold` fires once per hold. If you need the continuously updated hold
time, read `held_ms`.

`events.click` is intentionally delayed until the `double_click_ms` window
expires. Without this delay, a double click would first emit a single `click`
and then a `double_click`. The first short release is now stored as a pending
click. If the second short click arrives in time, only `double_click` is
emitted. If no second click arrives, the pending click becomes `click`.

## Multi-click Sequences

Use `with_multi_click(count, timeout_ms)` when an action should run only after a
specific number of short clicks:

```rust,ignore
let config = ButtonConfig::active_low()
    .with_debounce_ms(20)
    .with_hold_ms(1_000)
    .with_multi_click(5, 3_000);
```

With this configuration, every short release increments the internal
multi-click counter. When the fifth short release is accepted before a pause
greater than `3_000` ms appears between releases, `update.events.multi_click`
is true for that update and `update.multi_click_count` is `5`.

If the pause between two short releases is greater than
`multi_click_timeout_ms`, the counter is reset. A long hold is not counted as a
short click and also clears the active multi-click sequence.

For a configurable double click that allows a longer pause than
`double_click_ms`, use a target count of `2`:

```rust,ignore
let config = ButtonConfig::active_low()
    .with_double_click_ms(250)
    .with_multi_click(2, 3_000);
```

In this mode, `events.multi_click` can represent a slow double click within
three seconds, while `events.double_click` still keeps its stricter
`double_click_ms` meaning. While a multi-click sequence is active, the delayed
single `click` is held back until the sequence either completes or times out.

## How `Button::update` Works

`update(now_ms)` is the crate's main function. Call it regularly from the main
loop, an RTOS task, or a timer. Each call reads the pin once, updates internal
state, and returns `ButtonUpdate`.

```rust,ignore
let update = button.update(now_ms)?;
```

Important: `update` does not wait internally. It does not call `delay` and does
not block the program during debounce. Filtering happens across repeated calls
with fresh `now_ms` values.

Internal state:

- `raw_state` - latest physical pin state after applying `active_low` or
  `active_high`;
- `raw_changed_at` - time when `raw_state` last changed;
- `stable_state` - state that has already passed debounce filtering;
- `pressed_at` - time when the stable state became `Pressed`;
- `pending_click_at` - time of a short release that is waiting to become either
  a single `click` or part of a `double_click`;
- `multi_click_seen` - number of short clicks in the active multi-click
  sequence;
- `multi_click_last_at` - time of the latest short release in that sequence;
- `hold_reported` - whether `hold` has already been emitted for the current
  hold.

One `update(now_ms)` call works like this:

1. Read the pin through `pin.is_high()`.
2. Convert the electrical level to `ButtonState`.
   For `active_low`: `Low -> Pressed`, `High -> Released`.
3. If the new raw state differs from the previous `raw_state`, store it and
   restart the debounce timer:

   ```text
   raw_state = new state
   raw_changed_at = now_ms
   ```

   No `pressed` or `released` event is created yet.

4. If `raw_state` differs from `stable_state`, check how long the raw state has
   stayed unchanged:

   ```text
   now_ms - raw_changed_at >= debounce_ms
   ```

5. Only then is the raw state accepted as the new stable state. This is where
   `pressed` or `released` events appear.
6. When the stable state becomes `Pressed`, store `pressed_at = now_ms`.
7. When the stable state becomes `Released`, compute
   `released_after_ms = now_ms - pressed_at`.
8. If the release happened before `hold_ms`, it is a short-click candidate.
9. If multi-click detection is enabled, increment the active sequence count.
   If the pause since the previous short release is greater than
   `multi_click_timeout_ms`, reset the count before incrementing it.
10. If the sequence reaches `multi_click_count`, emit `events.multi_click`,
    report the target count in `update.multi_click_count`, and clear the
    sequence.
11. Store the short release in `pending_click_at`; `events.click` is not
    emitted yet while the click can still become part of a double click or an
    active multi-click sequence.
12. If a second short click completes within `double_click_ms`, emit
    `events.double_click` and clear the pending single click.
13. If the second click does not arrive before `double_click_ms` expires and no
    multi-click sequence is active, emit
    `events.click`.
14. While the button remains stably pressed, compute `held_ms`.
15. When `held_ms >= hold_ms`, emit `events.hold` once for the hold.

Example with `debounce_ms = 20`, active-low button:

```text
now_ms   pin   raw_state   stable_state   event
0        High  Released    Released       -
10       Low   Pressed     Released       raw changed, wait for debounce_ms
25       Low   Pressed     Released       only 15 ms elapsed, too early
30       Low   Pressed     Pressed        events.pressed = true
80       High  Released    Pressed        raw changed, wait for debounce_ms
95       High  Released    Pressed        only 15 ms elapsed, too early
100      High  Released    Released       events.released = true, click waits for double_click_ms
301      High  Released    Released       events.click = true
```

The important rule is that an event is not produced when the physical pin first
changes. It is produced only after the new state has remained stable for at
least `debounce_ms`.

`ButtonUpdate` describes the current call only:

- `state` stores the current stable state and persists between calls;
- `events.*` are short one-call pulses;
- `held_ms` is present only while the button is stably pressed;
- `released_after_ms` is present only in the call that stably released the
  button.
- `multi_click_count` shows the active multi-click sequence length, or the
  target count on the update that emits `events.multi_click`.

If `pin.is_high()` returns an error, `update` returns `Err(...)` immediately.
Internal state is not updated in that case.

Calling `update` more often gives more precise event timing. A 1-10 ms polling
period is usually convenient. Slower polling still keeps the logic correct, but
events can be delayed by up to the polling period.

## Function Call Flow


The main path starts in the user loop that regularly calls
`Button::update(now_ms)`.

```mermaid
flowchart TD
    user["User loop"] --> update["Button::update(now_ms)"]

    update --> read_raw["read_raw_state()"]
    read_raw --> pin_read["pin.is_high()"]
    read_raw --> map_level["ActiveLevel::state_from_high(...)"]

    update --> raw_changed{"raw_state changed?"}
    raw_changed -->|yes| save_raw["raw_state = new state\nraw_changed_at = now_ms"]
    raw_changed -->|no| debounce_check
    save_raw --> debounce_check{"elapsed(now_ms, raw_changed_at)\n>= debounce_ms?"}

    debounce_check -->|yes| accept["accept_stable_state(now_ms, update)"]
    debounce_check -->|no| hold_path

    accept --> pressed_or_released{"new stable_state"}
    pressed_or_released -->|Pressed| accept_pressed["pressed_at = now_ms\nhold_reported = false\nevents.pressed = true"]
    pressed_or_released -->|Released| accept_released["released_after_ms = elapsed(...)\nevents.released = true"]

    accept_released --> short_press{"held_ms < hold_ms?"}
    short_press -->|yes| register_short_release["register_short_release(now_ms, update)"]
    short_press -->|no| hold_path

    register_short_release --> multi_count["register_multi_click(...)\nreset count if pause > timeout"]
    multi_count --> multi_target{"multi_click_count reached?"}
    multi_target -->|yes| multi_event["events.multi_click = true\nclear multi-click count"]
    multi_target -->|no| double_check
    multi_event --> double_check{"pending_click_at inside\ndouble_click_ms?"}
    double_check -->|yes| double_event["events.double_click = true\npending_click_at = None"]
    double_check -->|no| multi_fired{"multi_click fired?"}
    multi_fired -->|yes| clear_pending["pending_click_at = None"]
    multi_fired -->|no| remember_click["pending_click_at = Some(now_ms)"]
    remember_click --> pending_check{"pending_click_at expired?"}
    pending_check -->|yes| click_event["events.click = true\npending_click_at = None"]
    pending_check -->|no| hold_path

    accept_pressed --> hold_path
    clear_pending --> hold_path
    click_event --> hold_path
    double_event --> hold_path

    hold_path{"stable_state == Pressed?"} -->|yes| held["held_ms(now_ms)"]
    held --> elapsed_held["elapsed(now_ms, pressed_at)"]
    elapsed_held --> hold_check{"held_ms >= hold_ms\nand hold not reported?"}
    hold_check -->|yes| hold_event["events.hold = true\nhold_reported = true"]
    hold_check -->|no| result
    hold_path -->|no| result["Ok(ButtonUpdate)"]
    hold_event --> result
```

The same flow as a list:

1. `Button::update(now_ms)` reads the physical pin through `read_raw_state()`.
2. `read_raw_state()` calls `pin.is_high()` and maps the level to `ButtonState`
   through `ActiveLevel::state_from_high(...)`.
3. If the raw state changed, `update` stores the new raw state and
   `raw_changed_at`.
4. If the raw state has been stable for at least `debounce_ms`,
   `accept_stable_state(...)` is called.
5. A transition to `Pressed` emits `events.pressed` and stores `pressed_at`.
6. A transition to `Released` emits `events.released`, computes
   `released_after_ms`, and calls `register_short_release(...)` for a short
   press.
7. `register_short_release(...)` updates the multi-click counter when the
   detector is enabled.
8. If the counter reaches `multi_click_count`, `events.multi_click` is emitted
   and the multi-click counter is cleared.
9. `register_short_release(...)` also confirms `events.double_click` if the
   previous short release was inside `double_click_ms`, or stores a new pending
   single click.
10. If the pending single click lives longer than `double_click_ms` and no
    multi-click sequence is active, `update` emits `events.click`.
11. If the stable state is currently `Pressed`, `update` computes `held_ms`.
12. When `held_ms >= hold_ms`, `events.hold` is emitted once for the hold.

Constructors and configuration:

```mermaid
flowchart LR
    new["Button::new(pin)"] --> with_config["Button::with_config(pin, ButtonConfig::active_low())"]
    custom["Button::with_config(pin, config)"] --> button["Button<PIN>"]
    with_config --> button

    active_low["ButtonConfig::active_low()"] --> config["ButtonConfig"]
    active_high["ButtonConfig::active_high()"] --> active_low
    debounce["with_debounce_ms(...)"] --> config
    double["with_double_click_ms(...)"] --> config
    hold["with_hold_ms(...)"] --> config
    multi_count["with_multi_click_count(...)"] --> config
    multi_timeout["with_multi_click_timeout_ms(...)"] --> config
    multi["with_multi_click(...)"] --> config
```

## Tests


Run library tests:

```powershell
cargo test
```

Check formatting and clippy:

```powershell
cargo fmt --check
cargo clippy --all-targets -- -D warnings
```