Skip to main content

m5stack_core/io/
touch_buttons.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Touch→button emulation for the CoreS3 (FT6336U).
3//!
4//! The CoreS3 has no physical front-panel buttons; a strip of the touch panel
5//! is split into three zones emulating the classic M5Stack Left/Center/Right
6//! buttons. A small state machine detects short taps (with multi-tap
7//! counting) and long presses and emits the same
8//! [`ButtonEvent`](crate::io::buttons::ButtonEvent) as the Fire27's physical
9//! buttons ([`crate::io::buttons`]), so the application maps input events in
10//! one place for both boards.
11
12use embassy_time::{Duration, Instant, Timer};
13
14use crate::driver::ft6336u;
15use crate::io::buttons::{ButtonAction, ButtonEvent, ButtonId, ButtonTiming};
16use crate::io::shared_i2c::SharedI2cBus;
17
18pub struct TouchButtonsConfig {
19    /// Touch-poll interval.
20    pub poll_ms: u64,
21    /// Hold time before a press becomes [`ButtonAction::Long`].
22    pub long_press_ms: u64,
23    /// Max gap between taps that still counts as a multi-tap. Set to `0` to
24    /// disable multi-tap counting entirely: each tap emits `Short(1)` on the next
25    /// poll after release (~`poll_ms`), with no counting delay — for
26    /// latency-sensitive input like an encoder (#58). See
27    /// [`with_timing`](Self::with_timing) /
28    /// [`ButtonTiming::immediate_single_press`] for the cross-driver preset.
29    pub multi_tap_ms: u64,
30    /// Zone strip: touches above this Y are ignored.
31    pub zone_y_min: u16,
32    /// X below this is [`ButtonId::Left`].
33    pub zone_x_left: u16,
34    /// X below this (and ≥ `zone_x_left`) is [`ButtonId::Center`]; above,
35    /// [`ButtonId::Right`].
36    pub zone_x_right: u16,
37}
38
39/// Bottom 40 px strip (y ≥ 200) split into thirds.
40impl Default for TouchButtonsConfig {
41    fn default() -> Self {
42        Self {
43            poll_ms: 20,
44            long_press_ms: 500,
45            multi_tap_ms: 300,
46            zone_y_min: 200,
47            zone_x_left: 107,
48            zone_x_right: 213,
49        }
50    }
51}
52
53impl TouchButtonsConfig {
54    /// [`Default`] geometry/poll, but with the press-decoding timings taken from
55    /// the cross-driver [`ButtonTiming`] — the same type and presets the Fire27
56    /// physical buttons take (`ButtonResources::into_buttons_with_timing`, feature
57    /// `buttons`). Use [`ButtonTiming::immediate_single_press`] for instant
58    /// single-press input (an encoder).
59    pub fn with_timing(timing: ButtonTiming) -> Self {
60        Self {
61            long_press_ms: timing.long_press_ms,
62            multi_tap_ms: timing.multi_tap_ms,
63            ..Self::default()
64        }
65    }
66}
67
68/// The emulated three-button strip. Construct once, then await
69/// [`next_event`](Self::next_event) in a loop.
70pub struct TouchButtons {
71    i2c: &'static SharedI2cBus,
72    config: TouchButtonsConfig,
73    /// FT6336U probed and answering (it may need AXP2101 power-up time).
74    probed: bool,
75    // press state
76    pressed: Option<ButtonId>,
77    press_start: Instant,
78    long_fired: bool,
79    // multi-tap state
80    tap_zone: Option<ButtonId>,
81    tap_count: usize,
82    last_release: Instant,
83}
84
85impl TouchButtons {
86    /// Construct with the cross-driver [`ButtonTiming`] (default geometry/poll) —
87    /// the touch-side counterpart of `ButtonResources::into_buttons_with_timing`
88    /// (feature `buttons`). Shorthand for
89    /// `new(i2c, TouchButtonsConfig::with_timing(timing))`.
90    pub fn with_timing(i2c: &'static SharedI2cBus, timing: ButtonTiming) -> Self {
91        Self::new(i2c, TouchButtonsConfig::with_timing(timing))
92    }
93
94    pub fn new(i2c: &'static SharedI2cBus, config: TouchButtonsConfig) -> Self {
95        Self {
96            i2c,
97            config,
98            probed: false,
99            pressed: None,
100            press_start: Instant::now(),
101            long_fired: false,
102            tap_zone: None,
103            tap_count: 0,
104            last_release: Instant::now(),
105        }
106    }
107
108    fn classify(&self, x: u16, y: u16) -> Option<ButtonId> {
109        if y < self.config.zone_y_min {
110            return None;
111        }
112        Some(if x < self.config.zone_x_left {
113            ButtonId::Left
114        } else if x < self.config.zone_x_right {
115            ButtonId::Center
116        } else {
117            ButtonId::Right
118        })
119    }
120
121    /// Wait for the FT6336U to appear on the bus (first call only — the
122    /// controller may need AXP2101 power-up time after a cold boot).
123    async fn probe(&mut self) {
124        let mut attempts = 0u32;
125        loop {
126            match ft6336u::read_touch(self.i2c).await {
127                Ok(_) => {
128                    info!("FT6336U found at 0x{:02X}", ft6336u::ADDR);
129                    self.probed = true;
130                    return;
131                }
132                Err(e) => {
133                    attempts += 1;
134                    if attempts <= 3 || attempts % 50 == 0 {
135                        warn!("FT6336U probe #{}: {:?}", attempts, e);
136                    }
137                }
138            }
139            Timer::after(Duration::from_millis(100)).await;
140        }
141    }
142
143    /// Poll the touch controller until the state machine produces the next
144    /// [`ButtonEvent`]. Press state persists across calls, so awaiting this
145    /// in a loop loses no events.
146    pub async fn next_event(&mut self) -> ButtonEvent {
147        if !self.probed {
148            self.probe().await;
149        }
150
151        loop {
152            Timer::after(Duration::from_millis(self.config.poll_ms)).await;
153
154            let touch = match ft6336u::read_touch(self.i2c).await {
155                Ok(t) => t,
156                Err(e) => {
157                    warn!("touch I2C err: {:?}", e);
158                    continue;
159                }
160            };
161            let cur_zone = touch.and_then(|(x, y)| self.classify(x, y));
162
163            match (self.pressed, cur_zone) {
164                // No touch was active, still no touch: flush a pending
165                // multi-tap once the tap window has passed.
166                (None, None) => {
167                    if self.tap_count > 0
168                        && self.last_release.elapsed()
169                            > Duration::from_millis(self.config.multi_tap_ms)
170                    {
171                        let id = self.tap_zone.take().unwrap();
172                        let count = core::mem::take(&mut self.tap_count);
173                        return ButtonEvent { id, action: ButtonAction::Short(count) };
174                    }
175                }
176                // New touch down in a zone.
177                (None, Some(zone)) => {
178                    self.pressed = Some(zone);
179                    self.press_start = Instant::now();
180                    self.long_fired = false;
181                }
182                // Touch held.
183                (Some(zone), Some(_cur)) => {
184                    if !self.long_fired
185                        && self.press_start.elapsed()
186                            > Duration::from_millis(self.config.long_press_ms)
187                    {
188                        self.long_fired = true;
189                        // A long press cancels any pending multi-tap.
190                        self.tap_count = 0;
191                        self.tap_zone = None;
192                        return ButtonEvent { id: zone, action: ButtonAction::Long };
193                    }
194                }
195                // Touch released.
196                (Some(zone), None) => {
197                    self.pressed = None;
198                    if !self.long_fired {
199                        if self.tap_zone == Some(zone) {
200                            // Same zone — accumulate the multi-tap.
201                            self.tap_count += 1;
202                            self.last_release = Instant::now();
203                        } else {
204                            // Different zone or first tap — flush the old
205                            // zone's taps (if any), start counting the new.
206                            let pending = self
207                                .tap_zone
208                                .replace(zone)
209                                .filter(|_| self.tap_count > 0)
210                                .map(|old| ButtonEvent {
211                                    id: old,
212                                    action: ButtonAction::Short(self.tap_count),
213                                });
214                            self.tap_count = 1;
215                            self.last_release = Instant::now();
216                            if let Some(event) = pending {
217                                return event;
218                            }
219                        }
220                    }
221                }
222            }
223        }
224    }
225}