Updated documentation

This commit is contained in:
Christoffer Martinsson 2025-09-20 13:41:48 +02:00
parent c5204b172b
commit 1799f765dc
14 changed files with 170 additions and 4 deletions

View File

@ -1,13 +1,23 @@
# Assistant Configuration
This file contains configuration and commands for the Claude assistant working on the CMtec CMDR Joystick 25.
## Global Rules
- Rust emdedded
- Always describe what you thinking and your plan befor starting to change files.
- Make sure code have max 5 indentation levels
- Use classes, arrays, structs, etc for clean organization
- Use arrays, structs, etc for clean organization
- Make sure the codebase is manageable and easily readable
- Always check code (compile/check)
- Always fix compile warnings
- Do not try to deploy project to hardware
- Use "just" for check, test, flash etc
- Use file structure described in this file
## Firmware File Structure Blueprint (RP2040 / RP2350)
- `src/hardware.rs`**Required.** Centralize pin assignments, clock constants, peripheral aliases, timer intervals, and other board-specific configuration. Nothing outside this module hardcodes MCU pin numbers or magic frequencies.
- `src/board.rs`**Required.** Board bring-up; owns peripheral wiring (clocks, GPIO, comms, sensors, USB), exposes `Board`/`BoardParts` (or equivalent). Keep granular comments explaining each hardware init block.
- `src/main.rs`**Required.** Thin firmware entry; fetch initialized parts, load persisted configuration, configure timers, and run the primary control loop (USB/event poll, scheduling, report generation). Runtime orchestration only.
- Feature modules stay single-purpose (e.g., `inputs.rs`, `sensors.rs`, `storage.rs`, `status.rs`, `usb_report.rs`, `usb_device.rs`). Each should include unit tests with short intent comments capturing edge cases and data packing, runnable in host mode.
- Utility crates (`mapping.rs`, `calibration.rs`, etc.) should avoid cross-module side effects—prefer explicit data passed through `BoardParts`/state structs.
- Comments document why a block exists or which hardware behaviour it mirrors; avoid repeating obvious code but provide enough context for re-use across RP-series projects.

View File

