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:
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "armor-firmware"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
rust-version = "1.82"
|
||||
|
||||
# 一个 bin:主板固件。三块"从"装甲板里没有 MCU,只有一路 WS2812 + 一路压电片
|
||||
# 直接接在主板 GPIO 上,所以没有第二份固件可烧(见 armor/README.md)。
|
||||
[[bin]]
|
||||
name = "armor"
|
||||
path = "src/main.rs"
|
||||
harness = false # 不在固件里跑主机测试框架;规则层的测试在 armor-protocol
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
anyhow = "1"
|
||||
embedded-svc = "0.29"
|
||||
esp-idf-svc = "0.53"
|
||||
|
||||
# 规则层 + 线路格式:血量、去抖、按键、灯带输出,以及上报给裁判终端的
|
||||
# `protocol::ArmorHealth`(armor-protocol 再导出)。规则层能在主机上单测。
|
||||
armor-protocol = { path = "../armor-protocol" }
|
||||
|
||||
[build-dependencies]
|
||||
embuild = "0.33.5"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
embuild::espidf::sysenv::output();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# 装甲板固件(4 MB flash)的自定义分区表。
|
||||
#
|
||||
# nvs : 颜色 / 队伍等应用设置,外加 WiFi 校准数据
|
||||
# phy_init : 射频校准数据
|
||||
# factory : 唯一的分区镜像(2 MB 余量;固件实测不到 1 MB,但默认的 1 MB 单 app
|
||||
# 槽对带 WiFi 的固件太紧)
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
phy_init, data, phy, 0xf000, 0x1000,
|
||||
factory, app, factory, 0x10000, 0x200000,
|
||||
|
@@ -0,0 +1,29 @@
|
||||
# 装甲板固件(ESP32-C3)的默认配置。
|
||||
#
|
||||
# CONFIG_IDF_TARGET 必须显式写出来:本机装的是通用 ESP-IDF,默认目标是 ESP32,
|
||||
# 不写这一行会按 ESP32 去配 ADC/RMT 通道,编译期就报"没有这个外设"。
|
||||
#
|
||||
# 串口调试走 UART0(RXD = GPIO20 / TXD = GPIO21,115200 8N1)——这是 C3 的 ROM
|
||||
# 默认控制台引脚,和参考固件里给 DBUS 用的那两个脚是同一对。想改成 USB
|
||||
# Serial/JTAG 输出,把 CONFIG_ESP_CONSOLE_UART_DEFAULT 换成
|
||||
# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG 即可。
|
||||
CONFIG_IDF_TARGET="esp32c3"
|
||||
|
||||
# 4 MB flash;应用分区放到 2 MB(见 partitions.csv),默认的 1 MB 单 app 槽对
|
||||
# WiFi + 日志的 Rust 固件太紧。
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
|
||||
# 调试口 = UART0(GPIO20 RX / GPIO21 TX)。
|
||||
#
|
||||
# 同时打开 USB Serial/JTAG 作为**次**控制台:日志会同时从板上那个原生 USB 口出来。
|
||||
# 这样现场两种接法都能看到日志 —— 接了 USB-TTL 到 RXD/TXD 的看 UART0,直接插板上
|
||||
# USB 口的看 USB。主控制台仍是 UART0,所以"RXD/TXD 就是调试口"这条成立。
|
||||
#
|
||||
# 只用原生 USB 口烧录(不接 USB-TTL)时,这一条是能看见日志的唯一办法。
|
||||
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
|
||||
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
|
||||
|
||||
# Rust 的 main 与规则层在同一个任务栈上跑,另外 log 会用到一些栈;默认 3.5 KB
|
||||
# 在带 WiFi 的固件里容易爆栈。
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
@@ -0,0 +1,27 @@
|
||||
//! 把 IDF 的 `esp_err_t` 返回值变成可读的 `anyhow::Error`。
|
||||
//!
|
||||
//! 固件里到处都要判 `ESP_OK` 并把错误码翻译成人能看懂的名字;不翻译的话现场只会
|
||||
//! 看到 `0x105`,而 IDF 的错误码表并不在脑子里。
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use esp_idf_svc::sys as ffi;
|
||||
|
||||
/// `ESP_OK` 之外的返回值转成 `Err`,`what` 是出错的那一步的名字。
|
||||
pub fn check(what: &str, rc: ffi::esp_err_t) -> Result<()> {
|
||||
if rc == ffi::ESP_OK {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(anyhow!("{what} failed: {} (0x{rc:x})", name(rc)))
|
||||
}
|
||||
|
||||
/// 错误码的名字,例如 `ESP_ERR_INVALID_ARG`。
|
||||
#[must_use]
|
||||
pub fn name(rc: ffi::esp_err_t) -> String {
|
||||
// SAFETY: esp_err_to_name 对任意 esp_err_t 都返回一个静态 C 字符串。
|
||||
unsafe {
|
||||
core::ffi::CStr::from_ptr(ffi::esp_err_to_name(rc))
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
}
|
||||
@@ -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(©_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:#}");
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
//! 装甲板主板固件(ESP32-C3):一主三从,与裁判终端联动。
|
||||
//!
|
||||
//! # 这套硬件是什么
|
||||
//!
|
||||
//! 一辆车四块装甲板(前/后/左/右),但只有**主板一颗 MCU**:另外三块"从"板里没有
|
||||
//! 单片机,各自只有一路 WS2812 和一路压电片,直接接线到主板上。所以这份固件同时管
|
||||
//! 四路输入与四路输出:
|
||||
//!
|
||||
//! ```text
|
||||
//! 压电片 前/后/左/右 ──ADC──┐
|
||||
//! ├──► armor-protocol(血量 / 去抖 / 灯色)
|
||||
//! BOOT 键 ──────── GPIO9 ──┘ │
|
||||
//! ├──► WS2812 前/后/左/右
|
||||
//! └──► UDP 广播 ──► 裁判终端 :5601
|
||||
//! ```
|
||||
//!
|
||||
//! # 规则(见 `armor-protocol`,那里有单元测试)
|
||||
//!
|
||||
//! - 整车一条血量:100 起,每次受击 −3,减到 0 不变负;0 血四路全黑。
|
||||
//! - 受击时**只有被打的那一路**灯带全黑 50 ms,其余三路不动。
|
||||
//! - 颜色由队伍决定:队伍一红、队伍二蓝;`ARMOR_TEAM` 编译期注入。
|
||||
//! - 血量账在板子上算,按 `protocol::ArmorHealth` 广播给本项目裁判终端。
|
||||
//!
|
||||
//! # 构建与烧录
|
||||
//!
|
||||
//! ```text
|
||||
//! scripts/build_armor.sh # 用 .env 里的 WIFI_SSID/WIFI_PASS/ARMOR_TEAM
|
||||
//! scripts/flash_armor.sh # 编译 + espflash + 串口日志
|
||||
//! ```
|
||||
//!
|
||||
//! 串口调试走 UART0:RXD = GPIO20 / TXD = GPIO21,115200 8N1(日志从 TX 出来,
|
||||
//! RX 目前空闲,留给以后加命令通道)。USB Serial/JTAG 配成了次控制台,所以插原生
|
||||
//! USB 口也能看到同一份日志。完整接线表、协议与踩坑见 `armor/README.md`。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use embedded_svc::wifi::{AuthMethod, ClientConfiguration, Configuration, PmfConfiguration};
|
||||
use esp_idf_svc::eventloop::EspSystemEventLoop;
|
||||
use esp_idf_svc::hal::gpio::{PinDriver, Pull};
|
||||
use esp_idf_svc::hal::peripherals::Peripherals;
|
||||
use esp_idf_svc::nvs::EspDefaultNvsPartition;
|
||||
use esp_idf_svc::wifi::{BlockingWifi, EspWifi};
|
||||
use log::{error, info, warn};
|
||||
|
||||
use armor_protocol::{Armor, Button, ButtonEvent, Plate, ReportSchedule, Rules, TEAM_2};
|
||||
|
||||
mod esp;
|
||||
mod leds;
|
||||
mod net;
|
||||
mod piezo;
|
||||
mod settings;
|
||||
|
||||
/// WiFi 账号来自 .env(构建期注入,固件里不留密码)。
|
||||
const SSID: &str = env!("WIFI_SSID");
|
||||
const PASSWORD: &str = env!("WIFI_PASS");
|
||||
|
||||
/// 编译期注入的队伍号:`0` = 队伍一(红),`1` = 队伍二(蓝)。不写就是队伍一。
|
||||
const TEAM_ENV: &str = match option_env!("ARMOR_TEAM") {
|
||||
Some(value) => value,
|
||||
None => "0",
|
||||
};
|
||||
|
||||
/// 受击阈值(原始 12 bit ADC)。参考实现用 2000,但压电片的幅度取决于贴装、线长
|
||||
/// 与有没有放大级,所以这里留一个编译期入口:`ARMOR_THRESHOLD=1200`。
|
||||
///
|
||||
/// 怎么定这个值:看串口 status 行里的 `peak[...]`。没敲时的底噪要明显低于阈值,
|
||||
/// 正常敲击要明显高于它。
|
||||
const THRESHOLD_ENV: &str = match option_env!("ARMOR_THRESHOLD") {
|
||||
Some(value) => value,
|
||||
None => "2000",
|
||||
};
|
||||
|
||||
/// 血量不变时的重发周期。裁判端靠它判断板子还在线 —— 一次丢包不该让界面变回
|
||||
/// "装甲板未接入"。
|
||||
const REPORT_HEARTBEAT_MS: u64 = 200;
|
||||
|
||||
/// 状态行的打印周期(UART 调试口)。
|
||||
const STATUS_INTERVAL_MS: u64 = 1000;
|
||||
|
||||
/// WiFi 断线检查周期。
|
||||
const WIFI_CHECK_MS: u64 = 5000;
|
||||
|
||||
/// 毫秒时基。`esp_timer` 是微秒计数器,转毫秒后 u64 不会被绕回。
|
||||
fn now_ms() -> u64 {
|
||||
// SAFETY: 纯读计时器。
|
||||
(unsafe { esp_idf_svc::sys::esp_timer_get_time() } as u64) / 1000
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// esp-idf-sys 的运行时补丁必须跑一次,日志系统也要先起来。
|
||||
esp_idf_svc::sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
|
||||
let team = armor_protocol::parse_team(TEAM_ENV)
|
||||
.ok_or_else(|| anyhow!("ARMOR_TEAM must be 0 or 1, got {TEAM_ENV:?}"))?;
|
||||
|
||||
let peripherals = Peripherals::take()?;
|
||||
let sys_loop = EspSystemEventLoop::take()?;
|
||||
let nvs = EspDefaultNvsPartition::take()?;
|
||||
|
||||
// 队别与颜色:ARMOR_TEAM 是权威,NVS 只记住结果。
|
||||
let color = settings::Settings::new(nvs.clone())?.resolve_color(team)?;
|
||||
|
||||
let threshold: u16 = THRESHOLD_ENV
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("ARMOR_THRESHOLD must be a number, got {THRESHOLD_ENV:?}"))?;
|
||||
let rules = Rules {
|
||||
threshold,
|
||||
..Rules::default()
|
||||
};
|
||||
|
||||
let mut armor = Armor::new(rules, color);
|
||||
|
||||
info!("pins: ws2812 front=IO10 rear=IO5 left=IO6 right=IO7");
|
||||
info!("pins: piezo front=GPIO0 rear=GPIO4 left=GPIO3 right=GPIO1");
|
||||
info!("pins: boot=GPIO9 debug uart0 rxd=GPIO20 txd=GPIO21 115200 8N1");
|
||||
info!(
|
||||
"armor board: team={team} ({}), {} hp, -{} per hit, {} ms dark on the hit strip",
|
||||
if team == TEAM_2 { "blue" } else { "red" },
|
||||
armor.hp(),
|
||||
rules.damage_per_hit,
|
||||
rules.hit_blink.as_millis()
|
||||
);
|
||||
|
||||
let mut leds = leds::Leds::new()?;
|
||||
let mut piezo = piezo::Piezo::new()?;
|
||||
|
||||
// BOOT 键(GPIO9,低有效):内部上拉,按下读低。
|
||||
let button = PinDriver::input(peripherals.pins.gpio9, Pull::Up)?;
|
||||
let mut button_fsm = Button::default();
|
||||
|
||||
let mut wifi = BlockingWifi::wrap(
|
||||
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
|
||||
sys_loop,
|
||||
)?;
|
||||
// WiFi 是尽力而为:**连不上也绝不能挡住受击检测与灯效**。一块因为没网就不亮
|
||||
// 灯的装甲板,在场上比"裁判端看不到血量"糟糕得多。所以这里只报警,主循环照跑,
|
||||
// 由看门狗每 5 s 重试。
|
||||
if let Err(err) = join(&mut wifi, Duration::from_secs(10)) {
|
||||
warn!("no referee link yet ({err:#}); hits and LEDs still work, retrying every {WIFI_CHECK_MS} ms");
|
||||
}
|
||||
|
||||
let reporter = net::Reporter::new()?;
|
||||
let sender_id = net::local_id();
|
||||
|
||||
let mut hits: Vec<(Plate, u16)> = Vec::with_capacity(16);
|
||||
let mut reports = ReportSchedule::new(Duration::from_millis(REPORT_HEARTBEAT_MS));
|
||||
let mut last_status_ms = now_ms();
|
||||
let mut last_wifi_check_ms = now_ms();
|
||||
// 启动即发一次:裁判端不用等到第一次受击才知道板子在线。
|
||||
let mut force_report = true;
|
||||
// 上报统计,状态行里打出来:现场"裁判端没显示"时,第一件事是分清是链路问题
|
||||
// 还是对端问题。计数在涨就说明包已经出去了。
|
||||
let mut reports_sent: u64 = 0;
|
||||
let mut report_errors: u64 = 0;
|
||||
// 上一次状态行以来每路 ADC 的峰值(校准阈值用),打印后清零。
|
||||
let mut peak = [0u16; 4];
|
||||
|
||||
info!("entering the main loop: long-press BOOT to reset hp, short-press to print status");
|
||||
|
||||
loop {
|
||||
let now = Duration::from_millis(now_ms());
|
||||
|
||||
// 采样:一次读一帧(约 10 ms),这一帧就是主循环的节拍。
|
||||
match piezo.read_frame(armor.rules().threshold, &mut hits) {
|
||||
Ok(stats) => {
|
||||
for (slot, value) in stats.peak.iter().enumerate() {
|
||||
peak[slot] = peak[slot].max(*value);
|
||||
}
|
||||
|
||||
for (plate, raw) in hits.iter().copied() {
|
||||
if let Some(event) = armor.on_sample(plate, raw, now) {
|
||||
info!(
|
||||
"hit plate={} raw={} hp={} (hits={})",
|
||||
event.plate.name(),
|
||||
event.raw,
|
||||
event.hp,
|
||||
armor.hits()
|
||||
);
|
||||
if event.dead {
|
||||
warn!("hp reached 0: all four strips stay dark until the next reset");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("adc frame failed: {err:#}");
|
||||
// 读失败时别把循环变成没有节拍的忙等。
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
// 灯带:只有内容变化的那一路才真的发 RMT(每秒另有一次强制全刷)。
|
||||
let strips = armor.strips(now);
|
||||
if let Err(err) = leds.refresh(strips, now_ms()) {
|
||||
leds::log_failure(&err);
|
||||
}
|
||||
|
||||
// 按键。BOOT 低有效,所以"按下" = 读到低电平。
|
||||
match button_fsm.tick(button.is_low(), now) {
|
||||
ButtonEvent::LongPress => {
|
||||
armor.reset();
|
||||
warn!("BOOT long press: hp reset to {} (new round)", armor.hp());
|
||||
force_report = true;
|
||||
}
|
||||
ButtonEvent::Click => {
|
||||
info!(
|
||||
"status: {}",
|
||||
status_with_link(&armor, now, reports_sent, report_errors, peak)
|
||||
);
|
||||
force_report = true;
|
||||
}
|
||||
ButtonEvent::None => {}
|
||||
}
|
||||
|
||||
// 上报:血量一变立刻发,其余按心跳重发。
|
||||
let current_ms = now_ms();
|
||||
if force_report || reports.due(armor.hp(), now) {
|
||||
match broadcast_health(&reporter, &armor, team, sender_id) {
|
||||
Ok(()) => {
|
||||
reports.mark_sent(armor.hp(), now);
|
||||
reports_sent = reports_sent.saturating_add(1);
|
||||
}
|
||||
Err(err) => {
|
||||
report_errors = report_errors.saturating_add(1);
|
||||
warn!("report failed: {err:#}");
|
||||
}
|
||||
}
|
||||
force_report = false;
|
||||
}
|
||||
|
||||
// 状态行:现场不看串口也能从日志确认板子活着、血量是多少、链路通不通。
|
||||
if current_ms.saturating_sub(last_status_ms) >= STATUS_INTERVAL_MS {
|
||||
last_status_ms = current_ms;
|
||||
info!(
|
||||
"status: {}",
|
||||
status_with_link(&armor, now, reports_sent, report_errors, peak)
|
||||
);
|
||||
// 峰值是"上一秒"的量,打完就清零,这样现场能一眼看出没敲时的底噪。
|
||||
peak = [0u16; 4];
|
||||
}
|
||||
|
||||
// WiFi 看门狗:**关联上 + 拿到 IP** 才算链路好。
|
||||
//
|
||||
// 判断不用 `BlockingWifi::is_connected()/is_up()`:那两个读的是 esp-idf-svc
|
||||
// 自己的事件缓存,实测在 IDF v6.0.1 上会停留在"已连接",而 IDF 的 API 明明
|
||||
// 说没关联(现场表现就是日志说一切正常、裁判端一个包都收不到)。所以这里
|
||||
// 直接问 IDF:`esp_wifi_sta_get_ap_info()` 与 `esp_netif_get_ip_info()`。
|
||||
//
|
||||
// 重连时只给 3 s 等 IP(启动时给的 10 s):这里是主循环,等太久会漏掉受击。
|
||||
if current_ms.saturating_sub(last_wifi_check_ms) >= WIFI_CHECK_MS {
|
||||
last_wifi_check_ms = current_ms;
|
||||
|
||||
let link = link_state();
|
||||
let ip = sta_ip();
|
||||
let healthy = link.associated() && ip.is_some_and(|ip| !ip.is_unspecified());
|
||||
|
||||
if !healthy {
|
||||
warn!("link is not usable ({}); reconnecting", describe_link(&link, ip));
|
||||
|
||||
// 断开再连:关联掉了或 DHCP 没下来时,只有重新来一遍才会好。
|
||||
if let Err(err) = wifi.disconnect() {
|
||||
warn!("disconnect failed: {err}");
|
||||
}
|
||||
if let Err(err) = join(&mut wifi, Duration::from_secs(3)) {
|
||||
warn!("reconnect failed: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 广播四块装甲板的血量给裁判终端。
|
||||
///
|
||||
/// 四条数据报的 `hp` 相同(整车一条血量),`index` 分别是 0..3 —— 裁判端已有的
|
||||
/// 四格血量界面无需改动。`sender_id` 用 MAC 派生的板子 id,方便抓包时分辨是谁发的。
|
||||
fn broadcast_health(
|
||||
reporter: &net::Reporter,
|
||||
armor: &Armor,
|
||||
team: u8,
|
||||
sender_id: u32,
|
||||
) -> Result<()> {
|
||||
for datagram in armor.datagrams_from(team, sender_id) {
|
||||
reporter.broadcast(&datagram)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 状态行 + 当场问出来的链路状态。
|
||||
///
|
||||
/// 每次都现算而不是缓存:现场排查时最怕看到的是过期的好消息。
|
||||
fn status_with_link(
|
||||
armor: &Armor,
|
||||
now: Duration,
|
||||
reports_sent: u64,
|
||||
report_errors: u64,
|
||||
peak: [u16; 4],
|
||||
) -> String {
|
||||
status_line(
|
||||
armor,
|
||||
now,
|
||||
&link_line(&link_state(), sta_ip(), reports_sent, report_errors),
|
||||
peak,
|
||||
)
|
||||
}
|
||||
|
||||
/// 一行状态,给 UART 调试口用。
|
||||
///
|
||||
/// `peak` 是这一秒里每路 ADC 的峰值。它比"有没有受击"更有用:现场要判断阈值该调到
|
||||
/// 多少,靠的就是"没敲时的底噪"和"敲击时的幅度"这两个数字。
|
||||
fn status_line(armor: &Armor, now: Duration, link: &str, peak: [u16; 4]) -> String {
|
||||
let counts = armor.plate_hits_all();
|
||||
let per_plate = Plate::ALL
|
||||
.iter()
|
||||
.map(|plate| format!("{}={}", plate.name(), counts[plate.index() as usize]))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let peaks = Plate::ALL
|
||||
.iter()
|
||||
.map(|plate| format!("{}={}", plate.name(), peak[plate.index() as usize]))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
format!(
|
||||
"hp={} hits={} dead={} plates[{}] peak[{}] thr={} strips[{}] {link}",
|
||||
armor.hp(),
|
||||
armor.hits(),
|
||||
armor.dead(),
|
||||
per_plate,
|
||||
peaks,
|
||||
armor.rules().threshold,
|
||||
leds::describe(armor.strips(now)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 链路那半截状态:连的是哪个 AP、信号多强、有没有 IP、上报发了多少包。
|
||||
///
|
||||
/// 现场"裁判端没显示"时,第一件事是分清链路问题还是对端问题:`reports` 在涨就说明
|
||||
/// 包已经出去了,该去查裁判端;一直不动就是这块板子没连上。而"没连上"还要再分
|
||||
/// 关联与 DHCP —— 关联上但 IP 是 0.0.0.0 是最容易看错的一种。
|
||||
fn link_line(link: &Link, ip: Option<std::net::Ipv4Addr>, reports_sent: u64, report_errors: u64) -> String {
|
||||
format!(
|
||||
"{} reports={reports_sent} errs={report_errors}",
|
||||
describe_link(link, ip)
|
||||
)
|
||||
}
|
||||
|
||||
/// 当前关联状态。**来源是 IDF 的 API**,不是 esp-idf-svc 的事件缓存(见看门狗注释)。
|
||||
#[derive(Debug, Clone)]
|
||||
enum Link {
|
||||
/// 关联上了:AP 名字、RSSI、信道。
|
||||
Associated {
|
||||
ssid: String,
|
||||
rssi: i8,
|
||||
channel: u8,
|
||||
},
|
||||
/// 没关联(或者 IDF 说 STA 没在跑)。
|
||||
Idle { rc: i32 },
|
||||
}
|
||||
|
||||
impl Link {
|
||||
#[must_use]
|
||||
fn associated(&self) -> bool {
|
||||
matches!(self, Link::Associated { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// 问一次 IDF:STA 关联到哪个 AP 了。
|
||||
fn link_state() -> Link {
|
||||
let mut record = esp_idf_svc::sys::wifi_ap_record_t::default();
|
||||
|
||||
// SAFETY: record 是本函数的局部量,IDF 只往里写;未关联时返回非 OK 而不动它。
|
||||
let rc = unsafe { esp_idf_svc::sys::esp_wifi_sta_get_ap_info(&mut record) };
|
||||
if rc != esp_idf_svc::sys::ESP_OK {
|
||||
return Link::Idle { rc };
|
||||
}
|
||||
|
||||
// ssid 是定长 33 字节、以 0 结尾的缓冲区。
|
||||
let ssid = unsafe {
|
||||
core::ffi::CStr::from_ptr(record.ssid.as_ptr() as *const core::ffi::c_char)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
|
||||
Link::Associated {
|
||||
ssid,
|
||||
rssi: record.rssi,
|
||||
channel: record.primary,
|
||||
}
|
||||
}
|
||||
|
||||
/// STA 网卡当前的 IPv4 地址(直接从 `esp_netif` 读,绕开 esp-idf-svc 的封装)。
|
||||
///
|
||||
/// `None` 表示还没拿到 DHCP 地址。0.0.0.0 也按 `None` 处理 —— 那等于没有地址。
|
||||
fn sta_ip() -> Option<std::net::Ipv4Addr> {
|
||||
// 默认 STA 网卡的 interface key(`esp_netif_create_default_wifi_sta` 用的就是它)。
|
||||
let handle = unsafe {
|
||||
esp_idf_svc::sys::esp_netif_get_handle_from_ifkey(c"WIFI_STA_DEF".as_ptr())
|
||||
};
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut info = esp_idf_svc::sys::esp_netif_ip_info_t::default();
|
||||
// SAFETY: info 是局部量,handle 来自上面的查询。
|
||||
if unsafe { esp_idf_svc::sys::esp_netif_get_ip_info(handle, &mut info) }
|
||||
!= esp_idf_svc::sys::ESP_OK
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// lwIP 的 `ip4_addr_t.addr` 是网络字节序,所以最低字节是点分表示的第一段。
|
||||
let raw = info.ip.addr;
|
||||
let ip = std::net::Ipv4Addr::new(
|
||||
(raw & 0xFF) as u8,
|
||||
((raw >> 8) & 0xFF) as u8,
|
||||
((raw >> 16) & 0xFF) as u8,
|
||||
((raw >> 24) & 0xFF) as u8,
|
||||
);
|
||||
|
||||
if ip.is_unspecified() {
|
||||
None
|
||||
} else {
|
||||
Some(ip)
|
||||
}
|
||||
}
|
||||
|
||||
/// 链路状态的一行描述,日志与状态行共用。
|
||||
fn describe_link(link: &Link, ip: Option<std::net::Ipv4Addr>) -> String {
|
||||
let ip = match ip {
|
||||
Some(ip) => ip.to_string(),
|
||||
None => "none".to_string(),
|
||||
};
|
||||
|
||||
match link {
|
||||
Link::Associated {
|
||||
ssid,
|
||||
rssi,
|
||||
channel,
|
||||
} => format!("ap={ssid} rssi={rssi} ch={channel} ip={ip}"),
|
||||
Link::Idle { rc } => format!("ap=none (rc=0x{rc:x}) ip={ip}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 等到 STA 真的拿到 IP(权威来源),最多等 `timeout`。
|
||||
///
|
||||
/// 不用 `BlockingWifi::wait_netif_up()`:它读的是 esp-idf-svc 的事件缓存,在
|
||||
/// IDF v6.0.1 上会"立刻成功"而实际没有地址 —— 那样 join() 会假装连上了,看门狗
|
||||
/// 也就永远不会重试。
|
||||
fn wait_for_ip(timeout: Duration) -> Option<std::net::Ipv4Addr> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
if let Some(ip) = sta_ip() {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
/// 连上 WiFi 并等一个 IP(幂等:已启动就不再 start 一次)。
|
||||
///
|
||||
/// 连不上时把扫到的网络打出来:这块模块只有 2.4 GHz,SSID 若在 5 GHz 上,现场看到
|
||||
/// 一张空列表比看一个关联超时有用得多。**调用方不应该因为这个错误就退出** —— 没有
|
||||
/// 网也要能打灯。
|
||||
fn join(wifi: &mut BlockingWifi<EspWifi<'static>>, ip_timeout: Duration) -> Result<()> {
|
||||
if !wifi.is_started()? {
|
||||
wifi.start()?;
|
||||
}
|
||||
|
||||
if !link_state().associated() {
|
||||
let config = Configuration::Client(ClientConfiguration {
|
||||
ssid: SSID
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("WIFI_SSID too long (max 32 bytes)"))?,
|
||||
bssid: None,
|
||||
auth_method: default_auth_method(),
|
||||
password: PASSWORD
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("WIFI_PASS too long (max 64 bytes)"))?,
|
||||
channel: None,
|
||||
// 通告 PMF:WPA2/WPA3 混合模式的路由器否则会丢掉握手;`required: false`
|
||||
// 仍然允许纯 WPA2 的 AP。
|
||||
pmf_cfg: PmfConfiguration::Capable { required: false },
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
wifi.set_configuration(&config)?;
|
||||
|
||||
if let Err(err) = wifi.connect() {
|
||||
error!("could not join \"{SSID}\": {err}");
|
||||
report_scan(wifi);
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
|
||||
// 省电模式换来的电流不值得:它会让上报晚一个信标间隔(上百毫秒),而"受击后
|
||||
// 50 ms 变暗"这件事本来就是要给人即时反馈的。
|
||||
let rc = unsafe {
|
||||
esp_idf_svc::sys::esp_wifi_set_ps(esp_idf_svc::sys::wifi_ps_type_t_WIFI_PS_NONE)
|
||||
};
|
||||
if rc != esp_idf_svc::sys::ESP_OK {
|
||||
warn!("could not disable wifi power save ({})", esp::name(rc));
|
||||
}
|
||||
|
||||
let link = link_state();
|
||||
|
||||
let Some(ip) = wait_for_ip(ip_timeout) else {
|
||||
report_scan(wifi);
|
||||
return Err(anyhow!(
|
||||
"no DHCP address within {} ms ({})",
|
||||
ip_timeout.as_millis(),
|
||||
describe_link(&link, None)
|
||||
));
|
||||
};
|
||||
|
||||
info!("joined \"{SSID}\" on {ip}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 没写 `WIFI_PASS` 就是开放网络。
|
||||
fn default_auth_method() -> AuthMethod {
|
||||
if PASSWORD.is_empty() {
|
||||
AuthMethod::None
|
||||
} else {
|
||||
AuthMethod::WPA2Personal
|
||||
}
|
||||
}
|
||||
|
||||
/// 把扫到的网络打出来,并标出我们要连的那个。连不上时的第一诊断信息。
|
||||
fn report_scan(wifi: &mut BlockingWifi<EspWifi<'static>>) {
|
||||
match wifi.scan() {
|
||||
Ok(aps) if aps.is_empty() => {
|
||||
error!("scan found no networks; check the antenna and that the AP is on 2.4 GHz");
|
||||
}
|
||||
Ok(aps) => {
|
||||
for ap in aps {
|
||||
info!(
|
||||
" {:<32} ch {:>2} {:>4} dBm {:?}{}",
|
||||
ap.ssid,
|
||||
ap.channel,
|
||||
ap.signal_strength,
|
||||
ap.auth_method,
|
||||
if ap.ssid.as_str() == SSID { " <- target" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => warn!("scan failed: {err}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! 上报通道:把 [`ArmorHealth`](armor_protocol::ArmorHealth) 广播给本项目裁判终端。
|
||||
//!
|
||||
//! # 为什么直接写 lwIP 的 socket
|
||||
//!
|
||||
//! 与同仓库的图传固件(`src/udp.rs`)一样:`std::net` 在 lwIP 上的行为要靠一层
|
||||
//! VFS 转接,而这里只需要"建一个 UDP socket + 广播"这一件事,直接用 IDF 原生的
|
||||
//! `lwip_*` 就能与图传板跑在同一条被验证过的代码路径上。
|
||||
//!
|
||||
//! # 为什么不 bind
|
||||
//!
|
||||
//! 装甲板只发不收:血量是板子算出来的,裁判端只显示。所以 socket 不绑定端口,
|
||||
//! 由 lwIP 选一个临时端口即可 —— 绑定 5601 反而会和裁判端、以及同网段的另一块
|
||||
//! 装甲板抢同一个端口。裁判端监听 5601,板子往 5601 发,方向是单向的。
|
||||
//!
|
||||
//! # 广播而不是单播
|
||||
//!
|
||||
//! 裁判终端的地址是 DHCP 变的(图传板的 Hello 广播就是为了绕开这件事)。装甲板
|
||||
//! 沿用同一个约定:不知道也不关心裁判端在哪,反正同一个局域网里它会收到。
|
||||
|
||||
use core::ffi::c_void;
|
||||
use std::net::SocketAddrV4;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use esp_idf_svc::sys as ffi;
|
||||
use log::{info, warn};
|
||||
|
||||
use armor_protocol::{ARMOR_PORT, BROADCAST_ADDR};
|
||||
|
||||
/// 把 Rust 的 socket 地址转成 lwIP 期望的 C 结构。
|
||||
///
|
||||
/// 逐字段构造而不是 `transmute`:`SocketAddrV4` 是紧凑的 6 字节(端口在前、主机
|
||||
/// 字节序),C 的 `sockaddr_in` 是 16 字节且地址是网络字节序 —— 指针强转会发到一个
|
||||
/// 乱地址,而且不会报错。
|
||||
fn to_sockaddr(addr: SocketAddrV4) -> ffi::sockaddr_in {
|
||||
let octets = addr.ip().octets();
|
||||
|
||||
ffi::sockaddr_in {
|
||||
sin_len: core::mem::size_of::<ffi::sockaddr_in>() as u8,
|
||||
sin_family: ffi::AF_INET as ffi::sa_family_t,
|
||||
// 用 lwIP 的 htons,字节序不必在 Rust 侧推理。
|
||||
sin_port: unsafe { ffi::lwip_htons(addr.port()) },
|
||||
sin_addr: ffi::in_addr {
|
||||
s_addr: u32::from_le_bytes(octets),
|
||||
},
|
||||
sin_zero: [0; 8],
|
||||
}
|
||||
}
|
||||
|
||||
/// 本机 id:由 WiFi MAC 派生(FNV-1a),重启不变,且避开 0 —— 0 留给裁判终端。
|
||||
pub fn local_id() -> u32 {
|
||||
let mut mac = [0u8; 6];
|
||||
|
||||
// SAFETY: `mac` 有 6 字节空间,函数会把 6 字节写满。
|
||||
let rc = unsafe { ffi::esp_read_mac(mac.as_mut_ptr(), ffi::esp_mac_type_t_ESP_MAC_WIFI_STA) };
|
||||
|
||||
if rc != ffi::ESP_OK {
|
||||
warn!("could not read the WiFi MAC ({:#x}); using a fixed id", rc);
|
||||
return 0xF1F0_0001;
|
||||
}
|
||||
|
||||
let id = armor_protocol::id_from_mac(&mac);
|
||||
info!("this board's id is 0x{id:08X}, from MAC {mac:02X?}");
|
||||
id
|
||||
}
|
||||
|
||||
/// 广播上报器。
|
||||
pub struct Reporter {
|
||||
socket: i32,
|
||||
}
|
||||
|
||||
impl Reporter {
|
||||
/// 建一个开了广播许可的 UDP socket。
|
||||
pub fn new() -> Result<Self> {
|
||||
// SAFETY: 建 socket 不涉及 Rust 内存。
|
||||
let socket = unsafe { ffi::lwip_socket(ffi::AF_INET as i32, ffi::SOCK_DGRAM as i32, 0) };
|
||||
if socket < 0 {
|
||||
// 用 std 的 errno 读取:`__errno` 不是每个目标的绑定里都有,而 lwIP 的
|
||||
// socket 错误码就是同一个 errno。
|
||||
return Err(anyhow!(
|
||||
"could not create UDP socket: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
|
||||
// 目标地址是 255.255.255.255,不开这个选项 sendto 会返回 EACCES。默认就是
|
||||
// 关的,而且失败不会自己冒出来 —— 只会表现为"裁判端一直显示未接入"。
|
||||
let enable: i32 = 1;
|
||||
// SAFETY: optval 指向栈上的 i32,optlen 与它一致。
|
||||
let rc = unsafe {
|
||||
ffi::lwip_setsockopt(
|
||||
socket,
|
||||
ffi::SOL_SOCKET as i32,
|
||||
ffi::SO_BROADCAST as i32,
|
||||
&enable as *const i32 as *const c_void,
|
||||
core::mem::size_of::<i32>() as u32,
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
// SAFETY: 关闭本函数刚建的 socket。
|
||||
unsafe { ffi::lwip_close(socket) };
|
||||
return Err(anyhow!("could not enable SO_BROADCAST: {err}"));
|
||||
}
|
||||
|
||||
info!("reporting to {BROADCAST_ADDR}:{ARMOR_PORT} over UDP");
|
||||
|
||||
Ok(Self { socket })
|
||||
}
|
||||
|
||||
/// 把一个数据报广播到裁判终端。
|
||||
pub fn broadcast(&self, payload: &[u8]) -> Result<()> {
|
||||
let addr = to_sockaddr(SocketAddrV4::new(BROADCAST_ADDR, ARMOR_PORT));
|
||||
|
||||
// SAFETY: 缓冲区与地址在调用期间都有效;sendto 复制数据而不是持有指针。
|
||||
let rc = unsafe {
|
||||
ffi::lwip_sendto(
|
||||
self.socket,
|
||||
payload.as_ptr() as *const c_void,
|
||||
payload.len(),
|
||||
ffi::MSG_DONTWAIT as i32,
|
||||
&addr as *const ffi::sockaddr_in as *const ffi::sockaddr,
|
||||
core::mem::size_of::<ffi::sockaddr_in>() as ffi::socklen_t,
|
||||
)
|
||||
};
|
||||
|
||||
if rc < 0 {
|
||||
return Err(anyhow!(
|
||||
"broadcast failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Reporter {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: 关闭本结构体自己建的 socket。
|
||||
unsafe { ffi::lwip_close(self.socket) };
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 板子自己的持久化设置(NVS):队别与颜色。
|
||||
//!
|
||||
//! 命名空间与键名都沿用参考实现(`armor` / `color`),所以从参考固件刷过来时,
|
||||
//! 之前存过的颜色还能被读到。新增一个 `team` 键用来记住这块板子是哪一队。
|
||||
|
||||
use anyhow::Result;
|
||||
use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs, NvsDefault};
|
||||
use log::info;
|
||||
|
||||
use armor_protocol::Rgb;
|
||||
|
||||
/// NVS 命名空间(与参考实现一致)。
|
||||
const NAMESPACE: &str = "armor";
|
||||
/// 队别键名。
|
||||
const KEY_TEAM: &str = "team";
|
||||
/// 颜色键名,值是 `0x00RRGGBB`(与参考实现一致)。
|
||||
const KEY_COLOR: &str = "color";
|
||||
|
||||
/// 打开着的 NVS 句柄。
|
||||
pub struct Settings {
|
||||
nvs: EspNvs<NvsDefault>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// 打开(必要时创建)命名空间。
|
||||
pub fn new(partition: EspDefaultNvsPartition) -> Result<Self> {
|
||||
Ok(Self {
|
||||
nvs: EspNvs::new(partition, NAMESPACE, true)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 定出这次该用哪个颜色,并把它写回 NVS。
|
||||
///
|
||||
/// 规则:**编译期注入的 `ARMOR_TEAM` 是权威**。换队伍只要重刷固件,不用先擦
|
||||
/// NVS;NVS 里的值只用来记住当前状态(也给以后加"手动换色"留位置)。两者
|
||||
/// 不一致时以 `ARMOR_TEAM` 为准并覆盖,日志里会说一声,免得现场纳闷为什么
|
||||
/// 灯色变了。
|
||||
pub fn resolve_color(&self, team: u8) -> Result<Rgb> {
|
||||
let expected = Rgb::for_team(team);
|
||||
let stored_team = self.nvs.get_u8(KEY_TEAM)?;
|
||||
let stored_color = self.nvs.get_u32(KEY_COLOR)?;
|
||||
|
||||
let color = match (stored_team, stored_color) {
|
||||
(Some(stored), Some(raw)) if stored == team => Rgb::from_u32(raw),
|
||||
(Some(stored), Some(raw)) => {
|
||||
info!(
|
||||
"ARMOR_TEAM={team} overrides the stored team {stored} (colour #{raw:06X}); rewriting NVS"
|
||||
);
|
||||
expected
|
||||
}
|
||||
_ => {
|
||||
info!(
|
||||
"first boot as team {team}: storing colour #{:06X}",
|
||||
expected.to_u32()
|
||||
);
|
||||
expected
|
||||
}
|
||||
};
|
||||
|
||||
self.nvs.set_u8(KEY_TEAM, team)?;
|
||||
self.nvs.set_u32(KEY_COLOR, color.to_u32())?;
|
||||
|
||||
Ok(color)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user