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
+213
View File
@@ -0,0 +1,213 @@
//! 四路压电片的 ADC 连续采样(ESP32-C3)。
//!
//! # 为什么要连续(DMA)采样
//!
//! 压电片被敲一下的波形只有几十微秒宽。用 oneshot 轮询(每圈读一次)几乎必然从
//! 尖峰旁边擦过去,表现就是"有时候敲得响才算";参考实现因此用 40 kHz 的连续采样,
//! 这里保持一致:四个通道合计 40 kHz(每通道 10 kHz),一帧 10 ms = 400 个采样点。
//!
//! # 为什么也走 sys 绑定
//!
//! `esp-idf-hal` 的 `adc::continuous` 要用 `AdcChannels` 组合通道,而多通道组合的
//! 入口 `EmptyAdcChannels::chain` 在 0.47 里的约束是 `A: Adc` 且
//! `O: AdcChannels<AdcUnit = A>` —— `ADC1` 满足前者、`ADCU1` 满足后者,同一个类型
//! 参数凑不齐,四路 ADC1 通道拼不出来。直接照着 IDF 的 C API 建驱动反而更短,也
//! 与参考实现逐行对应。
//!
//! 采样点的通道号由硬件结果自带(`type2.channel`),所以映射不依赖"结果按 pattern
//! 顺序轮转"这个假设。
use anyhow::Result;
use esp_idf_svc::sys as ffi;
use log::info;
use armor_protocol::Plate;
use crate::esp;
/// 四个通道合计的采样率;每通道 10 kHz。
const SAMPLE_FREQ_HZ: u32 = 40_000;
/// 一帧的采样点数:10 ms × 40 kHz。
pub const FRAME_MEASUREMENTS: usize = 400;
/// 一帧的字节数(每个结果 4 字节)。
const FRAME_BYTES: u32 = FRAME_MEASUREMENTS as u32 * ffi::SOC_ADC_DIGI_RESULT_BYTES;
/// 连续读的超时:正常情况 10 ms 就凑满一帧,200 ms 只用来发现"驱动卡住了"。
const READ_TIMEOUT_MS: u32 = 200;
/// 每个板位对应的 ADC 通道号(ESP32-C3:GPIO0/4/3/1 = ADC1_CH0/CH4/CH3/CH1)。
///
/// 顺序与 [`Plate`] 的 index 一致;硬件结果里的通道号就是这些值。
const PLATE_CHANNEL: [u8; 4] = [0, 4, 3, 1];
/// 一帧的统计:读到多少采样点,以及每个板位的峰值。
///
/// 峰值是给**现场校准阈值**用的:压电片的输出幅度取决于贴装、线长和有没有放大,
/// 只报"有没有过阈值"没法判断该把阈值调到哪里。status 行每秒打印一次。
#[derive(Debug, Clone, Copy, Default)]
pub struct FrameStats {
/// 这一帧实际读到的采样点数。
pub samples: usize,
/// 每个板位的最大原始 ADC 值,按 [`Plate`] 的 index 排列。
pub peak: [u16; 4],
}
/// 连续采样的压电片输入。
pub struct Piezo {
handle: ffi::adc_continuous_handle_t,
/// 一帧的原始结果。`adc_digi_output_data_t` 里有 union,bindgen 不给 `Default`,
/// 所以用零值显式初始化(这个类型是纯 POD,零值是合法状态)。
frame: Box<[ffi::adc_digi_output_data_t; FRAME_MEASUREMENTS]>,
}
impl Piezo {
/// 建驱动并开始采样。
pub fn new() -> Result<Self> {
let handle_config = ffi::adc_continuous_handle_cfg_t {
// 两帧的余量:这一帧还没读完时,下一帧有地方落。
max_store_buf_size: FRAME_BYTES * 2,
conv_frame_size: FRAME_BYTES,
..Default::default()
};
let mut handle: ffi::adc_continuous_handle_t = core::ptr::null_mut();
esp::check(
"adc_continuous_new_handle",
// SAFETY: 两个指针都指向本函数的局部量,调用期间有效。
unsafe { ffi::adc_continuous_new_handle(&handle_config, &mut handle) },
)?;
// 四路通道的采样参数:0 dB 衰减、12 bit、都在 ADC1。
let mut patterns = [ffi::adc_digi_pattern_config_t {
atten: ffi::adc_atten_t_ADC_ATTEN_DB_0 as u8,
channel: 0,
unit: ffi::adc_unit_t_ADC_UNIT_1 as u8,
bit_width: 12,
}; 4];
for (pattern, channel) in patterns.iter_mut().zip(PLATE_CHANNEL) {
pattern.channel = channel;
}
let config = ffi::adc_continuous_config_t {
pattern_num: patterns.len() as u32,
adc_pattern: patterns.as_mut_ptr(),
sample_freq_hz: SAMPLE_FREQ_HZ,
conv_mode: ffi::adc_digi_convert_mode_t_ADC_CONV_SINGLE_UNIT_1,
// C3 的连续采样只支持 12 bit 的 TYPE2 结果布局,也正好对应下面读的
// `type2.data`(参考实现依赖了默认值 0,这里写明确)。
format: ffi::adc_digi_output_format_t_ADC_DIGI_OUTPUT_FORMAT_TYPE2,
..Default::default()
};
if let Err(err) = esp::check(
"adc_continuous_config",
// SAFETY: IDF 会把 patterns 复制进驱动,调用后不再持有这个指针。
unsafe { ffi::adc_continuous_config(handle, &config) },
) {
// SAFETY: 句柄刚建好、还没 start,可以直接删。
unsafe { ffi::adc_continuous_deinit(handle) };
return Err(err);
}
esp::check("adc_continuous_start", unsafe {
ffi::adc_continuous_start(handle)
})?;
let zero = ffi::adc_digi_output_data_t {
__bindgen_anon_1: ffi::adc_digi_output_data_t__bindgen_ty_1 { val: 0 },
};
info!(
"piezo: {SAMPLE_FREQ_HZ} Hz total over {} channels, {} measurements/frame (10 ms)",
PLATE_CHANNEL.len(),
FRAME_MEASUREMENTS
);
Ok(Self {
handle,
frame: Box::new([zero; FRAME_MEASUREMENTS]),
})
}
/// 读一帧(约 10 ms),把过阈值的采样点收集进 `hits`。
///
/// `hits` 会被清空后填充 `(板位, 原始值)`;返回值里带这一帧的采样点数与每路峰值。
/// **去抖与扣血不在这里做** —— 那是
/// [`armor_protocol::Armor::on_sample`] 的职责,一次击打的多个过阈值采样点由它
/// 的冷却窗口收敛成一次受击。
pub fn read_frame(
&mut self,
threshold: u16,
hits: &mut Vec<(Plate, u16)>,
) -> Result<FrameStats> {
hits.clear();
let mut stats = FrameStats::default();
let mut out_length: u32 = 0;
let rc = unsafe {
ffi::adc_continuous_read(
self.handle,
self.frame.as_mut_ptr() as *mut u8,
FRAME_BYTES,
&mut out_length,
READ_TIMEOUT_MS,
)
};
if rc == ffi::ESP_ERR_TIMEOUT {
// 没有数据不算错误:上一帧还没凑满,下一圈再来。
return Ok(stats);
}
esp::check("adc_continuous_read", rc)?;
let count = (out_length as usize / ffi::SOC_ADC_DIGI_RESULT_BYTES as usize)
.min(FRAME_MEASUREMENTS);
stats.samples = count;
for index in 0..count {
// SAFETY: 配置的是 TYPE2(12 bit)格式,`type2` 是有效成员;每个结果
// 4 字节且已 4 字节对齐。
let result = unsafe { self.frame[index].__bindgen_anon_1.type2 };
let raw = result.data() as u16;
let Some(plate) = plate_of_channel(result.channel() as u8) else {
continue;
};
// 峰值取自**所有**采样点,不只是过阈值的:阈值调得对不对,要靠没敲时
// 的底噪和敲击时的幅度两个数字一起看。
let slot = plate.index() as usize;
if raw > stats.peak[slot] {
stats.peak[slot] = raw;
}
if raw > threshold {
hits.push((plate, raw));
}
}
Ok(stats)
}
}
impl Drop for Piezo {
fn drop(&mut self) {
// 固件里 main 永不返回;这段只在启动失败路径上跑到。
unsafe {
if !self.handle.is_null() {
ffi::adc_continuous_stop(self.handle);
ffi::adc_continuous_deinit(self.handle);
}
}
}
}
/// 硬件结果里的通道号 → 板位。
#[must_use]
fn plate_of_channel(channel: u8) -> Option<Plate> {
let slot = PLATE_CHANNEL.iter().position(|c| *c == channel)?;
Plate::ALL.get(slot).copied()
}