Refactored code

This commit is contained in:
Christoffer Martinsson 2025-09-19 18:58:21 +02:00
parent adc69a7f40
commit ce714ad71d
10 changed files with 1142 additions and 864 deletions

30
Justfile Normal file
View File

@ -0,0 +1,30 @@
set export := true
default: check
check:
cd rp2040 && cargo check --target thumbv6m-none-eabi
test:
cd rp2040 && cargo test --lib --target x86_64-unknown-linux-gnu --features std
build-uf2:
cd rp2040 && cargo build --release --target thumbv6m-none-eabi
cd rp2040 && cargo objcopy --release --target thumbv6m-none-eabi -- -O binary target/thumbv6m-none-eabi/release/cmdr-joystick-25.bin
cd rp2040 && python3 uf2conv.py target/thumbv6m-none-eabi/release/cmdr-joystick-25.bin --base 0x10000000 --family 0xe48bff56 --convert --output target/firmware.uf2
clean:
cargo clean --manifest-path rp2040/Cargo.toml
flash mount="" timeout="10":
@just build-uf2
MOUNT="{{mount}}" python3 tools/copy_uf2.py --source rp2040/target/firmware.uf2 --timeout {{timeout}}
flash-ssh target mount="/Volumes/RPI-RP2" key="" port="22":
@just build-uf2
target="{{target}}"
mount="{{mount}}"
key_arg=""
if [ -n "{{key}}" ]; then key_arg="-i {{key}}"; fi
ssh $key_arg -p {{port}} "$target" "mkdir -p \"$mount\""
scp $key_arg -P {{port}} rp2040/target/firmware.uf2 "$target:$mount/"

195
rp2040/src/board.rs Normal file
View File

@ -0,0 +1,195 @@
use crate::button_matrix::{ButtonMatrix, MatrixPins};
use crate::hardware::{self, BoardPins};
use crate::status::StatusLed;
use cortex_m::delay::Delay;
use cortex_m::interrupt;
use eeprom24x::{addr_size, page_size, unique_serial, Eeprom24x};
use rp2040_hal::adc::{Adc, AdcPin};
use rp2040_hal::clocks::Clock;
use rp2040_hal::gpio::{self, Pin, PullNone};
use rp2040_hal::i2c::I2C;
use rp2040_hal::pac;
use rp2040_hal::pio::PIOExt;
use rp2040_hal::sio::Sio;
use rp2040_hal::timer::Timer;
use rp2040_hal::watchdog::Watchdog;
use rp2040_hal::{clocks::init_clocks_and_plls, gpio::FunctionSioInput};
use static_cell::StaticCell;
use usb_device::class_prelude::UsbBusAllocator;
pub type JoystickMatrix = ButtonMatrix<
MatrixPins<{ hardware::BUTTON_ROWS }, { hardware::BUTTON_COLS }>,
{ hardware::BUTTON_ROWS },
{ hardware::BUTTON_COLS },
{ hardware::NUMBER_OF_BUTTONS },
>;
pub type JoystickStatusLed = StatusLed<pac::PIO0, rp2040_hal::pio::SM0, hardware::StatusLedPin>;
type BoardI2c = I2C<pac::I2C1, (hardware::I2cSdaPin, hardware::I2cSclPin)>;
type BoardEeprom = Eeprom24x<BoardI2c, page_size::B32, addr_size::TwoBytes, unique_serial::No>;
pub struct AxisAnalogPins {
pub left_x: AdcPin<Pin<gpio::bank0::Gpio29, FunctionSioInput, PullNone>>,
pub left_y: AdcPin<Pin<gpio::bank0::Gpio28, FunctionSioInput, PullNone>>,
pub right_x: AdcPin<Pin<gpio::bank0::Gpio27, FunctionSioInput, PullNone>>,
pub right_y: AdcPin<Pin<gpio::bank0::Gpio26, FunctionSioInput, PullNone>>,
}
impl AxisAnalogPins {
fn new(inputs: hardware::AxisInputs) -> Self {
let left_x = AdcPin::new(inputs.left_x).unwrap();
let left_y = AdcPin::new(inputs.left_y).unwrap();
let right_x = AdcPin::new(inputs.right_x).unwrap();
let right_y = AdcPin::new(inputs.right_y).unwrap();
Self {
left_x,
left_y,
right_x,
right_y,
}
}
}
pub struct Board {
button_matrix: JoystickMatrix,
status_led: JoystickStatusLed,
delay: Delay,
timer: Timer,
adc: Adc,
axis_pins: AxisAnalogPins,
left_extra_button: hardware::ExtraButtonPin,
right_extra_button: hardware::ExtraButtonPin,
eeprom: BoardEeprom,
usb_bus: &'static UsbBusAllocator<rp2040_hal::usb::UsbBus>,
}
pub struct BoardParts {
pub button_matrix: JoystickMatrix,
pub status_led: JoystickStatusLed,
pub delay: Delay,
pub timer: Timer,
pub adc: Adc,
pub axis_pins: AxisAnalogPins,
pub left_extra_button: hardware::ExtraButtonPin,
pub right_extra_button: hardware::ExtraButtonPin,
pub eeprom: BoardEeprom,
pub usb_bus: &'static UsbBusAllocator<rp2040_hal::usb::UsbBus>,
}
impl Board {
pub fn new() -> Self {
let mut pac = pac::Peripherals::take().unwrap();
let core = pac::CorePeripherals::take().unwrap();
let mut watchdog = Watchdog::new(pac.WATCHDOG);
let clocks = init_clocks_and_plls(
hardware::XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
let sio = Sio::new(pac.SIO);
let raw_pins = gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let pins = BoardPins::new(raw_pins);
let matrix_pins = MatrixPins::new(pins.matrix_rows, pins.matrix_cols);
let mut button_matrix = ButtonMatrix::new(
matrix_pins,
hardware::MATRIX_DEBOUNCE_SCANS,
hardware::MIN_PRESS_SPACING_SCANS,
);
button_matrix.init_pins();
let (mut pio, sm0, _, _, _) = pac.PIO0.split(&mut pac.RESETS);
let status_led = StatusLed::new(
pins.status_led,
&mut pio,
sm0,
clocks.peripheral_clock.freq(),
);
let timer = Timer::new(pac.TIMER, &mut pac.RESETS, &clocks);
let delay = Delay::new(core.SYST, clocks.system_clock.freq().to_Hz());
let i2c = I2C::i2c1(
pac.I2C1,
pins.i2c_sda,
pins.i2c_scl,
hardware::i2c::frequency(),
&mut pac.RESETS,
hardware::i2c::system_clock(),
);
let eeprom = Eeprom24x::new_24x32(i2c, hardware::i2c::EEPROM_ADDRESS);
let adc = Adc::new(pac.ADC, &mut pac.RESETS);
let axis_pins = AxisAnalogPins::new(pins.axis_inputs);
let usb_bus = usb_allocator(
pac.USBCTRL_REGS,
pac.USBCTRL_DPRAM,
clocks.usb_clock,
&mut pac.RESETS,
);
Self {
button_matrix,
status_led,
delay,
timer,
adc,
axis_pins,
left_extra_button: pins.left_extra_button,
right_extra_button: pins.right_extra_button,
eeprom,
usb_bus,
}
}
pub fn into_parts(self) -> BoardParts {
BoardParts {
button_matrix: self.button_matrix,
status_led: self.status_led,
delay: self.delay,
timer: self.timer,
adc: self.adc,
axis_pins: self.axis_pins,
left_extra_button: self.left_extra_button,
right_extra_button: self.right_extra_button,
eeprom: self.eeprom,
usb_bus: self.usb_bus,
}
}
}
fn usb_allocator(
usbctrl_regs: pac::USBCTRL_REGS,
usbctrl_dpram: pac::USBCTRL_DPRAM,
usb_clock: rp2040_hal::clocks::UsbClock,
resets: &mut pac::RESETS,
) -> &'static UsbBusAllocator<rp2040_hal::usb::UsbBus> {
static USB_BUS: StaticCell<UsbBusAllocator<rp2040_hal::usb::UsbBus>> = StaticCell::new();
interrupt::free(|_| {
USB_BUS.init_with(|| {
UsbBusAllocator::new(rp2040_hal::usb::UsbBus::new(
usbctrl_regs,
usbctrl_dpram,
usb_clock,
true,
resets,
))
})
})
}

30
rp2040/src/bootloader.rs Normal file
View File

@ -0,0 +1,30 @@
//! Bootloader helpers shared between power-on checks and runtime button chords.
use crate::status::{StatusLed, StatusMode};
use cortex_m::asm;
use rp2040_hal::gpio::AnyPin;
use rp2040_hal::pio::{PIOExt, StateMachineIndex};
/// Returns `true` when the power-on matrix snapshot requests bootloader entry.
///
/// The original firmware required the front-left-lower button to be held during
/// power-up to jump straight into ROM boot.
pub fn startup_requested(buttons: &[bool; crate::hardware::NUMBER_OF_BUTTONS]) -> bool {
buttons[crate::mapping::BUTTON_FRONT_LEFT_LOWER]
}
/// Puts the RP2040 into the ROM bootloader after updating the status LED.
pub fn enter<P, SM, I>(status_led: &mut StatusLed<P, SM, I>) -> !
where
P: PIOExt,
SM: StateMachineIndex,
I: AnyPin<Function = P::PinFunction>,
{
status_led.update(StatusMode::Bootloader);
let gpio_activity_pin_mask: u32 = 0;
let disable_interface_mask: u32 = 0;
rp2040_hal::rom_data::reset_to_usb_boot(gpio_activity_pin_mask, disable_interface_mask);
loop {
asm::nop();
}
}

