Skip to main content

Crate debounce_button_eng

Crate debounce_button_eng 

Source
Expand description

§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:

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

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

[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().

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:

button.update(100)?;
button.update(105)?;
button.update(120)?;

Incorrect:

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

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:

let config = ButtonConfig::active_high();

Timings can be changed at runtime:

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:

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:

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:

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.

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:

    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:

    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:

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).

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:

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:

cargo test

Check formatting and clippy:

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

Structs§

Button
Debounced wrapper around an embedded-hal input pin.
ButtonConfig
Button timing and polarity configuration.
ButtonEvents
Events produced by one Button::update call.
ButtonUpdate
Result of one button update.

Enums§

ActiveLevel
Electrical level that represents a pressed button.
ButtonState
Stable button state after debounce filtering.

Type Aliases§

Millis
Monotonic time in milliseconds.