@ -54,6 +54,7 @@ pub struct GimbalAxis {
impl Default for GimbalAxis {
fn default() -> Self {
// Apply new calibration limits supplied by the calibration manager.
GimbalAxis {
value: AXIS_CENTER,
value_before_hold: AXIS_CENTER,
@ -143,6 +144,7 @@ pub struct VirtualAxis {
impl Default for VirtualAxis {
fn default() -> Self {
// Create a virtual axis starting at center with the supplied step size.
VirtualAxis {
value: AXIS_CENTER,
step: 5,
@ -206,6 +208,7 @@ pub struct AxisManager {
impl Default for AxisManager {
fn default() -> Self {
// Delegate to `new` so the default stays aligned with explicit constructor logic.
Self::new()
}
}
@ -462,6 +465,7 @@ mod tests {
#[test]
fn test_gimbal_axis_default() {
// Factory defaults should leave every axis field pointing at the center span.
let axis = GimbalAxis::default();
assert_eq!(axis.value, AXIS_CENTER);
assert_eq!(axis.min, ADC_MIN);
@ -473,6 +477,7 @@ mod tests {
#[test]
fn test_gimbal_axis_new() {
// `new` should be a thin wrapper over `Default` without changing members.
let axis = GimbalAxis::new();
assert_eq!(axis.value, AXIS_CENTER);
assert_eq!(axis.min, ADC_MIN);
@ -483,6 +488,7 @@ mod tests {
#[test]
fn test_gimbal_axis_with_calibration() {
// Axis constructed with explicit calibration should adopt all provided bounds.
let axis = GimbalAxis::with_calibration(100, 3900, 2000);
assert_eq!(axis.min, 100);
assert_eq!(axis.max, 3900);
@ -491,6 +497,7 @@ mod tests {
#[test]
fn test_gimbal_axis_activity_detection() {
// Activity flag should only flip when the axis value actually changes.
let mut axis = GimbalAxis::new();
// Initially no activity (same as previous)
@ -506,6 +513,7 @@ mod tests {
#[test]
fn test_gimbal_axis_throttle_hold_processing() {
// Throttle hold remapping should cover remap, center latch, and pending hold cases.
let mut axis = GimbalAxis::new();
axis.set_hold(1500); // Set hold value below center
@ -541,6 +549,7 @@ mod tests {
#[test]
fn test_virtual_axis_default() {
// Virtual axis should boot at center with the configured step size.
let virtual_axis = VirtualAxis::default();
assert_eq!(virtual_axis.value, AXIS_CENTER);
assert_eq!(virtual_axis.step, 5);
@ -548,6 +557,7 @@ mod tests {
#[test]
fn test_virtual_axis_movement_up() {
// An upward press should increment the virtual axis and flag activity.
let mut virtual_axis = VirtualAxis::new(10);
// Test upward movement
@ -558,6 +568,7 @@ mod tests {
#[test]
fn test_virtual_axis_movement_down() {
// A downward press should decrement the virtual axis and flag activity.
let mut virtual_axis = VirtualAxis::new(10);
// Test downward movement
@ -568,6 +579,7 @@ mod tests {
#[test]
fn test_virtual_axis_return_to_center() {
// Without inputs the virtual axis should ease back toward the center value.
let mut virtual_axis = VirtualAxis::new(10);
virtual_axis.value = AXIS_CENTER + 20;
@ -579,6 +591,7 @@ mod tests {
#[test]
fn test_virtual_axis_direction_compensation() {
// Reversing direction should recenter before stepping to avoid large jumps.
let mut virtual_axis = VirtualAxis::new(10);
virtual_axis.value = AXIS_CENTER - 100;
@ -591,6 +604,7 @@ mod tests {
#[test]
fn test_axis_manager_creation() {
// Manager construction should initialize all axes and state to defaults.
let manager = AxisManager::new();
assert_eq!(manager.axes.len(), NBR_OF_GIMBAL_AXIS);
assert_eq!(manager.gimbal_mode, GIMBAL_MODE_M10);
@ -600,6 +614,7 @@ mod tests {
#[test]
fn test_gimbal_compensation_m10() {
// M10 gimbal compensation must invert the correct pair of axes.
let manager = AxisManager::new(); // Default is M10
let mut raw_values = [1000, 1500, 2000, 2500];
@ -614,6 +629,7 @@ mod tests {
#[test]
fn test_gimbal_compensation_m7() {
// M7 gimbal compensation must invert the complementary axes.
let mut manager = AxisManager::new();
manager.set_gimbal_mode(GIMBAL_MODE_M7);
let mut raw_values = [1000, 1500, 2000, 2500];
@ -629,6 +645,7 @@ mod tests {
#[test]
fn test_axis_activity_detection() {
// Manager activity flag should reflect when any axis value changes.
let mut manager = AxisManager::new();
// No activity initially
@ -644,6 +661,7 @@ mod tests {
#[test]
fn test_calculate_axis_value_boundaries() {
// Axis conversion should clamp inputs below min or above max calibration values.
let expo_lut = ExpoLUT::new(0.0); // No expo for testing
// Test min boundary
@ -657,6 +675,7 @@ mod tests {
#[test]
fn test_calculate_axis_value_deadzone() {
// Inputs inside the center deadzone should resolve to the exact center value.
let expo_lut = ExpoLUT::new(0.0); // No expo for testing
// Test center deadzone
@ -666,6 +685,7 @@ mod tests {
#[test]
fn test_calculate_axis_value_degenerate_calibration() {
// Degenerate calibration inputs should return the provided center without panicking.
let expo_lut = ExpoLUT::new(0.0);
// When calibration collapses to a single point (min=max=center),

View File

@ -29,6 +29,7 @@ pub type JoystickStatusLed = StatusLed<pac::PIO0, rp2040_hal::pio::SM0, hardware
type BoardI2c = I2C<pac::I2C1, (hardware::I2cSdaPin, hardware::I2cSclPin)>;
type BoardEeprom = Eeprom24x<BoardI2c, page_size::B32, addr_size::TwoBytes, unique_serial::No>;
/// Strongly-typed collection of ADC-capable pins for each physical gimbal axis.
pub struct AxisAnalogPins {
pub left_x: AdcPin<Pin<gpio::bank0::Gpio29, FunctionSioInput, PullNone>>,
pub left_y: AdcPin<Pin<gpio::bank0::Gpio28, FunctionSioInput, PullNone>>,
@ -38,6 +39,7 @@ pub struct AxisAnalogPins {
impl AxisAnalogPins {
fn new(inputs: hardware::AxisInputs) -> Self {
// Wrap the raw GPIO inputs into ADC-capable pins for each physical axis.
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();
@ -51,6 +53,7 @@ impl AxisAnalogPins {
}
}
/// Aggregates the runtime peripherals used by the joystick firmware.
pub struct Board {
button_matrix: JoystickMatrix,
status_led: JoystickStatusLed,
@ -64,6 +67,7 @@ pub struct Board {
usb_bus: &'static UsbBusAllocator<rp2040_hal::usb::UsbBus>,
}
/// Board components handed off to the application after initialization.
pub struct BoardParts {
pub button_matrix: JoystickMatrix,
pub status_led: JoystickStatusLed,
@ -79,10 +83,12 @@ pub struct BoardParts {
impl Board {
pub fn new() -> Self {
// Acquire RP2040 peripheral handles before configuration begins.
let mut pac = pac::Peripherals::take().unwrap();
let core = pac::CorePeripherals::take().unwrap();
let mut watchdog = Watchdog::new(pac.WATCHDOG);
// Bring up the primary system and USB clocks using the external crystal.
let clocks = init_clocks_and_plls(
hardware::XTAL_FREQ_HZ,
pac.XOSC,
@ -96,6 +102,7 @@ impl Board {
.unwrap();
let sio = Sio::new(pac.SIO);
// Split GPIO banks and translate them into the strongly typed board pins.
let raw_pins = gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
@ -105,6 +112,7 @@ impl Board {
let pins = BoardPins::new(raw_pins);
let matrix_pins = MatrixPins::new(pins.matrix_rows, pins.matrix_cols);
// Create the button matrix scanner with firmware debounce parameters.
let mut button_matrix = ButtonMatrix::new(
matrix_pins,
hardware::MATRIX_DEBOUNCE_SCANS,
@ -113,6 +121,7 @@ impl Board {
button_matrix.init_pins();
let (mut pio, sm0, _, _, _) = pac.PIO0.split(&mut pac.RESETS);
// Configure the WS2812 status LED using a dedicated PIO state machine.
let status_led = StatusLed::new(
pins.status_led,
&mut pio,
@ -120,9 +129,11 @@ impl Board {
clocks.peripheral_clock.freq(),
);
// Set up timers for scheduling and a blocking delay helper.
let timer = Timer::new(pac.TIMER, &mut pac.RESETS, &clocks);
let delay = Delay::new(core.SYST, clocks.system_clock.freq().to_Hz());
// Build the I²C bus and EEPROM driver used for calibration persistence.
let i2c = I2C::i2c1(
pac.I2C1,
pins.i2c_sda,
@ -133,9 +144,11 @@ impl Board {
);
let eeprom = Eeprom24x::new_24x32(i2c, hardware::i2c::EEPROM_ADDRESS);
// Bring up the ADC block and wrap each axis input pin.
let adc = Adc::new(pac.ADC, &mut pac.RESETS);
let axis_pins = AxisAnalogPins::new(pins.axis_inputs);
// Prepare a global USB bus allocator for the joystick HID device.
let usb_bus = usb_allocator(
pac.USBCTRL_REGS,
pac.USBCTRL_DPRAM,
@ -179,8 +192,11 @@ fn usb_allocator(
usb_clock: rp2040_hal::clocks::UsbClock,
resets: &mut pac::RESETS,
) -> &'static UsbBusAllocator<rp2040_hal::usb::UsbBus> {
// Lazily create the shared USB bus allocator so HID endpoints can borrow it.
static USB_BUS: StaticCell<UsbBusAllocator<rp2040_hal::usb::UsbBus>> = StaticCell::new();
// Wire up the USB bus allocator, HID class, and joystick endpoint once and reuse it.
interrupt::free(|_| {
USB_BUS.init_with(|| {
UsbBusAllocator::new(rp2040_hal::usb::UsbBus::new(

View File

@ -35,20 +35,24 @@ impl<const ROWS: usize, const COLS: usize> MatrixPins<ROWS, COLS> {
impl<const ROWS: usize, const COLS: usize> MatrixPinAccess<ROWS, COLS> for MatrixPins<ROWS, COLS> {
fn init_columns(&mut self) {
// Default all columns high so rows can be strobed one at a time.
for column in self.cols.iter_mut() {
let _ = column.set_high();
}
}
fn set_column_low(&mut self, column: usize) {
// Pull the active column low before scanning its rows.
let _ = self.cols[column].set_low();
}
fn set_column_high(&mut self, column: usize) {
// Release the column after scanning so other columns remain idle.
let _ = self.cols[column].set_high();
}
fn read_row(&mut self, row: usize) -> bool {
// Treat any low level as a pressed switch, defaulting to false on IO errors.
self.rows[row].is_low().unwrap_or(false)
}
}
@ -112,6 +116,7 @@ where
}
fn process_column(&mut self, column: usize) {
// Drive a single column scan to update button press history.
for row in 0..ROWS {
let index = column + (row * COLS);
let current_state = self.pins.read_row(row);
@ -138,6 +143,7 @@ where
}
fn should_register_press(&mut self, index: usize) -> bool {
// Decide if a press should register given debounce timing.
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;
@ -178,6 +184,7 @@ mod tests {
impl MockPins {
fn new(row_state: Rc<Cell<bool>>, column_state: Rc<Cell<bool>>) -> Self {
// Build a button matrix scanner with default state tracking arrays.
Self {
row_state,
column_state,
@ -187,18 +194,22 @@ mod tests {
impl MatrixPinAccess<1, 1> for MockPins {
fn init_columns(&mut self) {
// Simulate the hardware by driving the single column high by default.
self.column_state.set(true);
}
fn set_column_low(&mut self, _column: usize) {
// Drop the mock column low to emulate scanning behaviour.
self.column_state.set(false);
}
fn set_column_high(&mut self, _column: usize) {
// Release the mock column back to the idle high state.
self.column_state.set(true);
}
fn read_row(&mut self, _row: usize) -> bool {
// Return the mocked row state so tests can control pressed/unpressed.
self.row_state.get()
}
}
@ -217,6 +228,7 @@ mod tests {
#[test]
fn debounce_requires_consecutive_scans() {
// Debounce logic should require two consecutive pressed scans before registering.
let (mut matrix, row, _column) = fixture();
matrix.set_scan_counter(1);

View File

@ -67,6 +67,7 @@ pub struct ButtonManager {
impl Default for ButtonManager {
fn default() -> Self {
// Build a button manager with default-initialized buttons and state flags.
Self::new()
}
}
@ -121,6 +122,7 @@ impl ButtonManager {
}
fn reconcile_hat(&mut self, directions: &[usize; 4], center: usize) {
// Normalize hat inputs by clearing the center and conflicting directions.
let pressed_count = directions
.iter()
.filter(|&&index| self.buttons[index].pressed)
@ -236,6 +238,7 @@ impl ButtonManager {
/// Get a change event for a button: Some(true)=press, Some(false)=release, None=no change.
fn get_button_press_event(&self, button_index: usize) -> Option<bool> {
// Report the updated pressed state whenever it differs from the previous sample.
let button = &self.buttons[button_index];
if button.pressed != button.previous_pressed {
Some(button.pressed)
@ -265,6 +268,7 @@ impl ButtonManager {
/// - Short press: on release (and if long not activated), activate `usb_button`
/// - USB press lifetime: autorelease after a minimal hold so the host sees a pulse
fn update_button_press_type(button: &mut Button, current_time: u32) {
// Transition a single button between short/long press USB outputs.
const LONG_PRESS_THRESHOLD: u32 = 200;
// Pressing button
@ -334,12 +338,14 @@ mod tests {
#[test]
fn test_button_manager_creation() {
// Button manager should allocate an entry for every physical button.
let manager = ButtonManager::new();
assert_eq!(manager.buttons.len(), TOTAL_BUTTONS);
}
#[test]
fn test_button_default_state() {
// Default button instances start unpressed with no lingering USB state.
let button = Button::default();
assert!(!button.pressed);
assert!(!button.previous_pressed);
@ -349,6 +355,7 @@ mod tests {
#[test]
fn test_special_action_combinations() {
// The bootloader combo should trigger when all required buttons are pressed.
let mut manager = ButtonManager::new();
// Test bootloader combination
@ -362,6 +369,7 @@ mod tests {
#[test]
fn test_calibration_combination() {
// Calibration combo should generate the start-calibration action.
let mut manager = ButtonManager::new();
// Test calibration combination
@ -375,6 +383,7 @@ mod tests {
#[test]
fn test_throttle_hold_center() {
// Throttle hold combo should capture the centered axis value when centered.
let mut manager = ButtonManager::new();
manager.buttons[TH_BUTTON].pressed = true;
manager.buttons[TH_BUTTON].previous_pressed = false;
@ -385,6 +394,7 @@ mod tests {
#[test]
fn test_throttle_hold_value() {
// Off-center throttle hold should capture the live axis value for hold.
let mut manager = ButtonManager::new();
manager.buttons[TH_BUTTON].pressed = true;
manager.buttons[TH_BUTTON].previous_pressed = false;
@ -396,6 +406,7 @@ mod tests {
#[test]
fn test_virtual_throttle_toggle() {
// Virtual throttle button should emit the toggle action when pressed.
let mut manager = ButtonManager::new();
manager.buttons[VT_BUTTON].pressed = true;
manager.buttons[VT_BUTTON].previous_pressed = false;
@ -406,6 +417,7 @@ mod tests {
#[test]
fn test_hat_switch_filtering_left() {
// Left hat should clear center and conflicting directions when multiple inputs are active.
let mut manager = ButtonManager::new();
// Press multiple directional buttons on left hat
@ -424,6 +436,7 @@ mod tests {
#[test]
fn test_hat_switch_filtering_right() {
// Right hat should behave the same way, disabling conflicts and the center button.
let mut manager = ButtonManager::new();
// Press multiple directional buttons on right hat
@ -442,6 +455,7 @@ mod tests {
#[test]
fn test_hat_center_button_filtering() {
// Pressing a direction should suppress the corresponding hat center button.
let mut manager = ButtonManager::new();
// Press directional button and center button
@ -458,6 +472,7 @@ mod tests {
#[test]
fn test_hat_switch_single_direction_allowed() {
// A single direction press must remain active for both hats.
let mut manager = ButtonManager::new();
// Press only one directional button on left hat
@ -480,6 +495,7 @@ mod tests {
#[test]
fn test_hat_center_button_works_alone() {
// When no direction is pressed, the center button should report as pressed.
let mut manager = ButtonManager::new();
// Press only center button (no directions)
@ -493,6 +509,7 @@ mod tests {
#[test]
fn test_button_press_type_short_press() {
// Short presses should emit the primary USB button and flag a USB change.
let mut button = Button::default();
button.usb_button = 1;
button.enable_long_press = false;
@ -513,6 +530,7 @@ mod tests {
#[test]
fn test_button_press_type_long_press() {
// Long presses should switch to the alternate USB button and mark handled state.
let mut button = Button::default();
button.usb_button = 1;
button.usb_button_long = 2;
@ -533,6 +551,7 @@ mod tests {
#[test]
fn test_button_press_type_long_press_auto_release_once() {
// Non-hold long presses should auto-release once after triggering the long press.
let mut button = Button::default();
button.usb_button_long = 2;
button.enable_long_press = true;
@ -564,6 +583,7 @@ mod tests {
#[test]
fn test_timer_integration_method_exists() {
// Document that the timer-backed helper stays callable without hardware wiring.
let manager = ButtonManager::new();
// This test verifies the timer integration method signature and basic functionality

View File

@ -176,6 +176,7 @@ impl CalibrationManager {
/// Reset each axis calibration to its current smoothed center.
fn reset_axis_calibration(
// Recenter all axis calibration values using current smoother readings.
&self,
axes: &mut [GimbalAxis; NBR_OF_GIMBAL_AXIS],
smoothers: &[DynamicSmootherEcoI32; NBR_OF_GIMBAL_AXIS],
@ -191,6 +192,7 @@ impl CalibrationManager {
impl Default for CalibrationManager {
fn default() -> Self {
// Construct a calibration manager using the Default implementation.
Self::new()
}
}
@ -205,6 +207,7 @@ mod tests {
#[test]
fn test_calibration_manager_creation() {
// Report whether calibration is currently active.
let manager = CalibrationManager::new();
assert!(!manager.is_active());
assert_eq!(manager.get_gimbal_mode(), GIMBAL_MODE_M10);
@ -212,6 +215,7 @@ mod tests {
#[test]
fn test_calibration_state_management() {
// Start and stop transitions should flip the active flag accordingly.
let mut manager = CalibrationManager::new();
// Initially inactive
@ -228,6 +232,7 @@ mod tests {
#[test]
fn test_gimbal_mode_management() {
// Manual mode setter should swap between M10 and M7 without side effects.
let mut manager = CalibrationManager::new();
// Default mode
@ -244,6 +249,7 @@ mod tests {
#[test]
fn test_dynamic_calibration_inactive() {
// Inactive calibration should ignore updates and leave min/max untouched.
let manager = CalibrationManager::new();
let mut axes = [GimbalAxis::new(); NBR_OF_GIMBAL_AXIS];
@ -271,6 +277,7 @@ mod tests {
#[test]
fn test_dynamic_calibration_active() {
// Active calibration should track new lows/highs as smoothed values change.
let mut manager = CalibrationManager::new();
manager.start_calibration();
@ -319,6 +326,7 @@ mod tests {
#[test]
fn test_mode_selection_inactive() {
// Mode change commands should fail when calibration mode is not active.
let mut manager = CalibrationManager::new();
let mut axes = [GimbalAxis::new(); NBR_OF_GIMBAL_AXIS];
@ -341,6 +349,7 @@ mod tests {
#[test]
fn test_load_axis_calibration_success() {
// Successful EEPROM reads should populate the axis calibration tuple.
let mut axes = [GimbalAxis::new(); NBR_OF_GIMBAL_AXIS];
// Mock EEPROM data simulating successful calibration read
@ -373,6 +382,7 @@ mod tests {
#[test]
fn test_load_axis_calibration_failure() {
// Failed EEPROM reads should leave default calibration values intact.
let mut axes = [GimbalAxis::new(); NBR_OF_GIMBAL_AXIS];
let original_axes = axes;
@ -391,6 +401,7 @@ mod tests {
#[test]
fn test_load_gimbal_mode_success() {
// Reading the stored gimbal mode should return the persisted value.
// Mock successful EEPROM read for M7 mode
let mut read_fn = |addr: u32| {
match addr {
@ -405,6 +416,7 @@ mod tests {
#[test]
fn test_load_gimbal_mode_failure() {
// Read failures should fall back to the default M10 mode.
// Mock EEPROM read failure
let mut read_fn = |_addr: u32| Err(());
@ -414,6 +426,7 @@ mod tests {
#[test]
fn test_update_calibration_inactive() {
// When calibration is inactive, mode/set/save helpers should be no-ops.
let mut manager = CalibrationManager::new();
let mut axes = [GimbalAxis::new(); NBR_OF_GIMBAL_AXIS];
@ -442,6 +455,7 @@ mod tests {
#[test]
fn test_process_mode_selection_m10_command() {
// Active M10 command should set mode and recenter axes using smoother values.
let mut manager = CalibrationManager::new();
manager.start_calibration();
let mut axes = [GimbalAxis::new(); NBR_OF_GIMBAL_AXIS];
@ -469,6 +483,7 @@ mod tests {
#[test]
fn test_process_mode_selection_m7_command() {
// Active M7 command should likewise set mode and recenter axes.
let mut manager = CalibrationManager::new();
manager.start_calibration();
let mut axes = [GimbalAxis::new(); NBR_OF_GIMBAL_AXIS];
@ -496,6 +511,7 @@ mod tests {
#[test]
fn test_save_calibration_command() {
// Successful saves should write EEPROM data and exit calibration mode.
let mut manager = CalibrationManager::new();
manager.start_calibration();
let axes = [GimbalAxis::new(); NBR_OF_GIMBAL_AXIS];
@ -515,6 +531,7 @@ mod tests {
#[test]
fn test_save_calibration_failure_keeps_active() {
// Write failures should keep calibration active for retrials.
let mut manager = CalibrationManager::new();
manager.start_calibration();
let axes = [GimbalAxis::new(); NBR_OF_GIMBAL_AXIS];
@ -529,6 +546,7 @@ mod tests {
#[test]
fn test_save_calibration_inactive() {
// Save attempts while inactive should no-op without touching storage.
let mut manager = CalibrationManager::new(); // Note: not starting calibration
let axes = [GimbalAxis::new(); NBR_OF_GIMBAL_AXIS];

View File

@ -79,6 +79,7 @@ mod tests {
#[test]
fn test_generate_expo_lut_boundaries() {
// Ensure expo LUT boundary entries clamp to ADC range.
let lut = generate_expo_lut(0.5);
assert_eq!(lut[0], ADC_MIN);
assert_eq!(lut[ADC_MAX as usize], ADC_MAX);
@ -86,6 +87,7 @@ mod tests {
#[test]
fn test_generate_expo_lut_center_point() {
// Midpoint of the expo LUT stays near the physical center.
let lut = generate_expo_lut(0.5);
let center_index = (ADC_MAX / 2) as usize;
let center_value = lut[center_index];
@ -94,6 +96,7 @@ mod tests {
#[test]
fn test_generate_expo_lut_different_factors() {
// Different expo factors should yield distinct transfer functions at the same index.
let lut_linear = generate_expo_lut(0.0);
let lut_expo = generate_expo_lut(1.0);
let quarter_point = (ADC_MAX / 4) as usize;
@ -102,6 +105,7 @@ mod tests {
#[test]
fn test_apply_expo_curve_no_expo() {
// With zero expo factor the lookup table should behave linearly.
let lut = generate_expo_lut(0.0);
let input_value = 1000u16;
let result = apply_expo_curve(input_value, &lut);
@ -111,6 +115,7 @@ mod tests {
#[test]
fn test_apply_expo_curve_with_expo() {
// Non-zero expo factor should change outputs relative to the linear LUT.
let lut_linear = generate_expo_lut(0.0);
let lut_expo = generate_expo_lut(0.5);
let test_value = 1000u16;
@ -123,6 +128,7 @@ mod tests {
#[test]
fn test_apply_expo_curve_center_unchanged() {
// Expo mapping should leave the center point near the mechanical center.
let lut = generate_expo_lut(0.5);
let result = apply_expo_curve(AXIS_CENTER, &lut);
// Center point should remain close to center
@ -131,21 +137,25 @@ mod tests {
#[test]
fn test_constrain_within_range() {
// Values inside limits should be returned unchanged by constrain.
assert_eq!(constrain(50u16, 0u16, 100u16), 50u16);
}
#[test]
fn test_constrain_above_range() {
// Values above the upper bound should clamp to the max.
assert_eq!(constrain(150u16, 0u16, 100u16), 100u16);
}
#[test]
fn test_constrain_below_range() {
// Values below the lower bound should clamp to the min.
assert_eq!(constrain(0u16, 50u16, 100u16), 50u16);
}
#[test]
fn test_expo_integration_real_world_values() {
// Representative axis values should always map within the ADC domain.
let lut = generate_expo_lut(0.3);
let test_values = [500u16, 1000u16, 2000u16, 3000u16];

View File

@ -43,6 +43,7 @@ impl UsbState {
}
pub fn on_poll(&mut self) {
// Called on every USB poll; mark device active and wake from idle.
if !self.initialized {
self.initialized = true;
}
@ -53,6 +54,7 @@ impl UsbState {
}
pub fn mark_activity(&mut self) {
// Flag that input activity occurred and reset idle counters.
self.activity = true;
self.activity_elapsed_ms = 0;
self.idle_mode = false;
@ -60,6 +62,7 @@ impl UsbState {
}
pub fn handle_input_activity(&mut self) {
// Treat input changes as activity and request wake from suspend.
self.mark_activity();
if self.suspended && self.wake_on_input {
self.wake_on_input = false;
@ -67,6 +70,7 @@ impl UsbState {
}
pub fn on_suspend_change(&mut self, state: UsbDeviceState) {
// Update suspend bookkeeping when USB state changes.
let was_suspended = self.suspended;
self.suspended = state == UsbDeviceState::Suspend;
@ -86,6 +90,7 @@ impl UsbState {
}
pub fn advance_idle_timer(&mut self, interval_ms: u32) {
// Age the activity timer, transitioning to idle after the timeout.
if !self.activity {
return;
}
@ -99,6 +104,7 @@ impl UsbState {
}
pub fn acknowledge_report(&mut self) {
// Reset pending state once the host accepts a report.
self.send_pending = false;
}
}
@ -116,6 +122,7 @@ pub struct JoystickState {
impl JoystickState {
pub fn new() -> Self {
// Initialize managers, smoothers, expo curves, and USB state.
Self {
axis_manager: AxisManager::new(),
button_manager: ButtonManager::new(),
@ -132,6 +139,7 @@ impl JoystickState {
where
R: FnMut(u32) -> Result<u8, ()>,
{
// Fetch stored axis calibration and gimbal mode from persistent storage.
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);
@ -149,6 +157,7 @@ impl JoystickState {
L::Error: Debug,
R::Error: Debug,
{
// Refresh matrix-driven and discrete button inputs, then normalize hats.
self.button_manager.update_from_matrix(matrix);
self.button_manager
.update_extra_buttons(left_button, right_button);
@ -156,10 +165,12 @@ impl JoystickState {
}
pub fn finalize_button_logic(&mut self, timer: &Timer) -> bool {
// Update per-button timers and USB state using the shared hardware timer.
self.button_manager.process_button_logic_with_timer(timer)
}
pub fn check_special_action(&self) -> SpecialAction {
// Inspect button state for bootloader/calibration/hold command combinations.
self.button_manager.check_special_combinations(
self.axis_manager.get_value_before_hold(),
self.calibration_manager.is_active(),
@ -170,6 +181,7 @@ impl JoystickState {
where
W: FnMut(u32, &[u8]) -> Result<(), ()>,
{
// Execute the requested special action, updating calibration/throttle state as needed.
match action {
SpecialAction::Bootloader => {}
SpecialAction::StartCalibration => {

View File

@ -20,6 +20,7 @@ pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080;
#[rp2040_hal::entry]
fn main() -> ! {
// Firmware entry point initializes hardware before handing off to RTIC.
let BoardParts {
mut button_matrix,
mut status_led,
@ -33,6 +34,7 @@ fn main() -> ! {
usb_bus,
} = Board::new().into_parts();
// Build the HID joystick class on the shared USB bus.
let mut usb_hid_joystick = UsbHidClassBuilder::new()
.add_device(JoystickConfig::default())
.build(usb_bus);
@ -56,10 +58,12 @@ fn main() -> ! {
}
{
// Load persisted calibration values from EEPROM if available.
let mut read_fn = |addr: u32| eeprom.read_byte(addr).map_err(|_| ());
state.load_calibration(&mut read_fn);
}
// Set up periodic timers for scanning, status updates, and USB activity.
let mut scan_tick = timer.count_down();
scan_tick.start(timers::SCAN_INTERVAL_US.micros());
@ -71,7 +75,9 @@ fn main() -> ! {
let mut status_time_ms: u32 = 0;
// Main control loop: service USB, process inputs, and emit reports.
loop {
// Service the USB stack and HID class when data is pending.
if usb_dev.poll(&mut [&mut usb_hid_joystick]) {
state.usb_state().on_poll();
}
@ -79,11 +85,13 @@ fn main() -> ! {
let usb_state = usb_dev.state();
state.usb_state().on_suspend_change(usb_state);
// Periodically refresh the status LED animation.
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);
}
// Slow the scan cadence when USB is suspended to save power.
let should_scan = if state.usb_state().suspended {
static mut SUSPENDED_SCAN_COUNTER: u8 = 0;
unsafe {
@ -95,6 +103,7 @@ fn main() -> ! {
};
if should_scan && scan_tick.wait().is_ok() {
// Scan buttons, read analog axes, and update state machines.
button_matrix.scan_matrix(&mut delay);
let mut raw_values = [
@ -111,6 +120,7 @@ fn main() -> ! {
&mut right_extra_button,
);
// Evaluate special button combinations (bootloader, calibration, etc.).
let action = state.check_special_action();
if matches!(action, SpecialAction::Bootloader) {
if !state.usb_state().suspended {
@ -132,6 +142,7 @@ fn main() -> ! {
state.handle_special_action(action, &mut write_page);
}
// Track calibration extrema and process axis/virtual/button logic.
state.update_calibration_tracking();
if state.process_axes() {
@ -147,6 +158,7 @@ fn main() -> ! {
}
}
// Advance USB idle timers and decide when to send reports.
let usb_tick_elapsed = usb_tick.wait().is_ok();
if usb_tick_elapsed {
state
@ -154,6 +166,7 @@ fn main() -> ! {
.advance_idle_timer(timers::USB_UPDATE_INTERVAL_MS);
}
// Emit a new HID report when activity is pending and USB is ready.
if state.usb_state().activity
&& (usb_tick_elapsed || state.usb_state().send_pending)
&& !state.usb_state().suspended

View File

@ -171,6 +171,7 @@ mod tests {
#[test]
fn front_buttons_have_expected_mappings() {
// Front panel buttons map to the expected USB button ids.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
configure_button_mappings(&mut buttons);
@ -181,6 +182,7 @@ mod tests {
#[test]
fn long_press_flags_set_correctly() {
// Long-press flags are configured for buttons that need them at runtime.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
configure_button_mappings(&mut buttons);
@ -192,6 +194,7 @@ mod tests {
#[test]
fn hat_buttons_map_to_expected_ids() {
// Hat direction buttons should map to the numerical HID hat constants.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
configure_button_mappings(&mut buttons);

View File

@ -67,6 +67,7 @@ const HEARTBEAT_IDLE_MS: u32 = 3200;
impl LedEffect {
fn update_interval_ms(self) -> u32 {
// Resolve the LED descriptor for the requested status mode.
match self {
LedEffect::Solid => 0,
LedEffect::Blink { period_ms } => period_ms / 2,
@ -75,6 +76,7 @@ impl LedEffect {
}
fn color_for(self, base: RGB8, elapsed_ms: u32) -> RGB8 {
// Compute the base status mode given system state flags.
match self {
LedEffect::Solid => base,
LedEffect::Blink { period_ms } => {
@ -197,6 +199,7 @@ const fn descriptor_for(mode: StatusMode, base_mode: StatusMode) -> ModeDescript
}
fn scale_color(base: RGB8, brightness: u8) -> RGB8 {
// Scale each RGB component proportionally to the requested brightness factor.
let scale = brightness as u16;
RGB8 {
r: ((base.r as u16 * scale) / 255) as u8,
@ -206,6 +209,7 @@ fn scale_color(base: RGB8, brightness: u8) -> RGB8 {
}
fn determine_base_mode(system_state: SystemState) -> StatusMode {
// Compute the base status mode based on USB/calibration/idle state.
if system_state.usb_suspended {
StatusMode::Suspended
} else if system_state.calibration_active {
@ -331,6 +335,7 @@ where
/// Write a single color to the LED.
fn write_color(&mut self, color: RGB8) {
// Push the color to the WS2812 LED, ignoring transient IO errors.
let _ = self.ws2812_direct.write([color].iter().copied());
}
}
@ -354,6 +359,7 @@ mod tests {
#[test]
fn idle_mode_uses_base_color_with_heartbeat() {
// Idle state should inherit the base color while enforcing a heartbeat pattern.
let state = SystemState {
usb_active: false,
usb_initialized: true,
@ -376,6 +382,7 @@ mod tests {
#[test]
fn power_mode_uses_fast_heartbeat() {
// Power mode descriptor should use the fast heartbeat cadence and green.
let descriptor = descriptor_for(StatusMode::Power, StatusMode::Normal);
if let LedEffect::Heartbeat { period_ms } = descriptor.effect {
assert_eq!(period_ms, HEARTBEAT_POWER_MS);
@ -387,6 +394,7 @@ mod tests {
#[test]
fn calibration_has_priority_over_idle() {
// Calibration activity should override idle when both flags are set.
let state = SystemState {
usb_active: true,
usb_initialized: true,
@ -403,6 +411,7 @@ mod tests {
#[test]
fn heartbeat_effect_fades() {
// Heartbeat should ramp up and down around the target color.
let base = StatusMode::Normal;
let descriptor = descriptor_for(StatusMode::Idle, base);
let LedEffect::Heartbeat { period_ms } = descriptor.effect else {
@ -422,6 +431,7 @@ mod tests {
#[test]
fn blink_effect_toggles() {
// Blink descriptor should alternate between the color and off state.
let descriptor = descriptor_for(StatusMode::NormalFlash, StatusMode::NormalFlash);
let LedEffect::Blink { period_ms } = descriptor.effect else {
panic!("NormalFlash should use blink effect");
@ -435,6 +445,7 @@ mod tests {
#[test]
fn determine_base_mode_before_usb() {
// Before USB comes up the controller should stay in Power mode.
let state = SystemState {
usb_active: false,
usb_initialized: false,
@ -450,6 +461,7 @@ mod tests {
#[test]
fn usb_suspend_takes_priority() {
// USB suspend should trump other status priorities.
let state = SystemState {
usb_active: true,
usb_initialized: true,

View File

@ -79,6 +79,7 @@ pub fn write_calibration_data(
/// Read a u16 value from EEPROM in littleendian (low then high byte) format.
fn read_u16_with_closure(
// Fetch a little-endian u16 by reading two consecutive EEPROM bytes.
read_byte_fn: &mut dyn FnMut(u32) -> Result<u8, ()>,
low_addr: u32,
high_addr: u32,
@ -171,6 +172,7 @@ mod tests {
#[test]
fn test_boundary_values() {
// Pack axis tuples into EEPROM layout and write the gimbal mode byte.
let mut buffer = [0u8; 4];
// Test minimum value (manual packing)

View File

@ -37,6 +37,7 @@ pub trait Try {
type Ok;
type Error;
fn into_result(self) -> Result<Self::Ok, Self::Error>;
// Trait shim replicating `core::Try` for use in no_std contexts.
}
impl<T> Try for Option<T> {
@ -45,6 +46,7 @@ impl<T> Try for Option<T> {
#[inline]
fn into_result(self) -> Result<T, NoneError> {
// Convert an optional value into a result with a unit error.
self.ok_or(NoneError)
}
}
@ -55,6 +57,7 @@ impl<T, E> Try for Result<T, E> {
#[inline]
fn into_result(self) -> Self {
// `Result` already matches the desired signature; return it untouched.
self
}
}
@ -165,12 +168,16 @@ impl<'a, B: UsbBus> DeviceClass<'a> for Joystick<'a, B> {
type I = Interface<'a, B, InBytes32, OutNone, ReportSingle>;
fn interface(&mut self) -> &mut Self::I {
// Expose the HID interface so the USB stack can enqueue reports.
&mut self.interface
}
fn reset(&mut self) {}
fn reset(&mut self) {
// Nothing to reset for this simple HID device.
}
fn tick(&mut self) -> Result<(), UsbHidError> {
// Flush pending HID data and poll the USB stack for new requests.
Ok(())
}
}
@ -181,6 +188,7 @@ pub struct JoystickConfig<'a> {
impl Default for JoystickConfig<'_> {
fn default() -> Self {
// Construct the HID interface with the default joystick descriptor and endpoints.
Self::new(
unwrap!(unwrap!(InterfaceBuilder::new(JOYSTICK_DESCRIPTOR))
.boot_device(InterfaceProtocol::None)
@ -203,6 +211,7 @@ impl<'a, B: UsbBus + 'a> UsbAllocatable<'a, B> for JoystickConfig<'a> {
type Allocated = Joystick<'a, B>;
fn allocate(self, usb_alloc: &'a UsbBusAllocator<B>) -> Self::Allocated {
// Allocate the HID interface using the provided USB bus allocator.
Self::Allocated {
interface: Interface::new(usb_alloc, self.interface),
}

View File

@ -184,6 +184,7 @@ mod tests {
#[test]
fn test_joystick_report_basic_axes() {
// Remap helper scales values between integer ranges with clamping.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
let mut axes = [GimbalAxis::new(); 4];
@ -214,6 +215,7 @@ mod tests {
#[test]
fn test_virtual_throttle_mode() {
// Slider combines throttle hold and virtual throttle for report serialization.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
let mut axes = [GimbalAxis::new(); 4];
@ -237,6 +239,7 @@ mod tests {
#[test]
fn test_virtual_throttle_below_center() {
// When VT mode is enabled below center, slider should invert along the left half of travel.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
let mut axes = [GimbalAxis::new(); 4];
@ -261,6 +264,7 @@ mod tests {
#[test]
fn test_button_mapping_regular_buttons() {
// Regular buttons should set their HID bitmask without disturbing hat state.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
let mut axes = [GimbalAxis::new(); 4];
@ -285,6 +289,7 @@ mod tests {
#[test]
fn test_hat_switch_mapping() {
// A single hat direction should map to the appropriate HID hat value.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
let mut axes = [GimbalAxis::new(); 4];
@ -307,6 +312,7 @@ mod tests {
#[test]
fn test_long_press_button_handling() {
// Buttons configured for long press should surface the alternate USB id.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
let mut axes = [GimbalAxis::new(); 4];
@ -327,6 +333,7 @@ mod tests {
#[test]
fn test_usb_changed_flag_reset() {
// Packaging a report should clear the usb_changed flags once consumed.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
let mut axes = [GimbalAxis::new(); 4];
@ -347,6 +354,7 @@ mod tests {
#[test]
fn test_edge_case_hat_values() {
// Additional hat directions should map to the correct encoded value.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
let mut axes = [GimbalAxis::new(); 4];
@ -368,6 +376,7 @@ mod tests {
#[test]
fn test_multiple_buttons_and_hat() {
// Report should accommodate simultaneous button presses and hat direction.
let mut buttons = [Button::default(); TOTAL_BUTTONS];
let mut axes = [GimbalAxis::new(); 4];