saltylab-firmware/src/encoder_odom.c
sl-controls 779f9d00e2 feat: Encoder odometry and wheel speed feedback (Issue #632)
- TIM2 (32-bit) left encoder, TIM3 (16-bit) right encoder in mode 3
- RPM calculation with int16 clamp; 16-bit wrap handled via signed delta
- Differential-drive odometry: x/y/theta Euler-forward integration
- Flash config (sector 7, 0x0807FF00) for ticks_per_rev/wheel_diam/base
- JLINK_TLM_ODOM (0x8C) at 50 Hz: rpm_l/r, x_mm, y_mm, theta_cdeg, speed_mmps
- 75/75 unit tests passing (TEST_HOST build)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 16:34:38 -04:00

348 lines
10 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* encoder_odom.c — quadrature encoder reading and differential-drive
* odometry for Issue #632.
*
* TIM2 (32-bit) = left encoder, TIM3 (16-bit) = right encoder.
* Both configured in encoder mode 3 (count on both A and B edges × 4).
*
* RPM formula:
* rpm = delta_ticks * 60 / (ticks_per_rev * dt_s)
*
* Odometry (Euler-forward differential drive):
* d_l = delta_left * meters_per_tick
* d_r = delta_right * meters_per_tick
* d_c = (d_l + d_r) / 2
* dθ = (d_r - d_l) / wheel_base_m
* x += d_c * cos(θ)
* y += d_c * sin(θ)
* θ += dθ
*/
#include "encoder_odom.h"
#include "jlink.h"
#include <math.h>
#include <string.h>
/* ---- Platform abstraction (stubbed in TEST_HOST builds) ---- */
#ifndef TEST_HOST
#include "stm32f7xx_hal.h"
#include "config.h"
/* Read 32-bit left encoder counter (TIM2) */
static inline uint32_t enc_read_left(void)
{
return (uint32_t)TIM2->CNT;
}
/* Read 16-bit right encoder counter (TIM3) */
static inline uint16_t enc_read_right(void)
{
return (uint16_t)TIM3->CNT;
}
/* Configure TIM2 in encoder mode (left wheel) */
static void enc_tim2_init(void)
{
/* Enable peripheral clock */
__HAL_RCC_TIM2_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_GPIOB_CLK_ENABLE();
/* PA15 = TIM2_CH1 (AF1), PB3 = TIM2_CH2 (AF1) — floating input */
GPIO_InitTypeDef g = {0};
g.Mode = GPIO_MODE_AF_PP;
g.Pull = GPIO_PULLUP;
g.Speed = GPIO_SPEED_FREQ_LOW;
g.Alternate = GPIO_AF1_TIM2;
g.Pin = GPIO_PIN_15;
HAL_GPIO_Init(GPIOA, &g);
g.Pin = GPIO_PIN_3;
HAL_GPIO_Init(GPIOB, &g);
/* Encoder mode 3: count on both TI1 and TI2 edges */
TIM2->CR1 = 0;
TIM2->SMCR = TIM_SMCR_SMS_0 | TIM_SMCR_SMS_1; /* SMS = 0x3 */
TIM2->CCMR1 = (TIM_CCMR1_CC1S_0 | TIM_CCMR1_CC2S_0); /* IC1→TI1, IC2→TI2 */
TIM2->CCER = 0; /* no polarity inversion (change CC1P/CC2P to reverse) */
TIM2->ARR = 0xFFFFFFFFUL;
TIM2->CNT = 0;
TIM2->CR1 |= TIM_CR1_CEN;
}
/* Configure TIM3 in encoder mode (right wheel) */
static void enc_tim3_init(void)
{
__HAL_RCC_TIM3_CLK_ENABLE();
__HAL_RCC_GPIOC_CLK_ENABLE();
/* PC6 = TIM3_CH1 (AF2), PC7 = TIM3_CH2 (AF2) */
GPIO_InitTypeDef g = {0};
g.Mode = GPIO_MODE_AF_PP;
g.Pull = GPIO_PULLUP;
g.Speed = GPIO_SPEED_FREQ_LOW;
g.Alternate = GPIO_AF2_TIM3;
g.Pin = GPIO_PIN_6 | GPIO_PIN_7;
HAL_GPIO_Init(GPIOC, &g);
TIM3->CR1 = 0;
TIM3->SMCR = TIM_SMCR_SMS_0 | TIM_SMCR_SMS_1;
TIM3->CCMR1 = (TIM_CCMR1_CC1S_0 | TIM_CCMR1_CC2S_0);
TIM3->CCER = 0;
TIM3->ARR = 0xFFFF;
TIM3->CNT = 0;
TIM3->CR1 |= TIM_CR1_CEN;
}
/* Flash helpers */
static bool flash_write_words(uint32_t addr, const void *data, uint32_t len)
{
const uint32_t *src = (const uint32_t *)data;
uint32_t words = (len + 3u) / 4u;
for (uint32_t i = 0; i < words; i++) {
if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD,
addr + i * 4u, src[i]) != HAL_OK) {
return false;
}
}
return true;
}
static bool flash_erase_sector7(void)
{
FLASH_EraseInitTypeDef erase = {
.TypeErase = FLASH_TYPEERASE_SECTORS,
.Sector = FLASH_SECTOR_7,
.NbSectors = 1,
.VoltageRange = FLASH_VOLTAGE_RANGE_3,
};
uint32_t err = 0;
return (HAL_FLASHEx_Erase(&erase, &err) == HAL_OK) &&
(err == 0xFFFFFFFFUL);
}
#else /* TEST_HOST stubs */
/* Test-controlled counter values */
static uint32_t g_enc_left_cnt = 0;
static uint16_t g_enc_right_cnt = 0;
static inline uint32_t enc_read_left(void) { return g_enc_left_cnt; }
static inline uint16_t enc_read_right(void) { return g_enc_right_cnt; }
static void enc_tim2_init(void) {}
static void enc_tim3_init(void) {}
static bool g_flash_erase_ok = true;
static bool g_flash_write_ok = true;
static enc_flash_config_t g_flash_store;
static bool g_flash_has_data = false;
static bool flash_erase_sector7(void) { return g_flash_erase_ok; }
static bool flash_write_words(uint32_t addr, const void *data, uint32_t len)
{
(void)addr;
if (!g_flash_write_ok) return false;
memcpy(&g_flash_store, data, len < sizeof(g_flash_store) ? len : sizeof(g_flash_store));
g_flash_has_data = true;
return true;
}
#endif /* TEST_HOST */
/* ---- Config defaults ---- */
static void cfg_set_defaults(enc_config_t *cfg)
{
cfg->ticks_per_rev = ENC_TICKS_PER_REV_DEFAULT;
cfg->wheel_diam_mm = ENC_WHEEL_DIAM_MM_DEFAULT;
cfg->wheel_base_mm = ENC_WHEEL_BASE_MM_DEFAULT;
}
/* ---- Pre-compute derived constants from config ---- */
static void enc_update_precomputed(encoder_odom_t *eo)
{
eo->meters_per_tick = (3.14159265358979f * (float)eo->cfg.wheel_diam_mm * 0.001f)
/ (float)eo->cfg.ticks_per_rev;
eo->wheel_base_m = (float)eo->cfg.wheel_base_mm * 0.001f;
}
/* ---- encoder_odom_load_config() ---- */
bool encoder_odom_load_config(enc_config_t *cfg)
{
#ifndef TEST_HOST
const enc_flash_config_t *p = (const enc_flash_config_t *)ENC_FLASH_ADDR;
if (p->magic == ENC_FLASH_MAGIC &&
p->ticks_per_rev > 0u &&
p->wheel_diam_mm > 0u &&
p->wheel_base_mm > 0u)
{
cfg->ticks_per_rev = p->ticks_per_rev;
cfg->wheel_diam_mm = p->wheel_diam_mm;
cfg->wheel_base_mm = p->wheel_base_mm;
return true;
}
#else
if (g_flash_has_data && g_flash_store.magic == ENC_FLASH_MAGIC &&
g_flash_store.ticks_per_rev > 0u &&
g_flash_store.wheel_diam_mm > 0u &&
g_flash_store.wheel_base_mm > 0u)
{
cfg->ticks_per_rev = g_flash_store.ticks_per_rev;
cfg->wheel_diam_mm = g_flash_store.wheel_diam_mm;
cfg->wheel_base_mm = g_flash_store.wheel_base_mm;
return true;
}
#endif
cfg_set_defaults(cfg);
return false;
}
/* ---- encoder_odom_save_config() ---- */
bool encoder_odom_save_config(const enc_config_t *cfg)
{
enc_flash_config_t rec;
memset(&rec, 0xFF, sizeof(rec));
rec.magic = ENC_FLASH_MAGIC;
rec.ticks_per_rev = cfg->ticks_per_rev;
rec.wheel_diam_mm = cfg->wheel_diam_mm;
rec.wheel_base_mm = cfg->wheel_base_mm;
#ifndef TEST_HOST
HAL_FLASH_Unlock();
bool ok = flash_erase_sector7() &&
flash_write_words(ENC_FLASH_ADDR, &rec, sizeof(rec));
HAL_FLASH_Lock();
return ok;
#else
return flash_write_words(ENC_FLASH_ADDR, &rec, sizeof(rec));
#endif
}
/* ---- encoder_odom_init() ---- */
void encoder_odom_init(encoder_odom_t *eo)
{
memset(eo, 0, sizeof(*eo));
/* Load or default config */
encoder_odom_load_config(&eo->cfg);
enc_update_precomputed(eo);
/* Initialize timers */
enc_tim2_init();
enc_tim3_init();
/* Snapshot initial counter values (treat as zero reference) */
eo->cnt_left = enc_read_left();
eo->cnt_right = enc_read_right();
/* Initialize TLM timer so first call fires immediately */
eo->last_tlm_ms = (uint32_t)(-(uint32_t)(1000u / (ENC_TLM_HZ > 0u ? ENC_TLM_HZ : 1u)));
}
/* ---- encoder_odom_reset_pose() ---- */
void encoder_odom_reset_pose(encoder_odom_t *eo)
{
eo->x_mm = 0.0f;
eo->y_mm = 0.0f;
eo->theta_rad = 0.0f;
}
/* ---- encoder_odom_tick() ---- */
void encoder_odom_tick(encoder_odom_t *eo, uint32_t now_ms)
{
/* Compute dt */
uint32_t elapsed_ms = now_ms - eo->last_tick_ms;
if (elapsed_ms == 0u) return; /* avoid divide-by-zero at high call rates */
if (elapsed_ms > 500u) elapsed_ms = 500u; /* clamp: stale data guard */
float dt_s = (float)elapsed_ms * 0.001f;
eo->last_tick_ms = now_ms;
/* Read counters */
uint32_t new_left = enc_read_left();
uint16_t new_right = enc_read_right();
/* Signed delta — handles 32/16-bit wrap correctly */
int32_t delta_left = (int32_t)(new_left - eo->cnt_left);
int16_t delta_right = (int16_t)(new_right - eo->cnt_right);
eo->cnt_left = new_left;
eo->cnt_right = (uint16_t)new_right;
/* RPM */
float rpm_scale = 60.0f / ((float)eo->cfg.ticks_per_rev * dt_s);
float rpm_l = (float)delta_left * rpm_scale;
float rpm_r = (float)delta_right * rpm_scale;
/* Clamp to int16 range */
if (rpm_l > 32767.0f) rpm_l = 32767.0f;
if (rpm_l < -32768.0f) rpm_l = -32768.0f;
if (rpm_r > 32767.0f) rpm_r = 32767.0f;
if (rpm_r < -32768.0f) rpm_r = -32768.0f;
eo->rpm_left = (int16_t)rpm_l;
eo->rpm_right = (int16_t)rpm_r;
/* Wheel displacements (metres) */
float d_left = (float)delta_left * eo->meters_per_tick;
float d_right = (float)delta_right * eo->meters_per_tick;
/* Linear / angular increments */
float d_center = (d_left + d_right) * 0.5f;
float d_theta = (d_right - d_left) / eo->wheel_base_m;
/* Linear speed (mm/s), clamped to int16 */
float speed_raw = (d_center / dt_s) * 1000.0f; /* m/s → mm/s */
if (speed_raw > 32767.0f) speed_raw = 32767.0f;
if (speed_raw < -32768.0f) speed_raw = -32768.0f;
eo->speed_mmps = (int16_t)speed_raw;
/* Integrate pose (Euler-forward) */
float cos_h = cosf(eo->theta_rad);
float sin_h = sinf(eo->theta_rad);
eo->x_mm += d_center * cos_h * 1000.0f;
eo->y_mm += d_center * sin_h * 1000.0f;
eo->theta_rad += d_theta;
/* Wrap theta to (-π, π] */
while (eo->theta_rad > 3.14159265358979f) eo->theta_rad -= 2.0f * 3.14159265358979f;
while (eo->theta_rad < -3.14159265358979f) eo->theta_rad += 2.0f * 3.14159265358979f;
}
/* ---- encoder_odom_send_tlm() ---- */
void encoder_odom_send_tlm(const encoder_odom_t *eo, uint32_t now_ms)
{
if (ENC_TLM_HZ == 0u) return;
uint32_t interval_ms = 1000u / ENC_TLM_HZ;
if ((now_ms - eo->last_tlm_ms) < interval_ms) return;
/* Cast away const for timestamp — only mutable field */
((encoder_odom_t *)eo)->last_tlm_ms = now_ms;
jlink_tlm_odom_t tlm;
tlm.rpm_left = eo->rpm_left;
tlm.rpm_right = eo->rpm_right;
/* x/y: float mm → int32_t mm (saturate at ±2147 km — more than adequate) */
float xf = eo->x_mm;
float yf = eo->y_mm;
if (xf > 2147483647.0f) xf = 2147483647.0f;
if (xf < -2147483648.0f) xf = -2147483648.0f;
if (yf > 2147483647.0f) yf = 2147483647.0f;
if (yf < -2147483648.0f) yf = -2147483648.0f;
tlm.x_mm = (int32_t)xf;
tlm.y_mm = (int32_t)yf;
/* theta: radians → centidegrees (0.01° steps) */
float theta_cdeg = eo->theta_rad * (18000.0f / 3.14159265358979f);
if (theta_cdeg > 32767.0f) theta_cdeg = 32767.0f;
if (theta_cdeg < -32768.0f) theta_cdeg = -32768.0f;
tlm.theta_cdeg = (int16_t)theta_cdeg;
tlm.speed_mmps = eo->speed_mmps;
jlink_send_odom_tlm(&tlm);
}