rmk_q6_he_ansi/matrix/analog_matrix/scan.rs
1//! Hot-path matrix scanning: column stability check, rapid-trigger logic,
2//! and per-key travel computation.
3//!
4//! [`auto_calib_update`] and [`travel_from`] are free functions so they can be
5//! inlined into [`AnalogHallMatrix::scan_for_next_change`] without going
6//! through an extra method dispatch.
7
8use super::{AdcSampleTime, AnalogHallMatrix, AutoCalib, HallCfg, KeyCalib, KeyState, get2, get2_mut};
9use crate::matrix::{
10 analog_matrix::types::{
11 AUTO_CALIB_CONFIDENCE_THRESHOLD,
12 AUTO_CALIB_FULL_TRAVEL_THRESHOLD,
13 AUTO_CALIB_MIN_RANGE,
14 AUTO_CALIB_RELEASE_THRESHOLD,
15 AUTO_CALIB_ZERO_JITTER,
16 AutoCalibPhase,
17 BOTTOM_JITTER,
18 DEBOUNCE_PASSES,
19 FULL_TRAVEL_UNIT,
20 MIN_USEFUL_FULL_RANGE,
21 VALID_RAW_MAX,
22 VALID_RAW_MIN,
23 ZERO_TRAVEL_DEAD_ZONE,
24 },
25 hc164_cols::Hc164Cols,
26 sensor_mapping::SENSOR_POSITIONS,
27};
28use core::{
29 array::from_fn,
30 hint::{likely, unlikely},
31};
32use embassy_stm32::{
33 adc::{BasicInstance, ConfiguredSequence, Instance, RxDma},
34 dma::InterruptHandler,
35 interrupt::typelevel::Binding,
36 pac::adc,
37};
38use embassy_time::Timer;
39use num_traits::ToPrimitive as _;
40use rmk::event::KeyboardEvent;
41
42/// Update the auto-calibration state machine for a single key.
43///
44/// Called on every scan reading that passes the noise gate, before the
45/// travel computation and rapid-trigger logic. Watches complete
46/// press/release cycles and refines `calib[row][col]` once
47/// [`AUTO_CALIB_CONFIDENCE_THRESHOLD`] confident cycles have accumulated,
48/// compensating for temperature and mechanical drift without requiring a
49/// manual re-calibration.
50///
51/// A cycle is scored only when the ADC range (zero − full) meets
52/// [`AUTO_CALIB_MIN_RANGE`]; partial presses or noisy readings are
53/// discarded. Updated calibration takes effect immediately on the next
54/// travel computation within the same scan pass.
55fn auto_calib_update<const ROW: usize, const COL: usize>(
56 auto_calib: &mut [[AutoCalib; COL]; ROW],
57 calib: &mut [[KeyCalib; COL]; ROW],
58 row: usize,
59 col: usize,
60 raw: u16,
61) {
62 let Some(ac) = get2_mut(auto_calib, row, col) else { return };
63
64 match ac.phase {
65 AutoCalibPhase::Idle => {
66 if raw < AUTO_CALIB_FULL_TRAVEL_THRESHOLD {
67 ac.full_candidate = raw;
68 ac.phase = AutoCalibPhase::Pressing;
69 }
70 }
71 AutoCalibPhase::Pressing => {
72 if raw < ac.full_candidate {
73 // Key is pressing deeper; track the running minimum.
74 ac.full_candidate = raw;
75 } else if raw.saturating_sub(ac.full_candidate) > AUTO_CALIB_RELEASE_THRESHOLD {
76 // Key has lifted enough above the minimum to count as releasing.
77 ac.zero_candidate = raw;
78 ac.phase = AutoCalibPhase::Releasing;
79 } else {
80 // Reading is between full_candidate and full_candidate +
81 // AUTO_CALIB_RELEASE_THRESHOLD: bottom-of-travel jitter or
82 // a partial early lift. Stay in
83 // Pressing until a decisive rise.
84 }
85 }
86 AutoCalibPhase::Releasing => {
87 if raw > ac.zero_candidate {
88 // Key still rising; update the zero-travel peak.
89 ac.zero_candidate = raw;
90 } else if ac.zero_candidate.saturating_sub(raw) <= AUTO_CALIB_ZERO_JITTER {
91 // ADC has settled within jitter of the zero-travel peak;
92 // score the cycle if the range is plausible.
93 let range = ac.zero_candidate.saturating_sub(ac.full_candidate);
94 if range >= AUTO_CALIB_MIN_RANGE {
95 ac.confidence = ac.confidence.saturating_add(1);
96 }
97
98 if ac.confidence >= AUTO_CALIB_CONFIDENCE_THRESHOLD {
99 ac.confidence = 0;
100
101 let new_zero = ac.zero_candidate.saturating_sub(ZERO_TRAVEL_DEAD_ZONE);
102 let new_full = ac
103 .full_candidate
104 .saturating_add(BOTTOM_JITTER)
105 .clamp(VALID_RAW_MIN, new_zero.saturating_sub(MIN_USEFUL_FULL_RANGE));
106
107 // Only commit the update if the new zero differs
108 // meaningfully from the current one, avoiding
109 // unnecessary churn in the precomputed polynomial
110 // calibration data derived by `KeyCalib::new`.
111 if let Some(cal) = get2_mut(calib, row, col) {
112 if cal.zero.abs_diff(new_zero) > 10 {
113 *cal = KeyCalib::new(new_zero, new_full);
114 }
115 }
116 }
117
118 ac.phase = AutoCalibPhase::Idle;
119 } else {
120 // ADC dropped significantly below zero_candidate - key was
121 // re-pressed before fully releasing. Restart the machine.
122 ac.phase = AutoCalibPhase::Idle;
123 }
124 }
125 }
126}
127
128/// Convert a raw ADC reading into a travel value.
129///
130/// Returns `None` if the position is uncalibrated, if `raw` is outside
131/// [`VALID_RAW_MIN`]..=[`VALID_RAW_MAX`], or if the computation overflows.
132#[inline]
133#[optimize(speed)]
134fn travel_from(cal: &KeyCalib, raw: u16) -> Option<u8> {
135 if !cal.used {
136 return None;
137 }
138 if unlikely(!(VALID_RAW_MIN..=VALID_RAW_MAX).contains(&raw)) {
139 return None;
140 }
141 if cal.poly_delta_full <= 0.0 {
142 return None;
143 }
144
145 let delta_raw = KeyCalib::poly(f32::from(raw)) - cal.poly_zero;
146
147 let scaled =
148 (delta_raw / cal.poly_delta_full * f32::from(FULL_TRAVEL_UNIT)).clamp(0.0, f32::from(FULL_TRAVEL_UNIT));
149 Some(scaled.to_u8().unwrap_or(FULL_TRAVEL_UNIT))
150}
151
152impl<'peripherals, ADC, D, IRQ, IM, const ROW: usize, const COL: usize>
153 AnalogHallMatrix<'peripherals, ADC, D, IRQ, IM, ROW, COL>
154where
155 ADC: Instance<Regs = adc::Adc> + BasicInstance,
156 D: RxDma<ADC>,
157 IRQ: Binding<D::Interrupt, InterruptHandler<D>> + Copy + 'peripherals,
158 IM: embassy_stm32::i2c::mode::MasterMode,
159 AdcSampleTime<ADC>: Clone,
160{
161 /// Scan the matrix once, returning the first key state change found.
162 ///
163 /// The [`ConfiguredSequence`] is programmed once per invocation and
164 /// reused across all columns. Both the column settle delay and the DMA
165 /// transfer are fully async. For each reading that passes the noise gate
166 /// the auto-calibrator is updated before the travel and rapid-trigger
167 /// logic runs, so any calibration refinement takes effect within the same
168 /// scan pass.
169 #[optimize(speed)]
170 pub(super) async fn scan_for_next_change(
171 cols: &mut Hc164Cols<'peripherals>,
172 state: &mut [[KeyState; COL]; ROW],
173 calib: &mut [[KeyCalib; COL]; ROW],
174 auto_calib: &mut [[AutoCalib; COL]; ROW],
175 seq: &mut ConfiguredSequence<'_, adc::Adc>,
176 buf: &mut [u16; ROW],
177 cfg: HallCfg,
178 ) -> Option<KeyboardEvent> {
179 let act_threshold = cfg.actuation_pt;
180 // Clamp to 1 so a zero config value never disables the dead-band.
181 let sensitivity_press = cfg.rt_sensitivity_press.max(1);
182 let sensitivity_release = cfg.rt_sensitivity_release.max(1);
183 for col in 0..COL {
184 cols.select(col);
185 Timer::after(cfg.col_settle_us).await;
186 seq.read(buf).await;
187
188 // Column stability check: re-read the same column DEBOUNCE_PASSES
189 // times and compare each re-read to the first. If any key's
190 // pressed state (above / below the actuation threshold) disagrees
191 // between the first and a subsequent read, the entire column is
192 // skipped for this scan pass. One noisy key invalidates the whole
193 // column so no state is mutated on a transient spike.
194 //
195 // `calib` is read-only here - `auto_calib_update` only runs later,
196 // after the stability gate passes, so the calibration data used for
197 // both reads is identical.
198 //
199 // `buf` is restored to `first_read` unconditionally so the per-key
200 // noise gate always compares against the same initial reading,
201 // regardless of the stability outcome.
202 let first_read = *buf;
203 // Pre-compute each row's pressed state from the first reading.
204 // first_read is constant across all debounce passes, so computing
205 // it here avoids repeating the travel conversion / polynomial
206 // evaluation inside the pass loop for the baseline side of the
207 // comparison.
208 let first_pressed: [bool; ROW] = from_fn(|row| {
209 get2(calib, row, col).and_then(|c| travel_from(&c, first_read[row])).is_some_and(|t| t >= act_threshold)
210 });
211 let mut stable = true;
212 for _ in 0..DEBOUNCE_PASSES {
213 seq.read(buf).await;
214 let state_changed =
215 buf.iter().zip(first_pressed.iter()).enumerate().any(|(row, (&recheck, &was_pressed))| {
216 get2(calib, row, col).and_then(|c| travel_from(&c, recheck)).is_some_and(|t| t >= act_threshold)
217 != was_pressed
218 });
219 if state_changed {
220 stable = false;
221 break;
222 }
223 }
224 *buf = first_read;
225 if !stable {
226 continue;
227 }
228
229 for (row, &raw) in buf.iter().enumerate() {
230 // Skip positions with no physical hall-effect sensor. Their
231 // ADC readings are undefined and must never feed the key-state
232 // machine or auto-calibrator.
233 if !SENSOR_POSITIONS.get(row).and_then(|r| r.get(col)).copied().unwrap_or(false) {
234 continue;
235 }
236
237 let Some(st) = get2_mut(state, row, col) else { continue };
238 let last_raw = st.last_raw;
239
240 // Skip if the reading has not changed beyond the noise gate.
241 if likely(last_raw.abs_diff(raw) < cfg.noise_gate) {
242 continue;
243 }
244
245 // Record the raw value now so that repeated identical readings
246 // are filtered by the noise gate even when travel_from later
247 // returns None (uncalibrated or out-of-range position).
248 st.last_raw = raw;
249
250 // Update the auto-calibrator with this reading before the
251 // travel computation so any refined KeyCalib is used immediately.
252 auto_calib_update(auto_calib, calib, row, col, raw);
253
254 let Some(cal) = get2(calib, row, col) else { continue };
255 let prev_travel = st.travel;
256 let was_pressed = st.pressed;
257 let Some(new_travel) = travel_from(&cal, raw) else { continue };
258
259 if new_travel == prev_travel {
260 continue;
261 }
262
263 st.travel = new_travel;
264
265 // Dynamic Rapid Trigger
266 let now_pressed = if was_pressed {
267 // Track the peak while held; release when travel drops at
268 // least `sensitivity_release` below it. The actuation floor
269 // is a hard lower bound that forces an immediate release.
270 st.extremum = st.extremum.max(new_travel);
271 new_travel >= act_threshold && new_travel > st.extremum.saturating_sub(sensitivity_release)
272 } else {
273 // Track the trough while released; re-press when travel
274 // climbs at least `sensitivity_press` above it AND exceeds
275 // the actuation floor. The trough is not required to have
276 // dipped below the floor first, so a finger hovering
277 // mid-travel after an RT-release can re-fire immediately.
278 st.extremum = st.extremum.min(new_travel);
279 new_travel >= act_threshold && new_travel >= st.extremum.saturating_add(sensitivity_press)
280 };
281
282 if now_pressed != st.pressed {
283 // Reset extremum so the next direction starts fresh from
284 // the transition point.
285 st.extremum = new_travel;
286 st.pressed = now_pressed;
287 return Some(KeyboardEvent::key(
288 u8::try_from(row).unwrap_or_default(),
289 u8::try_from(col).unwrap_or_default(),
290 now_pressed,
291 ));
292 }
293 }
294 }
295 None
296 }
297}