View File

@ -1,140 +1,166 @@
//! Button matrix scanner for CMDR Joystick 25
//! Button matrix scanner for CMDR Joystick 25.
//!
//! Scans a row/column matrix and produces a debounced boolean state for each
//! button. Designed for small matrices on microcontrollers where timing is
//! deterministic and GPIO is plentiful.
//!
//! - Rows are configured as inputs with pullups
//! - Columns are configured as pushpull outputs
//! - Debounce is handled perbutton using a simple counter
//! - A tiny intercolumn delay is inserted to allow signals to settle
//! Mirrors the refactor performed for the keyboard firmware: the matrix owns
//! concrete pins, exposes a small `MatrixPinAccess` trait, and keeps the
//! debouncing + minimum press spacing behaviour identical to the original
//! joystick implementation.
use core::convert::Infallible;
use cortex_m::delay::Delay;
use embedded_hal::digital::{InputPin, OutputPin};
use rp2040_hal::gpio::{DynPinId, FunctionSioInput, FunctionSioOutput, Pin, PullNone, PullUp};
/// Button matrix driver
///
/// Generics
/// - `R`: number of rows
/// - `C`: number of columns
/// - `N`: total number of buttons (usually `R * C`)
///
/// Example
/// ```ignore
/// // 4 rows, 6 columns, 24 buttons, 5-scan debounce
/// let mut matrix: ButtonMatrix<4, 6, 24> = ButtonMatrix::new(row_pins, col_pins, 5);
/// matrix.init_pins();
/// loop {
/// matrix.scan_matrix(&mut delay);
/// let states = matrix.buttons_pressed();
/// // use `states`
/// }
/// ```
pub struct ButtonMatrix<'a, const R: usize, const C: usize, const N: usize> {
rows: &'a mut [&'a mut dyn InputPin<Error = Infallible>; R],
cols: &'a mut [&'a mut dyn OutputPin<Error = Infallible>; C],
pressed: [bool; N],
debounce: u8,
debounce_counter: [u8; N],
// Anti-bounce protection: minimum time between same-button presses
last_press_scan: [u32; N],
/// Abstraction over the matrix pins so the scanner can work with either the
/// concrete RP2040 pins or test doubles.
pub trait MatrixPinAccess<const ROWS: usize, const COLS: usize> {
fn init_columns(&mut self);
fn set_column_low(&mut self, column: usize);
fn set_column_high(&mut self, column: usize);
fn read_row(&mut self, row: usize) -> bool;
}
/// Concrete matrix pins backed by RP2040 GPIO using dynamic pin IDs.
type RowPin = Pin<DynPinId, FunctionSioInput, PullUp>;
type ColPin = Pin<DynPinId, FunctionSioOutput, PullNone>;
pub struct MatrixPins<const ROWS: usize, const COLS: usize> {
rows: [RowPin; ROWS],
cols: [ColPin; COLS],
}
impl<const ROWS: usize, const COLS: usize> MatrixPins<ROWS, COLS> {
pub fn new(rows: [RowPin; ROWS], cols: [ColPin; COLS]) -> Self {
Self { rows, cols }
}
}
impl<const ROWS: usize, const COLS: usize> MatrixPinAccess<ROWS, COLS> for MatrixPins<ROWS, COLS> {
fn init_columns(&mut self) {
for column in self.cols.iter_mut() {
let _ = column.set_high();
}
}
fn set_column_low(&mut self, column: usize) {
let _ = self.cols[column].set_low();
}
fn set_column_high(&mut self, column: usize) {
let _ = self.cols[column].set_high();
}
fn read_row(&mut self, row: usize) -> bool {
self.rows[row].is_low().unwrap_or(false)
}
}
/// Row/column scanned button matrix driver with debounce counters and minimum
/// spacing between subsequent presses of the same key.
pub struct ButtonMatrix<P, const ROWS: usize, const COLS: usize, const BUTTONS: usize>
where
P: MatrixPinAccess<ROWS, COLS>,
{
pins: P,
pressed: [bool; BUTTONS],
debounce_threshold: u8,
debounce_counter: [u8; BUTTONS],
last_press_scan: [u32; BUTTONS],
min_press_gap_scans: u32,
scan_counter: u32,
}
impl<'a, const R: usize, const C: usize, const N: usize> ButtonMatrix<'a, R, C, N> {
/// Creates a new button matrix.
///
/// Arguments
/// - `rows`: array of row pins (inputs with pullups)
/// - `cols`: array of column pins (pushpull outputs)
/// - `debounce`: number of consecutive scans a change must persist before it is accepted
pub fn new(
rows: &'a mut [&'a mut dyn InputPin<Error = Infallible>; R],
cols: &'a mut [&'a mut dyn OutputPin<Error = Infallible>; C],
debounce: u8,
) -> Self {
impl<P, const ROWS: usize, const COLS: usize, const BUTTONS: usize>
ButtonMatrix<P, ROWS, COLS, BUTTONS>
where
P: MatrixPinAccess<ROWS, COLS>,
{
pub fn new(pins: P, debounce_threshold: u8, min_press_gap_scans: u32) -> Self {
debug_assert_eq!(BUTTONS, ROWS * COLS);
Self {
rows,
cols,
pressed: [false; N],
debounce,
debounce_counter: [0; N],
last_press_scan: [0; N],
pins,
pressed: [false; BUTTONS],
debounce_threshold,
debounce_counter: [0; BUTTONS],
last_press_scan: [0; BUTTONS],
min_press_gap_scans,
scan_counter: 0,
}
}
/// Initialize the matrix GPIOs (set all columns high).
///
/// Call once before the first scan.
pub fn init_pins(&mut self) {
for col in self.cols.iter_mut() {
col.set_high().unwrap();
self.pins.init_columns();
}
pub fn prime(&mut self, delay: &mut Delay, passes: usize) {
for _ in 0..passes {
self.scan_matrix(delay);
}
}
/// Scan the matrix and update each button's debounced state.
///
/// Call at a fixed cadence. The simple debounce uses a perbutton counter: only
/// when a changed level is observed for `debounce` consecutive scans is the
/// new state committed.
///
/// Arguments
/// - `delay`: short delay implementation used to let signals settle between columns
pub fn scan_matrix(&mut self, delay: &mut Delay) {
self.scan_counter = self.scan_counter.wrapping_add(1);
for col_index in 0..self.cols.len() {
self.cols[col_index].set_low().unwrap();
for column in 0..COLS {
self.pins.set_column_low(column);
delay.delay_us(1);
self.process_column(col_index);
self.cols[col_index].set_high().unwrap();
self.process_column(column);
self.pins.set_column_high(column);
delay.delay_us(1);
}
}
/// Process a single column: drive low, sample rows, update debounce state, then release high.
///
/// Arguments
/// - `col_index`: index of the column being scanned
fn process_column(&mut self, col_index: usize) {
for row_index in 0..self.rows.len() {
let button_index: usize = col_index + (row_index * C);
let current_state = self.rows[row_index].is_low().unwrap();
pub fn buttons_pressed(&self) -> [bool; BUTTONS] {
self.pressed
}
if current_state == self.pressed[button_index] {
self.debounce_counter[button_index] = 0;
fn process_column(&mut self, column: usize) {
for row in 0..ROWS {
let index = column + (row * COLS);
let current_state = self.pins.read_row(row);
if current_state == self.pressed[index] {
self.debounce_counter[index] = 0;
continue;
}
self.debounce_counter[button_index] += 1;
if self.debounce_counter[button_index] >= self.debounce {
// Anti-bounce protection for press events: minimum 25 scans (5ms) between presses
self.debounce_counter[index] = self.debounce_counter[index].saturating_add(1);
if self.debounce_counter[index] < self.debounce_threshold {
continue;
}
self.debounce_counter[index] = 0;
if current_state {
// Pressing
let scans_since_last = self
.scan_counter
.wrapping_sub(self.last_press_scan[button_index]);
if scans_since_last >= 25 {
// 5ms at 200μs scan rate
self.pressed[button_index] = current_state;
self.last_press_scan[button_index] = self.scan_counter;
if self.should_register_press(index) {
self.pressed[index] = true;
}
} else {
// Releasing
self.pressed[button_index] = current_state;
}
self.debounce_counter[button_index] = 0;
self.pressed[index] = false;
}
}
}
/// Return a copy of the debounced pressed state for all buttons.
///
/// For small `N` this copy is cheap. If needed, the API could be extended to
/// return a reference in the future.
pub fn buttons_pressed(&mut self) -> [bool; N] {
self.pressed
fn should_register_press(&mut self, index: usize) -> bool {
let elapsed = self.scan_counter.wrapping_sub(self.last_press_scan[index]);
let can_register = self.last_press_scan[index] == 0 || elapsed >= self.min_press_gap_scans;
if can_register {
self.last_press_scan[index] = self.scan_counter;
}
can_register
}
#[cfg(all(test, feature = "std"))]
pub(crate) fn process_column_for_test(&mut self, column: usize) {
self.process_column(column);
}
#[cfg(all(test, feature = "std"))]
pub(crate) fn set_scan_counter(&mut self, value: u32) {
self.scan_counter = value;
}
#[cfg(all(test, feature = "std"))]
pub(crate) fn bump_scan_counter(&mut self) {
self.scan_counter = self.scan_counter.wrapping_add(1);
}
}
@ -142,123 +168,65 @@ impl<'a, const R: usize, const C: usize, const N: usize> ButtonMatrix<'a, R, C,
mod tests {
use super::*;
use core::cell::Cell;
use embedded_hal::digital::ErrorType;
use std::rc::Rc;
struct MockInputPin {
state: Rc<Cell<bool>>,
#[derive(Clone)]
struct MockPins {
row_state: Rc<Cell<bool>>,
column_state: Rc<Cell<bool>>,
}
impl MockInputPin {
fn new(state: Rc<Cell<bool>>) -> Self {
Self { state }
impl MockPins {
fn new(row_state: Rc<Cell<bool>>, column_state: Rc<Cell<bool>>) -> Self {
Self {
row_state,
column_state,
}
}
}
impl ErrorType for MockInputPin {
type Error = Infallible;
impl MatrixPinAccess<1, 1> for MockPins {
fn init_columns(&mut self) {
self.column_state.set(true);
}
impl InputPin for MockInputPin {
fn is_high(&mut self) -> Result<bool, Self::Error> {
Ok(!self.state.get())
fn set_column_low(&mut self, _column: usize) {
self.column_state.set(false);
}
fn is_low(&mut self) -> Result<bool, Self::Error> {
Ok(self.state.get())
fn set_column_high(&mut self, _column: usize) {
self.column_state.set(true);
}
fn read_row(&mut self, _row: usize) -> bool {
self.row_state.get()
}
}
struct MockOutputPin {
state: Rc<Cell<bool>>,
}
impl MockOutputPin {
fn new(state: Rc<Cell<bool>>) -> Self {
Self { state }
}
}
impl ErrorType for MockOutputPin {
type Error = Infallible;
}
impl OutputPin for MockOutputPin {
fn set_high(&mut self) -> Result<(), Self::Error> {
self.state.set(true);
Ok(())
}
fn set_low(&mut self) -> Result<(), Self::Error> {
self.state.set(false);
Ok(())
}
}
fn matrix_fixture() -> (
ButtonMatrix<'static, 1, 1, 1>,
fn fixture() -> (
ButtonMatrix<MockPins, 1, 1, 1>,
Rc<Cell<bool>>,
Rc<Cell<bool>>,
) {
let row_state = Rc::new(Cell::new(false));
let col_state = Rc::new(Cell::new(false));
let row_pin: &'static mut dyn InputPin<Error = Infallible> =
Box::leak(Box::new(MockInputPin::new(row_state.clone())));
let col_pin: &'static mut dyn OutputPin<Error = Infallible> =
Box::leak(Box::new(MockOutputPin::new(col_state.clone())));
let rows: &'static mut [&'static mut dyn InputPin<Error = Infallible>; 1] =
Box::leak(Box::new([row_pin]));
let cols: &'static mut [&'static mut dyn OutputPin<Error = Infallible>; 1] =
Box::leak(Box::new([col_pin]));
let matrix = ButtonMatrix::new(rows, cols, 15);
(matrix, row_state, col_state)
let row = Rc::new(Cell::new(false));
let column = Rc::new(Cell::new(true));
let pins = MockPins::new(row.clone(), column.clone());
let matrix = ButtonMatrix::new(pins, 2, 3);
(matrix, row, column)
}
#[test]
fn init_pins_sets_columns_high() {
let (mut matrix, _row_state, col_state) = matrix_fixture();
assert!(!col_state.get());
matrix.init_pins();
assert!(col_state.get());
}
fn debounce_requires_consecutive_scans() {
let (mut matrix, row, _column) = fixture();
matrix.set_scan_counter(1);
#[test]
fn process_column_obeys_debounce() {
let (mut matrix, row_state, _col_state) = matrix_fixture();
let mut states = matrix.buttons_pressed();
assert!(!states[0]);
row.set(true);
matrix.bump_scan_counter();
matrix.process_column_for_test(0);
assert!(!matrix.buttons_pressed()[0]);
// Set scan counter to start with enough history
matrix.scan_counter = 100;
row_state.set(true);
// Need 15 scans to register press
for _ in 0..14 {
matrix.scan_counter = matrix.scan_counter.wrapping_add(1);
matrix.process_column(0);
states = matrix.buttons_pressed();
assert!(!states[0]); // Still not pressed
}
matrix.scan_counter = matrix.scan_counter.wrapping_add(1);
matrix.process_column(0); // 15th scan
states = matrix.buttons_pressed();
assert!(states[0]); // Now pressed
row_state.set(false);
// Need 15 scans to register release
for _ in 0..14 {
matrix.scan_counter = matrix.scan_counter.wrapping_add(1);
matrix.process_column(0);
states = matrix.buttons_pressed();
assert!(states[0]); // Still pressed
}
matrix.scan_counter = matrix.scan_counter.wrapping_add(1);
matrix.process_column(0); // 15th scan
states = matrix.buttons_pressed();
assert!(!states[0]); // Now released
matrix.bump_scan_counter();
matrix.process_column_for_test(0);
assert!(matrix.buttons_pressed()[0]);
}
}

View File

@ -8,7 +8,7 @@
//! - Evaluate special combinations (bootloader, calibration, etc.)
//! - Expose a compact state consumed by USB report generation
use crate::button_matrix::ButtonMatrix;
use crate::button_matrix::{ButtonMatrix, MatrixPins};
use crate::hardware::{AXIS_CENTER, BUTTON_COLS, BUTTON_ROWS, NUMBER_OF_BUTTONS};
use crate::mapping::*;
use embedded_hal::digital::InputPin;
@ -17,6 +17,13 @@ use rp2040_hal::timer::Timer;
// Total buttons including the two extra (nonmatrix) buttons
pub const TOTAL_BUTTONS: usize = NUMBER_OF_BUTTONS + 2;
pub type JoystickButtonMatrix = ButtonMatrix<
MatrixPins<{ BUTTON_ROWS }, { BUTTON_COLS }>,
{ BUTTON_ROWS },
{ BUTTON_COLS },
{ NUMBER_OF_BUTTONS },
>;
// ==================== BUTTON STRUCT ====================
#[derive(Copy, Clone, Default)]
@ -75,10 +82,7 @@ impl ButtonManager {
}
/// Update button states from the button matrix snapshot.
pub fn update_from_matrix(
&mut self,
matrix: &mut ButtonMatrix<BUTTON_ROWS, BUTTON_COLS, NUMBER_OF_BUTTONS>,
) {
pub fn update_from_matrix(&mut self, matrix: &mut JoystickButtonMatrix) {
for (index, key) in matrix.buttons_pressed().iter().enumerate() {
self.buttons[index].pressed = *key;
}

View File

@ -1,105 +1,39 @@
//! Hardware configuration for CMDR Joystick 25 (RP2040)
//!
//! Centralizes board constants, GPIO mappings, timing cadences and helper
//! macros to keep hardware details out of business logic.
//! Mirrors the structure introduced for the CMDR Keyboard firmware so that
//! bring-up, pin management, and timing constants follow the same layout.
use rp2040_hal::gpio::Pins;
use rp2040_hal::gpio::{
self, DynPinId, FunctionI2C, FunctionPio0, FunctionSioInput, FunctionSioOutput, Pin, PullNone,
PullUp,
};
// ==================== CRYSTAL AND USB CONSTANTS ====================
/// External crystal frequency (Hz).
pub const XTAL_FREQ_HZ: u32 = 12_000_000u32;
/// USB Vendor ID.
pub const XTAL_FREQ_HZ: u32 = 12_000_000;
/// USB Vendor ID/Product ID.
pub const USB_VID: u16 = 0x1209;
/// USB Product ID.
pub const USB_PID: u16 = 0x0002;
// ==================== JOYSTICK CONSTANTS ====================
/// Button matrix geometry (rows).
/// Button matrix geometry (rows/cols) and count.
pub const BUTTON_ROWS: usize = 5;
/// Button matrix geometry (columns).
pub const BUTTON_COLS: usize = 5;
/// Total number of matrix buttons.
pub const NUMBER_OF_BUTTONS: usize = BUTTON_ROWS * BUTTON_COLS;
/// ADC raw minimum (12bit).
/// ADC characteristics.
pub const ADC_MIN: u16 = 0;
/// ADC raw maximum (12bit).
pub const ADC_MAX: u16 = 4095;
/// Logical axis center.
pub const AXIS_CENTER: u16 = (ADC_MIN + ADC_MAX) / 2;
/// Number of physical gimbal axes.
pub const NBR_OF_GIMBAL_AXIS: usize = 4;
/// Debounce threshold (in scans) for the matrix.
/// Increased from 10 to 15 scans to prevent double button presses from bounce.
/// At 200μs scan rate: 15 scans = 3ms debounce time.
pub const DEBOUNCE: u8 = 15;
/// Bytes reserved in EEPROM for calibration data + gimbal mode.
/// Debounce thresholds.
pub const MATRIX_DEBOUNCE_SCANS: u8 = 15;
pub const MIN_PRESS_SPACING_SCANS: u32 = 25; // ~5ms @ 200µs cadence
/// EEPROM storage length (calibration data + gimbal mode).
pub const EEPROM_DATA_LENGTH: usize = 25;
// ==================== GPIO PIN DEFINITIONS ====================
/// Logical mapping between board functions and GPIO numbers.
pub mod pins {
/// Extra buttons (TX/RX pins)
pub const LEFT_EXTRA_BUTTON_PIN: u8 = 1;
pub const RIGHT_EXTRA_BUTTON_PIN: u8 = 0;
/// Button matrix row pins
pub const BUTTON_ROW_PIN_0: u8 = 6;
pub const BUTTON_ROW_PIN_1: u8 = 8;
pub const BUTTON_ROW_PIN_2: u8 = 4;
pub const BUTTON_ROW_PIN_3: u8 = 7;
pub const BUTTON_ROW_PIN_4: u8 = 5;
/// Button matrix column pins
pub const BUTTON_COL_PIN_0: u8 = 9;
pub const BUTTON_COL_PIN_1: u8 = 10;
pub const BUTTON_COL_PIN_2: u8 = 11;
pub const BUTTON_COL_PIN_3: u8 = 12;
pub const BUTTON_COL_PIN_4: u8 = 13;
/// ADC pins for gimbal axes
pub const ADC_LEFT_X_PIN: u8 = 29;
pub const ADC_LEFT_Y_PIN: u8 = 28;
pub const ADC_RIGHT_X_PIN: u8 = 27;
pub const ADC_RIGHT_Y_PIN: u8 = 26;
/// Status LED pin
pub const STATUS_LED_PIN: u8 = 16;
/// I2C pins for EEPROM
pub const I2C_SDA_PIN: u8 = 14;
pub const I2C_SCL_PIN: u8 = 15;
}
// ==================== I2C CONFIGURATION ====================
/// I2C frequency and system clock helpers for the EEPROM bus.
pub mod i2c {
use fugit::{Rate, RateExtU32};
pub const I2C_FREQUENCY_HZ: u32 = 400_000;
pub fn i2c_frequency() -> Rate<u32, 1, 1> {
I2C_FREQUENCY_HZ.Hz()
}
pub const SYSTEM_CLOCK_HZ: u32 = 125_000_000;
pub fn system_clock() -> Rate<u32, 1, 1> {
SYSTEM_CLOCK_HZ.Hz()
}
}
// ==================== TIMER INTERVALS ====================
/// Cadences for periodic firmware tasks.
pub mod timers {
/// Status LED update interval (ms).
pub const STATUS_LED_INTERVAL_MS: u32 = 40;
/// Button matrix scan interval (µs).
pub const SCAN_INTERVAL_US: u32 = 200;
/// USB HID report interval (ms).
pub const USB_UPDATE_INTERVAL_MS: u32 = 1;
/// USB activity timeout (ms) - stop sending reports after this period of inactivity.
pub const USB_ACTIVITY_TIMEOUT_MS: u32 = 5_000; // 5 seconds
}
// ==================== USB DEVICE CONFIGURATION ====================
/// USB string descriptors.
pub mod usb {
pub const MANUFACTURER: &str = "CMtec";
@ -107,86 +41,155 @@ pub mod usb {
pub const SERIAL_NUMBER: &str = "0001";
}
// ==================== PIN ACCESS MACROS ====================
/// Macro to access typed GPIO pins using board constants.
/// Avoids scattering raw GPIO numbers; each arm references the constant it maps.
#[macro_export]
macro_rules! get_pin {
($pins:expr, left_extra_button) => {{
const _: u8 = $crate::hardware::pins::LEFT_EXTRA_BUTTON_PIN;
$pins.gpio1
}};
($pins:expr, right_extra_button) => {{
const _: u8 = $crate::hardware::pins::RIGHT_EXTRA_BUTTON_PIN;
$pins.gpio0
}};
($pins:expr, button_row_0) => {{
const _: u8 = $crate::hardware::pins::BUTTON_ROW_PIN_0;
$pins.gpio6
}};
($pins:expr, button_row_1) => {{
const _: u8 = $crate::hardware::pins::BUTTON_ROW_PIN_1;
$pins.gpio8
}};
($pins:expr, button_row_2) => {{
const _: u8 = $crate::hardware::pins::BUTTON_ROW_PIN_2;
$pins.gpio4
}};
($pins:expr, button_row_3) => {{
const _: u8 = $crate::hardware::pins::BUTTON_ROW_PIN_3;
$pins.gpio7
}};
($pins:expr, button_row_4) => {{
const _: u8 = $crate::hardware::pins::BUTTON_ROW_PIN_4;
$pins.gpio5
}};
($pins:expr, button_col_0) => {{
const _: u8 = $crate::hardware::pins::BUTTON_COL_PIN_0;
$pins.gpio9
}};
($pins:expr, button_col_1) => {{
const _: u8 = $crate::hardware::pins::BUTTON_COL_PIN_1;
$pins.gpio10
}};
($pins:expr, button_col_2) => {{
const _: u8 = $crate::hardware::pins::BUTTON_COL_PIN_2;
$pins.gpio11
}};
($pins:expr, button_col_3) => {{
const _: u8 = $crate::hardware::pins::BUTTON_COL_PIN_3;
$pins.gpio12
}};
($pins:expr, button_col_4) => {{
const _: u8 = $crate::hardware::pins::BUTTON_COL_PIN_4;
$pins.gpio13
}};
($pins:expr, adc_left_x) => {{
const _: u8 = $crate::hardware::pins::ADC_LEFT_X_PIN;
$pins.gpio29
}};
($pins:expr, adc_left_y) => {{
const _: u8 = $crate::hardware::pins::ADC_LEFT_Y_PIN;
$pins.gpio28
}};
($pins:expr, adc_right_x) => {{
const _: u8 = $crate::hardware::pins::ADC_RIGHT_X_PIN;
$pins.gpio27
}};
($pins:expr, adc_right_y) => {{
const _: u8 = $crate::hardware::pins::ADC_RIGHT_Y_PIN;
$pins.gpio26
}};
($pins:expr, status_led) => {{
const _: u8 = $crate::hardware::pins::STATUS_LED_PIN;
$pins.gpio16
}};
($pins:expr, i2c_sda) => {{
const _: u8 = $crate::hardware::pins::I2C_SDA_PIN;
$pins.gpio14
}};
($pins:expr, i2c_scl) => {{
const _: u8 = $crate::hardware::pins::I2C_SCL_PIN;
$pins.gpio15
}};
/// Timing cadences.
pub mod timers {
pub const STATUS_LED_INTERVAL_MS: u32 = 40;
pub const SCAN_INTERVAL_US: u32 = 200;
pub const USB_UPDATE_INTERVAL_MS: u32 = 1;
pub const USB_ACTIVITY_TIMEOUT_MS: u32 = 5_000;
}
/// I2C helpers.
pub mod i2c {
use eeprom24x::SlaveAddr;
use fugit::{Rate, RateExtU32};
pub const FREQUENCY_HZ: u32 = 400_000;
pub const SYSTEM_CLOCK_HZ: u32 = 125_000_000;
pub fn frequency() -> Rate<u32, 1, 1> {
FREQUENCY_HZ.Hz()
}
pub fn system_clock() -> Rate<u32, 1, 1> {
SYSTEM_CLOCK_HZ.Hz()
}
pub const EEPROM_ADDRESS: SlaveAddr = SlaveAddr::Alternative(false, false, false);
}
/// Raw GPIO constants retained for documentation/reference.
pub mod pins {
pub const LEFT_EXTRA_BUTTON: u8 = 1;
pub const RIGHT_EXTRA_BUTTON: u8 = 0;
pub const BUTTON_ROW_0: u8 = 6;
pub const BUTTON_ROW_1: u8 = 8;
pub const BUTTON_ROW_2: u8 = 4;
pub const BUTTON_ROW_3: u8 = 7;
pub const BUTTON_ROW_4: u8 = 5;
pub const BUTTON_COL_0: u8 = 9;
pub const BUTTON_COL_1: u8 = 10;
pub const BUTTON_COL_2: u8 = 11;
pub const BUTTON_COL_3: u8 = 12;
pub const BUTTON_COL_4: u8 = 13;
pub const ADC_LEFT_X: u8 = 29;
pub const ADC_LEFT_Y: u8 = 28;
pub const ADC_RIGHT_X: u8 = 27;
pub const ADC_RIGHT_Y: u8 = 26;
pub const STATUS_LED: u8 = 16;
pub const I2C_SDA: u8 = 14;
pub const I2C_SCL: u8 = 15;
}
/// Matrix row pins (dynamic to simplify scanning code).
pub type MatrixRowPin = Pin<DynPinId, FunctionSioInput, PullUp>;
/// Matrix column pins (dynamic push-pull outputs).
pub type MatrixColPin = Pin<DynPinId, FunctionSioOutput, PullNone>;
/// Extra buttons (pull-up inputs).
pub type ExtraButtonPin = Pin<DynPinId, FunctionSioInput, PullUp>;
/// Status LED pin configured for PIO output.
pub type StatusLedPin = Pin<gpio::bank0::Gpio16, FunctionPio0, PullNone>;
/// I2C SDA/SCL pins after reconfiguration.
pub type I2cSdaPin = Pin<gpio::bank0::Gpio14, FunctionI2C, PullUp>;
pub type I2cSclPin = Pin<gpio::bank0::Gpio15, FunctionI2C, PullUp>;
/// Analog axis input pins (remain as SIO inputs until wrapped by `AdcPin`).
pub struct AxisInputs {
pub left_x: Pin<gpio::bank0::Gpio29, FunctionSioInput, PullNone>,
pub left_y: Pin<gpio::bank0::Gpio28, FunctionSioInput, PullNone>,
pub right_x: Pin<gpio::bank0::Gpio27, FunctionSioInput, PullNone>,
pub right_y: Pin<gpio::bank0::Gpio26, FunctionSioInput, PullNone>,
}
/// Bundle returned by `split_board_pins`.
pub struct BoardPins {
pub matrix_rows: [MatrixRowPin; BUTTON_ROWS],
pub matrix_cols: [MatrixColPin; BUTTON_COLS],
pub left_extra_button: ExtraButtonPin,
pub right_extra_button: ExtraButtonPin,
pub axis_inputs: AxisInputs,
pub status_led: StatusLedPin,
pub i2c_sda: I2cSdaPin,
pub i2c_scl: I2cSclPin,
}
impl BoardPins {
pub fn new(pins: Pins) -> Self {
let row0 = pins.gpio6.into_pull_up_input().into_dyn_pin();
let row1 = pins.gpio8.into_pull_up_input().into_dyn_pin();
let row2 = pins.gpio4.into_pull_up_input().into_dyn_pin();
let row3 = pins.gpio7.into_pull_up_input().into_dyn_pin();
let row4 = pins.gpio5.into_pull_up_input().into_dyn_pin();
let col0 = pins
.gpio9
.into_push_pull_output()
.into_pull_type::<PullNone>()
.into_dyn_pin();
let col1 = pins
.gpio10
.into_push_pull_output()
.into_pull_type::<PullNone>()
.into_dyn_pin();
let col2 = pins
.gpio11
.into_push_pull_output()
.into_pull_type::<PullNone>()
.into_dyn_pin();
let col3 = pins
.gpio12
.into_push_pull_output()
.into_pull_type::<PullNone>()
.into_dyn_pin();
let col4 = pins
.gpio13
.into_push_pull_output()
.into_pull_type::<PullNone>()
.into_dyn_pin();
let left_extra = pins.gpio1.into_pull_up_input().into_dyn_pin();
let right_extra = pins.gpio0.into_pull_up_input().into_dyn_pin();
let axis_inputs = AxisInputs {
left_x: pins.gpio29.into_floating_input(),
left_y: pins.gpio28.into_floating_input(),
right_x: pins.gpio27.into_floating_input(),
right_y: pins.gpio26.into_floating_input(),
};
let status_led = pins
.gpio16
.into_function::<FunctionPio0>()
.into_pull_type::<PullNone>();
let i2c_sda = pins
.gpio14
.into_function::<FunctionI2C>()
.into_pull_type::<PullUp>();
let i2c_scl = pins
.gpio15
.into_function::<FunctionI2C>()
.into_pull_type::<PullUp>();
Self {
matrix_rows: [row0, row1, row2, row3, row4],
matrix_cols: [col0, col1, col2, col3, col4],
left_extra_button: left_extra,
right_extra_button: right_extra,
axis_inputs,
status_led,
i2c_sda,
i2c_scl,
}
}
}

309
rp2040/src/joystick.rs Normal file
View File

@ -0,0 +1,309 @@
//! Runtime state for the CMDR Joystick 25.
//!
//! This mirrors the `KeyboardState` abstraction from the keyboard refactor and
//! concentrates axis/button/calibration logic alongside USB bookkeeping.
use crate::axis::AxisManager;
use crate::buttons::{ButtonManager, SpecialAction};
use crate::calibration::CalibrationManager;
use crate::expo::ExpoLUT;
use crate::hardware;
use crate::status::SystemState;
use crate::usb_joystick_device::JoystickReport;
use crate::usb_report::get_joystick_report;
use core::fmt::Debug;
use dyn_smooth::DynamicSmootherEcoI32;
use embedded_hal::digital::InputPin;
use rp2040_hal::timer::Timer;
use usb_device::device::UsbDeviceState;
pub struct UsbState {
pub initialized: bool,
pub active: bool,
pub suspended: bool,
pub send_pending: bool,
pub wake_on_input: bool,
pub idle_mode: bool,
pub activity: bool,
activity_elapsed_ms: u32,
}
impl UsbState {
pub const fn new() -> Self {
Self {
initialized: false,
active: false,
suspended: false,
send_pending: false,
wake_on_input: false,
idle_mode: false,
activity: false,
activity_elapsed_ms: 0,
}
}
pub fn on_poll(&mut self) {
if !self.initialized {
self.initialized = true;
}
if !self.active {
self.mark_activity();
}
self.active = true;
}
pub fn mark_activity(&mut self) {
self.activity = true;
self.activity_elapsed_ms = 0;
self.idle_mode = false;
self.send_pending = true;
}
pub fn handle_input_activity(&mut self) {
self.mark_activity();
if self.suspended && self.wake_on_input {
self.wake_on_input = false;
}
}
pub fn on_suspend_change(&mut self, state: UsbDeviceState) {
let was_suspended = self.suspended;
self.suspended = state == UsbDeviceState::Suspend;
match (was_suspended, self.suspended) {
(true, false) => {
self.mark_activity();
self.wake_on_input = false;
}
(false, true) => {
self.idle_mode = true;
self.activity = false;
self.send_pending = false;
self.wake_on_input = true;
}
_ => {}
}
}
pub fn advance_idle_timer(&mut self, interval_ms: u32) {
if !self.activity {
return;
}
self.activity_elapsed_ms = self.activity_elapsed_ms.saturating_add(interval_ms);
if self.activity_elapsed_ms >= hardware::timers::USB_ACTIVITY_TIMEOUT_MS {
self.activity = false;
self.activity_elapsed_ms = 0;
self.idle_mode = true;
self.send_pending = false;
}
}
pub fn acknowledge_report(&mut self) {
self.send_pending = false;
}
}
pub struct JoystickState {
axis_manager: AxisManager,
button_manager: ButtonManager,
calibration_manager: CalibrationManager,
smoother: [DynamicSmootherEcoI32; hardware::NBR_OF_GIMBAL_AXIS],
expo_primary: ExpoLUT,
expo_virtual: ExpoLUT,
vt_enable: bool,
usb: UsbState,
}
impl JoystickState {
pub fn new() -> Self {
Self {
axis_manager: AxisManager::new(),
button_manager: ButtonManager::new(),
calibration_manager: CalibrationManager::new(),
smoother: AxisManager::create_smoothers(),
expo_primary: ExpoLUT::new(0.3),
expo_virtual: ExpoLUT::new(0.6),
vt_enable: false,
usb: UsbState::new(),
}
}
pub fn load_calibration<R>(&mut self, mut read_fn: R)
where
R: FnMut(u32) -> Result<u8, ()>,
{
CalibrationManager::load_axis_calibration(&mut self.axis_manager.axes, &mut read_fn);
let gimbal_mode = CalibrationManager::load_gimbal_mode(&mut read_fn);
self.axis_manager.set_gimbal_mode(gimbal_mode);
self.calibration_manager.set_gimbal_mode(gimbal_mode);
}
pub fn update_button_states<L, R>(
&mut self,
matrix: &mut crate::board::JoystickMatrix,
left_button: &mut L,
right_button: &mut R,
) where
L: InputPin,
R: InputPin,
L::Error: Debug,
R::Error: Debug,
{
self.button_manager.update_from_matrix(matrix);
self.button_manager
.update_extra_buttons(left_button, right_button);
self.button_manager.filter_hat_switches();
}
pub fn finalize_button_logic(&mut self, timer: &Timer) -> bool {
self.button_manager.process_button_logic_with_timer(timer)
}
pub fn check_special_action(&self) -> SpecialAction {
self.button_manager.check_special_combinations(
self.axis_manager.get_value_before_hold(),
self.calibration_manager.is_active(),
)
}
pub fn handle_special_action<W>(&mut self, action: SpecialAction, mut write_page: W)
where
W: FnMut(u32, &[u8]) -> Result<(), ()>,
{
match action {
SpecialAction::Bootloader => {}
SpecialAction::StartCalibration => {
for (index, axis) in self.axis_manager.axes.iter_mut().enumerate() {
let centered = self.smoother[index].value() as u16;
axis.center = centered;
axis.min = centered;
axis.max = centered;
}
self.axis_manager.clear_throttle_hold();
self.calibration_manager.start_calibration();
}
SpecialAction::CancelCalibration => {
self.calibration_manager.stop_calibration();
}
SpecialAction::ThrottleHold(value) => {
self.axis_manager.set_throttle_hold(value);
}
SpecialAction::VirtualThrottleToggle => {
self.vt_enable = !self.vt_enable;
}
SpecialAction::CalibrationSetModeM10 => {
if self
.calibration_manager
.set_gimbal_mode_m10(&mut self.axis_manager.axes, &self.smoother)
{
self.axis_manager
.set_gimbal_mode(self.calibration_manager.get_gimbal_mode());
self.axis_manager.clear_throttle_hold();
}
}
SpecialAction::CalibrationSetModeM7 => {
if self
.calibration_manager
.set_gimbal_mode_m7(&mut self.axis_manager.axes, &self.smoother)
{
self.axis_manager
.set_gimbal_mode(self.calibration_manager.get_gimbal_mode());
self.axis_manager.clear_throttle_hold();
}
}
SpecialAction::CalibrationSave => {
self.calibration_manager
.save_calibration(&self.axis_manager.axes, &mut |page, data| {
write_page(page, data)
});
}
SpecialAction::None => {}
}
}
pub fn update_calibration_tracking(&mut self) {
self.calibration_manager
.update_dynamic_calibration(&mut self.axis_manager.axes, &self.smoother);
}
pub fn tick_smoothers(&mut self, raw: &mut [u16; hardware::NBR_OF_GIMBAL_AXIS]) {
self.axis_manager.apply_gimbal_compensation(raw);
self.axis_manager.update_smoothers(&mut self.smoother, raw);
}
pub fn process_axes(&mut self) -> bool {
self.axis_manager
.process_axis_values(&self.smoother, &self.expo_primary)
}
pub fn update_virtual_axes(&mut self) -> bool {
self.axis_manager
.update_virtual_axes(self.button_manager.buttons(), self.vt_enable)
}
pub fn vt_enable(&self) -> bool {
self.vt_enable
}
pub fn usb_state(&mut self) -> &mut UsbState {
&mut self.usb
}
pub fn axis_manager(&mut self) -> &mut AxisManager {
&mut self.axis_manager
}
pub fn button_manager(&mut self) -> &mut ButtonManager {
&mut self.button_manager
}
pub fn expo_virtual(&self) -> &ExpoLUT {
&self.expo_virtual
}
pub fn smoother(&self) -> &[DynamicSmootherEcoI32; hardware::NBR_OF_GIMBAL_AXIS] {
&self.smoother
}
pub fn calibration_manager(&self) -> &CalibrationManager {
&self.calibration_manager
}
pub fn system_state(&self) -> SystemState {
SystemState {
usb_active: self.usb.active,
usb_initialized: self.usb.initialized,
usb_suspended: self.usb.suspended,
idle_mode: self.usb.idle_mode,
calibration_active: self.calibration_manager.is_active(),
throttle_hold_enable: self.axis_manager.throttle_hold_enable,
vt_enable: self.vt_enable,
}
}
pub fn build_report(&mut self) -> JoystickReport {
let virtual_ry = self.axis_manager.get_virtual_ry_value(&self.expo_virtual);
let virtual_rz = self.axis_manager.get_virtual_rz_value(&self.expo_virtual);
get_joystick_report(
self.button_manager.buttons_mut(),
&mut self.axis_manager.axes,
virtual_ry,
virtual_rz,
&self.vt_enable,
)
}
pub fn empty_report() -> JoystickReport {
JoystickReport {
x: 0,
y: 0,
z: 0,
rx: 0,
ry: 0,
rz: 0,
slider: 0,
hat: 8,
buttons: 0,
}
}
}

View File

@ -6,6 +6,13 @@
//! firmware: axis processing, button handling, calibration and storage, USB
//! HID reporting, and hardware/status abstractions.
#[cfg(not(feature = "std"))]
pub mod board;
#[cfg(not(feature = "std"))]
pub mod bootloader;
#[cfg(not(feature = "std"))]
pub mod joystick;
/// Axis processing for gimbal and virtual axes (smoothing, expo, holds).
pub mod axis;
/// Row/column scanned button matrix driver with debouncing.
@ -29,6 +36,11 @@ pub mod usb_joystick_device;
/// Convert runtime state into USB HID joystick reports.
pub mod usb_report;
#[cfg(not(feature = "std"))]
pub use board::{AxisAnalogPins, Board, BoardParts, JoystickMatrix, JoystickStatusLed};
#[cfg(not(feature = "std"))]
pub use joystick::{JoystickState, UsbState};
/// Re-exports for convenient access in `main` and downstream consumers.
pub use axis::{AxisManager, GimbalAxis, VirtualAxis};
pub use calibration::CalibrationManager;

View File

@ -1,273 +1,44 @@
//! CMDR Joystick 25 RP2040 main firmware
//!
//! Overview
//! - 4 gimbal axes (LX, LY, RX, RY) with smoothing, calibration and expo
//! - 2 virtual axes (RY/RZ) driven by buttons with direction compensation
//! - 5x5 button matrix + 2 extra buttons, with debounce and short/long press
//! - USB HID joystick: 7 axes, 32 buttons, 8way HAT
//! - EEPROMbacked calibration and gimbal mode (M10/M7)
//! - WS2812 status LED for state indication
//!
//! Modules
//! - hardware.rs: pins, clocks, timers, helpers
//! - axis.rs: gimbal/virtual axis processing and throttle hold
//! - button_matrix.rs + buttons.rs: scanning, debouncing, press types, special actions
//! - calibration.rs + storage.rs: runtime calibration and persistence
//! - usb_report.rs + usb_joystick_device.rs: HID descriptor and report generation
//! - status.rs: WS2812 driver and status model
//!
//! Modes: Normal, Calibration, Throttle Hold, Virtual Throttle, Bootloader
//!
//! Timing: scan 200 µs, process 1200 µs, USB 10 ms, LED 250 ms
#![no_std]
#![no_main]
mod axis;
mod button_matrix;
mod buttons;
mod calibration;
mod expo;
mod hardware;
mod mapping;
mod status;
mod storage;
mod usb_joystick_device;
mod usb_report;
use axis::AxisManager;
use button_matrix::ButtonMatrix;
use buttons::{ButtonManager, SpecialAction};
use calibration::CalibrationManager;
use core::convert::Infallible;
use core::panic::PanicInfo;
use cortex_m::delay::Delay;
use eeprom24x::{Eeprom24x, SlaveAddr};
use embedded_hal::digital::{InputPin, OutputPin};
use cmdr_joystick_25::buttons::SpecialAction;
use cmdr_joystick_25::hardware::{self, timers};
use cmdr_joystick_25::status::StatusMode;
use cmdr_joystick_25::usb_joystick_device::JoystickConfig;
use cmdr_joystick_25::{bootloader, Board, BoardParts, JoystickState};
use embedded_hal_0_2::adc::OneShot;
use embedded_hal_0_2::timer::CountDown;
use fugit::ExtU32;
use hardware::timers;
use mapping::*;
use rp2040_hal::{
adc::Adc,
adc::AdcPin,
clocks::{init_clocks_and_plls, Clock},
gpio::Pins,
i2c::I2C,
pac,
pio::PIOExt,
timer::Timer,
watchdog::Watchdog,
Sio,
};
use status::{StatusLed, StatusMode, SystemState};
use usb_device::class_prelude::*;
use usb_device::device::UsbDeviceState;
use panic_halt as _;
use usb_device::prelude::*;
use usb_joystick_device::JoystickConfig;
use usb_report::get_joystick_report;
use usbd_human_interface_device::prelude::*;
use usbd_human_interface_device::prelude::{UsbHidClassBuilder, UsbHidError};
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
/// Boot loader configuration for RP2040 ROM.
///
/// The linker places this boot block at the start of our program image to help the ROM
/// bootloader initialize our code. This specific boot loader supports W25Q080 flash memory.
#[link_section = ".boot2"]
#[no_mangle]
#[unsafe(link_section = ".boot2")]
#[unsafe(no_mangle)]
#[used]
pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080;
use expo::ExpoLUT;
/// Hardware configuration imports from the hardware abstraction layer.
use hardware::{ADC_MAX, ADC_MIN};
use hardware::{BUTTON_COLS, BUTTON_ROWS, NUMBER_OF_BUTTONS};
/// Additional hardware constants for button debouncing.
use hardware::DEBOUNCE;
#[cfg(not(test))]
#[rp2040_hal::entry]
fn main() -> ! {
// Hardware initialization and peripheral setup for joystick operation
// Acquire exclusive access to RP2040 peripherals
let mut pac = pac::Peripherals::take().unwrap();
// Initialize watchdog timer (required for clock configuration)
let mut watchdog = Watchdog::new(pac.WATCHDOG);
// Configure system clocks and phase-locked loops for stable operation
let clocks = init_clocks_and_plls(
hardware::XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
let core = pac::CorePeripherals::take().unwrap();
// Initialize SIO (Single-cycle I/O) for high-performance GPIO operations
let sio = Sio::new(pac.SIO);
// Configure GPIO pins to their default operational state
let pins = Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let i2c = I2C::i2c1(
pac.I2C1,
get_pin!(pins, i2c_sda).reconfigure(), // sda
get_pin!(pins, i2c_scl).reconfigure(), // scl
hardware::i2c::i2c_frequency(),
&mut pac.RESETS,
hardware::i2c::system_clock(),
);
let i2c_address = SlaveAddr::Alternative(false, false, false);
let mut eeprom = Eeprom24x::new_24x32(i2c, i2c_address);
// ADC configuration: prepare 12-bit ADC channels for all four gimbal axes
// Initialize 12-bit ADC with 4 channels for gimbal axes
let mut adc = Adc::new(pac.ADC, &mut pac.RESETS);
// Configure ADC input pins for 4-axis gimbal (Left X/Y, Right X/Y)
let mut adc_pin_left_x = AdcPin::new(get_pin!(pins, adc_left_x).into_floating_input()).unwrap();
let mut adc_pin_left_y = AdcPin::new(get_pin!(pins, adc_left_y).into_floating_input()).unwrap();
let mut adc_pin_right_x =
AdcPin::new(get_pin!(pins, adc_right_x).into_floating_input()).unwrap();
let mut adc_pin_right_y =
AdcPin::new(get_pin!(pins, adc_right_y).into_floating_input()).unwrap();
// # Button Matrix Configuration\n //\n // Configure the 5x5 button matrix using row/column scanning technique.\n // Rows are configured as pull-up inputs, columns as push-pull outputs.\n // This allows scanning 25 buttons with only 10 GPIO pins.\n\n // Configure button matrix row pins (inputs with pull-up resistors)
let button_matrix_row_pins: &mut [&mut dyn InputPin<Error = Infallible>; BUTTON_ROWS] = &mut [
&mut get_pin!(pins, button_row_0).into_pull_up_input(),
&mut get_pin!(pins, button_row_1).into_pull_up_input(),
&mut get_pin!(pins, button_row_2).into_pull_up_input(),
&mut get_pin!(pins, button_row_3).into_pull_up_input(),
&mut get_pin!(pins, button_row_4).into_pull_up_input(),
];
// Configure button matrix column pins (push-pull outputs for scanning)
let button_matrix_col_pins: &mut [&mut dyn OutputPin<Error = Infallible>; BUTTON_COLS] = &mut [
&mut get_pin!(pins, button_col_0).into_push_pull_output(),
&mut get_pin!(pins, button_col_1).into_push_pull_output(),
&mut get_pin!(pins, button_col_2).into_push_pull_output(),
&mut get_pin!(pins, button_col_3).into_push_pull_output(),
&mut get_pin!(pins, button_col_4).into_push_pull_output(),
];
// Initialize button matrix scanner with debouncing
let mut button_matrix: ButtonMatrix<BUTTON_ROWS, BUTTON_COLS, NUMBER_OF_BUTTONS> =
ButtonMatrix::new(button_matrix_row_pins, button_matrix_col_pins, DEBOUNCE);
// Configure matrix pins for scanning operation
button_matrix.init_pins();
// Configure additional buttons outside the matrix (total: 27 buttons)
let mut left_extra_button = get_pin!(pins, left_extra_button).into_pull_up_input();
let mut right_extra_button = get_pin!(pins, right_extra_button).into_pull_up_input();
// Status LED initialization: WS2812 via PIO for runtime status indication
// Initialize WS2812 status LED using PIO state machine
let (mut pio, sm0, _, _, _) = pac.PIO0.split(&mut pac.RESETS);
let mut status_led = StatusLed::new(
get_pin!(pins, status_led).into_function(),
&mut pio,
sm0,
clocks.peripheral_clock.freq(),
);
// Initial LED state (red) indicates system initialization
status_led.update(StatusMode::Error);
let mut delay = Delay::new(core.SYST, clocks.system_clock.freq().to_Hz());
// Bootloader entry check: early matrix scan; hold frontleftlower to enter USB massstorage bootloader
// Scan button matrix multiple times to ensure stable debounced readings
for _ in 0..10 {
// Multiple scans ensure debounce algorithm captures stable button states
button_matrix.scan_matrix(&mut delay);
}
if button_matrix.buttons_pressed()[BUTTON_FRONT_LEFT_LOWER] {
status_led.update(StatusMode::Bootloader);
let gpio_activity_pin_mask: u32 = 0;
let disable_interface_mask: u32 = 0;
rp2040_hal::rom_data::reset_to_usb_boot(gpio_activity_pin_mask, disable_interface_mask);
}
// Timer configuration: cadence for LED updates, scans, processing and USB
// Initialize hardware timer peripheral
let timer = Timer::new(pac.TIMER, &mut pac.RESETS, &clocks);
let mut status_led_count_down = timer.count_down();
status_led_count_down.start(timers::STATUS_LED_INTERVAL_MS.millis());
// Removed unused millisecond countdown timer
let mut scan_count_down = timer.count_down();
scan_count_down.start(timers::SCAN_INTERVAL_US.micros());
let mut usb_update_count_down = timer.count_down();
usb_update_count_down.start(timers::USB_UPDATE_INTERVAL_MS.millis());
let mut usb_activity: bool = false;
let mut usb_active: bool = false;
let mut usb_initialized: bool = false;
let mut usb_suspended: bool = false;
let mut usb_send_pending: bool = false;
let mut vt_enable: bool = false;
let mut idle_mode: bool = false;
let mut usb_activity_timeout_count: u32 = 0;
let mut wake_on_input: bool = false;
let mut axis_manager = AxisManager::new();
let mut button_manager = ButtonManager::new();
let mut calibration_manager = CalibrationManager::new();
// Signal processing: expo LUTs and smoothing filters
// Create exponential curve lookup tables (avoids floating-point math in real-time)
let expo_lut = ExpoLUT::new(0.3);
let expo_lut_virtual = ExpoLUT::new(0.6);
// Initialize digital smoothing filters for each gimbal axis
let mut smoother = AxisManager::create_smoothers();
// USB HID configuration (fullspeed joystick class)
// Initialize USB bus allocator for RP2040
let usb_bus = UsbBusAllocator::new(rp2040_hal::usb::UsbBus::new(
pac.USBCTRL_REGS,
pac.USBCTRL_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
let BoardParts {
mut button_matrix,
mut status_led,
mut delay,
timer,
mut adc,
mut axis_pins,
mut left_extra_button,
mut right_extra_button,
mut eeprom,
usb_bus,
} = Board::new().into_parts();
let mut usb_hid_joystick = UsbHidClassBuilder::new()
.add_device(JoystickConfig::default())
.build(&usb_bus);
.build(usb_bus);
let mut usb_dev =
UsbDeviceBuilder::new(&usb_bus, UsbVidPid(hardware::USB_VID, hardware::USB_PID))
UsbDeviceBuilder::new(usb_bus, UsbVidPid(hardware::USB_VID, hardware::USB_PID))
.strings(&[StringDescriptors::default()
.manufacturer(hardware::usb::MANUFACTURER)
.product(hardware::usb::PRODUCT)
@ -275,63 +46,45 @@ fn main() -> ! {
.unwrap()
.build();
// Calibration data initialization: load axis calibration and gimbal mode from EEPROM
let mut state = JoystickState::new();
status_led.update(StatusMode::Error);
// Load calibration data from EEPROM using CalibrationManager
button_matrix.prime(&mut delay, 10);
let initial_pressed = button_matrix.buttons_pressed();
if bootloader::startup_requested(&initial_pressed) {
bootloader::enter(&mut status_led);
}
{
let mut read_fn = |addr: u32| eeprom.read_byte(addr).map_err(|_| ());
CalibrationManager::load_axis_calibration(&mut axis_manager.axes, &mut read_fn);
let gimbal_mode = CalibrationManager::load_gimbal_mode(&mut read_fn);
axis_manager.set_gimbal_mode(gimbal_mode);
calibration_manager.set_gimbal_mode(gimbal_mode);
state.load_calibration(&mut read_fn);
}
let mut scan_tick = timer.count_down();
scan_tick.start(timers::SCAN_INTERVAL_US.micros());
let mut status_tick = timer.count_down();
status_tick.start(timers::STATUS_LED_INTERVAL_MS.millis());
let mut usb_tick = timer.count_down();
usb_tick.start(timers::USB_UPDATE_INTERVAL_MS.millis());
let mut status_time_ms: u32 = 0;
loop {
// Main control loop: poll USB, scan inputs, process data, send reports
// Handle USB device polling and maintain connection state
if usb_dev.poll(&mut [&mut usb_hid_joystick]) {
if !usb_initialized {
usb_initialized = true;
}
if !usb_active {
usb_activity = true; // Force initial report
idle_mode = false;
usb_activity_timeout_count = 0;
usb_send_pending = true;
}
usb_active = true;
state.usb_state().on_poll();
}
// Check USB device state for suspend/resume handling
let usb_state = usb_dev.state();
let was_suspended = usb_suspended;
usb_suspended = usb_state == UsbDeviceState::Suspend;
state.usb_state().on_suspend_change(usb_state);
// Handle USB resume transition
if was_suspended && !usb_suspended {
// Device was suspended and is now resumed
usb_activity = true;
idle_mode = false;
usb_activity_timeout_count = 0;
usb_send_pending = true;
wake_on_input = false;
if status_tick.wait().is_ok() {
status_time_ms = status_time_ms.saturating_add(timers::STATUS_LED_INTERVAL_MS);
status_led.update_from_system_state(state.system_state(), status_time_ms);
}
// Handle USB suspend transition
if !was_suspended && usb_suspended {
// Device has just been suspended - enter power saving mode
idle_mode = true;
usb_activity = false;
usb_send_pending = false;
wake_on_input = true;
// Reduce LED update frequency to save power when suspended
// LED will be off anyway (Suspended mode), so slow updates are fine
}
// Skip high-frequency scanning when suspended to save power
// Only scan periodically to detect wake-up inputs
let should_scan = if usb_suspended {
// When suspended, reduce scan frequency by factor of 10 (every ~2ms instead of 200μs)
let should_scan = if state.usb_state().suspended {
static mut SUSPENDED_SCAN_COUNTER: u8 = 0;
unsafe {
SUSPENDED_SCAN_COUNTER = (SUSPENDED_SCAN_COUNTER + 1) % 10;
@ -341,219 +94,83 @@ fn main() -> ! {
true
};
if scan_count_down.wait().is_ok() && should_scan {
// ## High-Frequency Input Sampling (~5 kHz)
//
// Sample all inputs at high frequency for responsive control:
// - Button matrix scanning with debouncing
// - ADC reading from all 4 gimbal axes
// - Digital filtering for noise reduction
// Scan 5x5 button matrix for input changes
if should_scan && scan_tick.wait().is_ok() {
button_matrix.scan_matrix(&mut delay);
// Read raw 12-bit ADC values from all 4 gimbal potentiometers
let mut raw_values = [
adc.read(&mut adc_pin_left_x).unwrap(),
adc.read(&mut adc_pin_left_y).unwrap(),
adc.read(&mut adc_pin_right_x).unwrap(),
adc.read(&mut adc_pin_right_y).unwrap(),
adc.read(&mut axis_pins.left_x).unwrap(),
adc.read(&mut axis_pins.left_y).unwrap(),
adc.read(&mut axis_pins.right_x).unwrap(),
adc.read(&mut axis_pins.right_y).unwrap(),
];
state.tick_smoothers(&mut raw_values);
// Apply hardware-specific axis compensation (M10/M7 differences)
axis_manager.apply_gimbal_compensation(&mut raw_values);
// Apply digital smoothing filters to reduce ADC noise and jitter
axis_manager.update_smoothers(&mut smoother, &raw_values);
// ## Immediate Data Processing (formerly 1000 Hz)
//
// Process all input data right after sampling for minimal latency.
// Update button states from matrix scan and extra buttons
button_manager.update_from_matrix(&mut button_matrix);
button_manager.update_extra_buttons(&mut left_extra_button, &mut right_extra_button);
button_manager.filter_hat_switches();
// Process special button combinations for system control
let action = button_manager.check_special_combinations(
axis_manager.get_value_before_hold(),
calibration_manager.is_active(),
);
match action {
SpecialAction::Bootloader => {
status_led.update(StatusMode::Bootloader);
let gpio_activity_pin_mask: u32 = 0;
let disable_interface_mask: u32 = 0;
rp2040_hal::rom_data::reset_to_usb_boot(
gpio_activity_pin_mask,
disable_interface_mask,
state.update_button_states(
&mut button_matrix,
&mut left_extra_button,
&mut right_extra_button,
);
let action = state.check_special_action();
if matches!(action, SpecialAction::Bootloader) {
if !state.usb_state().suspended {
let clear_report = JoystickState::empty_report();
for _ in 0..3 {
match usb_hid_joystick.device().write_report(&clear_report) {
Ok(_) => break,
Err(UsbHidError::WouldBlock) => {
let _ = usb_hid_joystick.tick();
}
SpecialAction::StartCalibration => {
for (index, item) in axis_manager.axes.iter_mut().enumerate() {
item.center = smoother[index].value() as u16;
item.min = item.center;
item.max = item.center;
}
axis_manager.clear_throttle_hold(); // Clear throttle hold when cancelling calibration
calibration_manager.start_calibration();
}
SpecialAction::CancelCalibration => {
calibration_manager.stop_calibration();
}
SpecialAction::ThrottleHold(hold_value) => {
axis_manager.set_throttle_hold(hold_value);
}
SpecialAction::VirtualThrottleToggle => {
vt_enable = !vt_enable;
}
SpecialAction::CalibrationSetModeM10 => {
// Set gimbal mode to M10 and reset calibration
if calibration_manager.set_gimbal_mode_m10(&mut axis_manager.axes, &smoother) {
axis_manager.set_gimbal_mode(calibration_manager.get_gimbal_mode());
axis_manager.clear_throttle_hold(); // Clear holds after mode change
Err(_) => break,
}
}
SpecialAction::CalibrationSetModeM7 => {
// Set gimbal mode to M7 and reset calibration
if calibration_manager.set_gimbal_mode_m7(&mut axis_manager.axes, &smoother) {
axis_manager.set_gimbal_mode(calibration_manager.get_gimbal_mode());
axis_manager.clear_throttle_hold(); // Clear holds after mode change
}
}
SpecialAction::CalibrationSave => {
// Save calibration data and end calibration mode
calibration_manager
.save_calibration(&axis_manager.axes, &mut |page: u32, data: &[u8]| {
eeprom.write_page(page, data).map_err(|_| ())
});
}
SpecialAction::None => {}
bootloader::enter(&mut status_led);
} else if !matches!(action, SpecialAction::None) {
let mut write_page =
|page: u32, data: &[u8]| eeprom.write_page(page, data).map_err(|_| ());
state.handle_special_action(action, &mut write_page);
}
// Always update calibration for dynamic min/max tracking when active
calibration_manager.update_dynamic_calibration(&mut axis_manager.axes, &smoother);
state.update_calibration_tracking();
// Process gimbal axes through calibration, expo curves, and scaling
if axis_manager.process_axis_values(&smoother, &expo_lut) {
usb_activity = true;
usb_activity_timeout_count = 0; // Reset timeout on real input activity
idle_mode = false;
usb_send_pending = true;
if state.process_axes() {
state.usb_state().handle_input_activity();
}
// Wake from USB suspend if input detected
if wake_on_input && usb_suspended {
// TODO: Implement remote wakeup if supported by host
wake_on_input = false;
if state.update_virtual_axes() {
state.usb_state().handle_input_activity();
}
if state.finalize_button_logic(&timer) {
state.usb_state().handle_input_activity();
}
}
// Update virtual axes based on front button states
if axis_manager.update_virtual_axes(button_manager.buttons(), vt_enable) {
usb_activity = true;
usb_activity_timeout_count = 0; // Reset timeout on real input activity
idle_mode = false;
usb_send_pending = true;
// Wake from USB suspend if input detected
if wake_on_input && usb_suspended {
// TODO: Implement remote wakeup if supported by host
wake_on_input = false;
}
let usb_tick_elapsed = usb_tick.wait().is_ok();
if usb_tick_elapsed {
state
.usb_state()
.advance_idle_timer(timers::USB_UPDATE_INTERVAL_MS);
}
// Process button logic (press types, timing, USB mapping)
if button_manager.process_button_logic_with_timer(&timer) {
usb_activity = true;
usb_activity_timeout_count = 0; // Reset timeout on real input activity
idle_mode = false;
usb_send_pending = true;
// Wake from USB suspend if input detected
if wake_on_input && usb_suspended {
// TODO: Implement remote wakeup if supported by host
wake_on_input = false;
}
}
}
if status_led_count_down.wait().is_ok() {
// ## Status LED Updates (100Hz)
//
// Update status LED to reflect current system state:
// - Green: Normal operation with USB connection
// - Blue: Calibration mode active
// - Yellow: Throttle hold or Virtual Throttle enabled
// - Red: Error state or disconnected
// - Purple: Bootloader mode
let system_state = SystemState {
usb_active,
usb_initialized,
usb_suspended,
idle_mode,
calibration_active: calibration_manager.is_active(),
throttle_hold_enable: axis_manager.throttle_hold_enable,
vt_enable,
};
status_led.update_from_system_state(
system_state,
(timer.get_counter().ticks() / 1000) as u32,
);
}
// ## USB HID Report Transmission (up to 1 kHz)
//
// Transmit USB HID reports only when there is input activity.
// This power-management approach prevents the computer from staying
// awake unnecessarily while maintaining responsive control.
//
// The report includes:
// - All 7 analog axes with proper scaling
// - 32-button bitmask with USB mapping
// - 8-direction HAT switch state
// - Virtual throttle mode handling
// Only transmit USB reports when input activity is detected and not suspended
let usb_tick = usb_update_count_down.wait().is_ok();
if usb_activity && (usb_tick || usb_send_pending) && !usb_suspended {
let mut send_report = || {
let virtual_ry_value = axis_manager.get_virtual_ry_value(&expo_lut_virtual);
let virtual_rz_value = axis_manager.get_virtual_rz_value(&expo_lut_virtual);
match usb_hid_joystick.device().write_report(&get_joystick_report(
button_manager.buttons_mut(),
&mut axis_manager.axes,
virtual_ry_value,
virtual_rz_value,
&vt_enable,
)) {
if state.usb_state().activity
&& (usb_tick_elapsed || state.usb_state().send_pending)
&& !state.usb_state().suspended
{
let report = state.build_report();
match usb_hid_joystick.device().write_report(&report) {
Err(UsbHidError::WouldBlock) => {}
Ok(_) => {
usb_send_pending = false;
state.usb_state().acknowledge_report();
}
Err(e) => {
Err(error) => {
status_led.update(StatusMode::Error);
core::panic!("Failed to write joystick report: {:?}", e);
panic!("Failed to write joystick report: {:?}", error);
}
}
};
if usb_tick {
usb_activity_timeout_count += timers::USB_UPDATE_INTERVAL_MS;
if usb_activity_timeout_count >= timers::USB_ACTIVITY_TIMEOUT_MS {
usb_activity = false;
usb_activity_timeout_count = 0;
idle_mode = true;
usb_send_pending = false;
} else {
send_report();
}
} else {
send_report();
}
} else if usb_tick && usb_active && !usb_suspended {
// Only update idle mode for non-suspended devices
idle_mode = true;
} else if usb_tick_elapsed && state.usb_state().active && !state.usb_state().suspended {
state.usb_state().idle_mode = true;
}
}
}

110
tools/copy_uf2.py Executable file
View File

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Copy a UF2 artifact to a detected RP2040 mass-storage mount."""
from __future__ import annotations
import argparse
import os
import shutil
import sys
import time
from pathlib import Path
INFO_FILE = "INFO_UF2.TXT"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True, help="Path to the UF2 file to copy")
parser.add_argument("--timeout", type=float, default=10.0, help="Seconds to wait for the mount")
parser.add_argument(
"--mount",
type=str,
default=os.environ.get("MOUNT", ""),
help="Explicit mount point (default: auto-detect)",
)
return parser.parse_args()
def candidate_paths(explicit: str, user: str) -> list[Path]:
paths: list[Path] = []
if explicit:
paths.append(Path(explicit))
roots = [
Path("/Volumes"),
Path("/media"),
Path(f"/media/{user}"),
Path("/run/media"),
Path(f"/run/media/{user}"),
]
for root in roots:
if not root.exists() or not root.is_dir():
continue
for child in root.iterdir():
if child.is_dir():
paths.append(child)
return paths
def choose_mount(explicit: str, user: str) -> Path | None:
candidates = candidate_paths(explicit, user)
if explicit:
path = Path(explicit)
return path if path.exists() and path.is_dir() else None
info_candidates = [path for path in candidates if (path / INFO_FILE).exists()]
if info_candidates:
return info_candidates[0]
for path in candidates:
if path.exists() and path.is_dir():
return path
return None
def main() -> int:
args = parse_args()
source = args.source
if not source.exists():
print(f"UF2 source file not found: {source}", file=sys.stderr)
return 1
explicit_mount = args.mount.strip()
user = os.environ.get("USER", "")
deadline = time.time() + float(args.timeout)
while time.time() <= deadline:
mount = choose_mount(explicit_mount, user)
if mount is not None:
if not mount.exists() or not mount.is_dir():
time.sleep(1)
continue
destination = mount / source.name
try:
shutil.copy2(source, destination)
try:
if hasattr(os, "sync"):
os.sync()
except Exception:
pass
time.sleep(0.5)
except Exception as exc: # noqa: BLE001
print(f"Failed to copy UF2 to {destination}: {exc}", file=sys.stderr)
return 1
print(f"Copied {source} to {destination}")
return 0
time.sleep(1)
if explicit_mount:
print(
f"Mount point '{explicit_mount}' not found within {args.timeout} seconds",
file=sys.stderr,
)
else:
print(
"Unable to detect RP2040 UF2 mount. Pass one via mount=/path",
file=sys.stderr,
)
return 1
if __name__ == "__main__": # pragma: no cover
sys.exit(main())