Skip to main content

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}