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}