armor: ESP32-C3 装甲板固件(一主三从)+ 烧录脚本,之前一直没提交

这一块代码本来就写好了、根 README 里也按"已实现"描述,但一直没有进仓库,
只存在于工作区里。同一个提交里带上 `.env.example` 的两个新变量(ARMOR_TEAM、
ARMOR_THRESHOLD)——它们的说明文字就是为这套固件写的。

内容:
- armor-protocol:规则层(血量、每板独立 50ms 受击冷却、受击后只有那一路灯带全黑、
  按键 FSM、上报编码)。纯计算,主机上 22 个测试,已跑通。
- armor-firmware:四路压电片 ADC 连续采样、四路 WS2812(C3 只有一个 RMT 通道可用,
  靠 rmt_tx_switch_gpio 轮流驱动)、NVS、BOOT 键、WiFi + UDP 广播。
- scripts:build_armor.sh / flash_armor.sh / armor_probe.py(不开界面单看广播)。

armor/ 是独立 workspace(目标 RISC-V 的 ESP32-C3),与外层 Xtensa 的图传固件互不相干,
各自 .cargo/config.toml 就是这条边界。构建产物由根 .gitignore 的 `/armor/target/` 覆盖,
本次没有把任何 target/ 或 build/ 下的东西加进来。

顺带把 `.DS_Store` 加进 .gitignore。
This commit is contained in:
2026-10-14 19:43:14 +08:00
parent 7fdebe3ec7
commit a30eb3200a
22 changed files with 4690 additions and 0 deletions
+312
View File
@@ -0,0 +1,312 @@
//! 四路 WS2812 灯带:一个 RMT TX 通道 + 官方 copy encoder,靠切 GPIO 轮流驱动。
//!
//! # 为什么不用 `esp-idf-hal` 的 `TxChannelDriver`
//!
//! 它把 `rmt_channel_handle_t` 藏在结构体里(`as_ptr()` 是 `pub(crate)`),而
//! "一个通道依次驱动四路灯带"必须用 `rmt_tx_switch_gpio()` 换脚。ESP32-C3 的 RMT
//! 只有 **2 个 TX 通道**,四路灯带不可能各占一个,所以这里直接用 `esp-idf-svc` 的
//! sys 绑定:通道、encoder、transmit 都是 IDF 原生的那几个函数,用法与参考实现的
//! `led_strip_encoder.c` 一一对应。
//!
//! # ESP32-C3 的 RMT 内存限制(照抄参考实现的踩坑记录)
//!
//! C3 的 RMT 只有一个 block,总计 **96 个 symbol 的通道内存**,按
//! `SOC_RMT_MEM_WORDS_PER_CHANNEL = 48` 为单位分配。`mem_block_symbols = 64` 会被
//! 向上取整到 96,也就是一个通道吃光全部内存,第二个 `rmt_new_tx_channel()` 会以
//! `no free tx channels` 失败:
//!
//! ```text
//! E rmt: rmt_tx_register_to_group(168): no free tx channels
//! ```
//!
//! 所以这里只建**一个**通道、让它独占 96 个 symbol(一帧 = 3 珠 × 24 bit + reset
//! = 73 个 symbol,装得下),再用 `rmt_tx_switch_gpio()` 依次切到四个 GPIO 上发送。
//! 每路约 90 µs,10 ms 周期里的开销可以忽略。
//!
//! # 与参考实现的差异
//!
//! 参考实现里四条灯带永远同色,所以每 10 ms 无条件刷四遍。这里四路颜色各自独立
//! (被打的那一路要单独变暗),于是:
//!
//! - 内容没变的路不发(`refresh` 里比较上一次的 [`Strip`]),省掉每次
//! `disable → switch → enable → transmit` 的四轮开销;
//! - 每秒仍强制全刷一次:灯带可能因为上电次序、掉电、静电而丢帧,缓存不能当真相。
use core::ffi::c_void;
use core::time::Duration;
use anyhow::Result;
use esp_idf_svc::hal::rmt::{PinState, Pulse, Symbol};
use esp_idf_svc::hal::units::Hertz;
use esp_idf_svc::sys as ffi;
use log::{info, warn};
use armor_protocol::{Plate, Rgb, Strip};
use crate::esp;
/// RMT 计时分辨率:10 MHz,即 1 tick = 0.1 µs。
const RMT_RESOLUTION_HZ: u32 = 10_000_000;
/// 每条灯带 3 颗灯珠(与参考实现一致)。
const LEDS_PER_STRIP: usize = 3;
/// 一帧的符号数:24 bit/珠 × 3 珠 + 1 个 reset 符号。
const FRAME_SYMBOLS: usize = LEDS_PER_STRIP * 24 + 1;
/// 独占 C3 全部 RMT 通道内存,只建一个通道(见模块文档)。
const MEM_BLOCK_SYMBOLS: usize = 96;
/// WS2812 的位时序:T0H / T0L / T1H / T1L。
const T0H_NS: u64 = 300;
const T0L_NS: u64 = 900;
const T1H_NS: u64 = 900;
const T1L_NS: u64 = 300;
/// 帧尾低电平时长;WS2812 要 >50 µs 才锁存。
const RESET_US: u64 = 50;
/// 灯带数据脚(GPIO 号,按 [`Plate`] 的 index 排列):前 10 / 后 5 / 左 6 / 右 7。
const STRIP_GPIO: [i32; 4] = [10, 5, 6, 7];
/// `rmt_tx_wait_all_done` 的超时(毫秒)。一帧只要 ~90 µs,1 s 是"卡死就报错"的量级。
const TX_TIMEOUT_MS: i32 = 1000;
/// 强制全刷的周期:缓存不可信(掉电、静电都可能让灯带丢帧)。
const FULL_REFRESH_MS: u64 = 1000;
/// 四路 WS2812。
pub struct Leds {
channel: ffi::rmt_channel_handle_t,
encoder: ffi::rmt_encoder_handle_t,
/// 通道当前是否处于 enable 状态;切 GPIO 只能在 INIT 状态下做。
enabled: bool,
bit0: Symbol,
bit1: Symbol,
reset: Symbol,
/// 每路的预编码帧。放在 `Box` 里:`rmt_transmit` 只借用指针,堆上的地址不会
/// 因为结构体搬动而失效。
frames: Box<[[ffi::rmt_symbol_word_t; FRAME_SYMBOLS]; 4]>,
/// 上一次真正发出去的内容,用来跳过没变化的刷新。
current: [Option<Strip>; 4],
last_full_refresh_ms: u64,
}
impl Leds {
/// 建通道与 encoder,把四路灯带全刷成黑色(上电先灭)。
pub fn new() -> Result<Self> {
let mut config = ffi::rmt_tx_channel_config_t::default();
// 先落在前装甲板那一路;其余三路靠 switch_gpio 换过去。
config.gpio_num = STRIP_GPIO[Plate::Front.index() as usize] as _;
config.clk_src = ffi::soc_periph_rmt_clk_src_t_RMT_CLK_SRC_DEFAULT;
config.resolution_hz = RMT_RESOLUTION_HZ;
config.mem_block_symbols = MEM_BLOCK_SYMBOLS;
config.trans_queue_depth = 4;
let mut channel: ffi::rmt_channel_handle_t = core::ptr::null_mut();
esp::check(
"rmt_new_tx_channel",
// SAFETY: config 与 channel 都是本函数的局部量,调用期间有效。
unsafe { ffi::rmt_new_tx_channel(&config, &mut channel) },
)?;
let mut encoder: ffi::rmt_encoder_handle_t = core::ptr::null_mut();
let copy_config = ffi::rmt_copy_encoder_config_t::default();
if let Err(err) = esp::check(
"rmt_new_copy_encoder",
// SAFETY: 同上,copy encoder 只是把用户给的 symbol 原样搬进硬件。
unsafe { ffi::rmt_new_copy_encoder(&copy_config, &mut encoder) },
) {
// SAFETY: 通道刚建好且未使用,可以直接删。
unsafe { ffi::rmt_del_channel(channel) };
return Err(err);
}
let resolution = Hertz(RMT_RESOLUTION_HZ);
let bit0 = Symbol::new_with(
resolution,
PinState::High,
Duration::from_nanos(T0H_NS),
PinState::Low,
Duration::from_nanos(T0L_NS),
)?;
let bit1 = Symbol::new_with(
resolution,
PinState::High,
Duration::from_nanos(T1H_NS),
PinState::Low,
Duration::from_nanos(T1L_NS),
)?;
let reset_low =
Pulse::new_with_duration(resolution, PinState::Low, Duration::from_micros(RESET_US))?;
// reset = 一整段(两半都是低电平)的符号。
let reset = Symbol::new(reset_low, reset_low);
// `rmt_symbol_word_t` 是 union,bindgen 不给它 `Default`,只能自己写一个全零符号。
let blank = ffi::rmt_symbol_word_t { val: 0 };
let mut leds = Self {
channel,
encoder,
enabled: false,
bit0,
bit1,
reset,
frames: Box::new([[blank; FRAME_SYMBOLS]; 4]),
current: [None; 4],
last_full_refresh_ms: 0,
};
info!(
"ws2812: 1 RMT channel x {MEM_BLOCK_SYMBOLS} symbols, {LEDS_PER_STRIP} leds/strip on GPIO {:?}",
STRIP_GPIO
);
// 上电先灭:不知道灯带上一次显示的是什么。
for plate in Plate::ALL {
leds.write(plate, Strip::Black)?;
}
Ok(leds)
}
/// 把预编码的帧发到指定那一路。
fn write(&mut self, plate: Plate, strip: Strip) -> Result<()> {
let slot = plate.index() as usize;
self.encode(slot, strip.color());
// 切 GPIO 只能在 INIT 状态下做,所以先 disable。
if self.enabled {
esp::check("rmt_disable", unsafe { ffi::rmt_disable(self.channel) })?;
self.enabled = false;
}
esp::check(
"rmt_tx_switch_gpio",
// SAFETY: 通道处于 INIT 状态;gpio 是 C3 上的合法输出脚。
unsafe { ffi::rmt_tx_switch_gpio(self.channel, STRIP_GPIO[slot] as _, false) },
)?;
esp::check("rmt_enable", unsafe { ffi::rmt_enable(self.channel) })?;
self.enabled = true;
let tx_config = ffi::rmt_transmit_config_t {
loop_count: 0, // 不循环
..Default::default()
};
esp::check(
"rmt_transmit",
// SAFETY: frames[slot] 在 Box 上,地址稳定;调用期间不会被移动。
unsafe {
ffi::rmt_transmit(
self.channel,
self.encoder,
self.frames[slot].as_ptr() as *const c_void,
core::mem::size_of_val(&self.frames[slot]),
&tx_config,
)
},
)?;
esp::check(
"rmt_tx_wait_all_done",
// SAFETY: 纯等待,超时给 1 s。
unsafe { ffi::rmt_tx_wait_all_done(self.channel, TX_TIMEOUT_MS) },
)?;
self.current[slot] = Some(strip);
Ok(())
}
/// 把 `rgb` 编成一条灯带的整帧(GRB、MSB first,3 珠同色)。
fn encode(&mut self, slot: usize, rgb: Rgb) {
let grb = rgb.to_grb();
let frame = &mut self.frames[slot];
// 一颗灯珠的 24 个符号只算一遍,再复制给另外两颗 —— 四条灯带都是 3 珠,
// 中间那两步没必要重复算。
let mut one_led = [ffi::rmt_symbol_word_t { val: 0 }; 24];
for (byte_index, byte) in grb.iter().enumerate() {
for bit in 0..8 {
let symbol = if (byte >> (7 - bit)) & 1 == 1 {
self.bit1
} else {
self.bit0
};
one_led[byte_index * 8 + bit] = symbol.into();
}
}
for led in 0..LEDS_PER_STRIP {
frame[led * 24..(led + 1) * 24].copy_from_slice(&one_led);
}
// 帧尾:>50 µs 低电平,灯带才会锁存这一帧。
frame[FRAME_SYMBOLS - 1] = self.reset.into();
}
/// 按 [`Plate`] 的 index 刷四路灯带。
///
/// 内容与上一次相同的路直接跳过(除非到了每秒一次的强制全刷),所以稳态下
/// 这个函数几乎不花时间。
pub fn refresh(&mut self, strips: [Strip; 4], now_ms: u64) -> Result<()> {
let full = now_ms.saturating_sub(self.last_full_refresh_ms) >= FULL_REFRESH_MS;
for plate in Plate::ALL {
let strip = strips[plate.index() as usize];
if !full && self.current[plate.index() as usize] == Some(strip) {
continue;
}
self.write(plate, strip)?;
}
if full {
self.last_full_refresh_ms = now_ms;
}
Ok(())
}
}
impl Drop for Leds {
fn drop(&mut self) {
// 固件里 main 永不返回,这段只在异常路径(比如 new() 之后的早期返回)跑到。
unsafe {
if self.enabled {
ffi::rmt_disable(self.channel);
}
if !self.encoder.is_null() {
ffi::rmt_del_encoder(self.encoder);
}
if !self.channel.is_null() {
ffi::rmt_del_channel(self.channel);
}
}
}
}
/// 供 main 打印一次用:把四路输出拼成一行,便于对照。
pub fn describe(strips: [Strip; 4]) -> String {
Plate::ALL
.iter()
.map(|plate| {
let strip = strips[plate.index() as usize];
let text = if strip.is_black() {
"off".to_string()
} else {
format!("#{:06X}", strip.color().to_u32())
};
format!("{}={text}", plate.name())
})
.collect::<Vec<_>>()
.join(" ")
}
/// 记一次刷新失败:诊断信息比重启有用,现场灯带接错线也不该把固件打挂。
pub fn log_failure(err: &anyhow::Error) {
warn!("ws2812 refresh failed: {err:#}");
}