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
+7
View File
@@ -0,0 +1,7 @@
# 这个 crate 在主机上跑单元测试,不交叉编译。
#
# 它坐在 armor/ 目录树里,cargo 会向上继承 armor/.cargo/config.toml 的 RISC-V
# 目标与 ldproxy linker —— 把主机测试编译成 ESP32-C3 固件会直接失败在链接上。
# 把 target 覆盖回 host triple 就够了,别的键对非 ESP 目标没有影响。
[build]
target = "aarch64-apple-darwin"
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "armor-protocol"
version = "0.1.0"
edition = "2021"
# 只依赖错误检查用的东西:规则层是纯计算(血量、去抖、按键状态机),
# 这样它能在 PC 上跑单元测试,不需要 ESP32-C3 硬件。
[dependencies]
protocol = { path = "../../protocol" }
+872
View File
@@ -0,0 +1,872 @@
//! 装甲板规则层:板位、整车血量、受击判定、按键状态机、上报帧。
//!
//! # 为什么和固件分开
//!
//! 这一层全是纯计算:一次采样算不算新受击、扣几滴血、灯带这一刻该显示什么颜色、
//! 按一下键算短按还是长按。这些恰好是最容易写错、又最不适合"烧上去看现象"的部分
//! —— 在 ESP32-C3 上,受击是几微秒的压电片尖峰,现场根本没法复现"冷却期内的第二次
//! 采样被误判"这类 bug。放在这里就能用 `Duration` 喂假时间来测。
//!
//! 固件侧(`armor-firmware`)只负责把外设接到这层:ADC 采样值、GPIO 电平、毫秒
//! 时基、UDP 发送。
//!
//! # 硬件形态(一主三从)
//!
//! **只有主板一颗 MCU**:三块"从"装甲板里没有单片机,只有一路 WS2812 和一路压电片,
//! 直接接到主板上。所以这一层同时管四路:
//!
//! | 板位 | WS2812 | 压电片 ADC |
//! | --- | --- | --- |
//! | 前 front | GPIO10 | ADC1_CH0 (GPIO0) |
//! | 后 rear | GPIO5 | ADC1_CH4 (GPIO4) |
//! | 左 left | GPIO6 | ADC1_CH3 (GPIO3) |
//! | 右 right | GPIO7 | ADC1_CH1 (GPIO1) |
//!
//! # 规则(已与本项目裁判端对齐)
//!
//! - 整车一条血量:初始 100,每次受击 −3,减到 0 为止**不变负**,血量 0 时四路灯带
//! 全黑("死了全黑")。
//! - 受击时**只有被打的那一路**灯带全黑 50 ms,其余三路不动;50 ms 同时是同一板位的
//! 重复判定冷却。
//! - 颜色由队伍决定:队伍一红、队伍二蓝(RoboMaster 惯例)。
//! - 血量账在板子上算,按 [`protocol::ArmorHealth`] 上报给本项目裁判终端(UDP 5601)。
//! 整车一条血量 → 四条上报的 `hp` 相同,`index` 分别是 0..3,裁判端已有的四格血量
//! 界面无需改动。
use std::time::Duration;
pub use protocol::{id_from_mac, ArmorHealth, ARMOR_PORT, BROADCAST_ADDR, MsgType};
/// 队伍一。
pub const TEAM_1: u8 = 0;
/// 队伍二。
pub const TEAM_2: u8 = 1;
/// 一块装甲板。
///
/// 判别值就是上报里的 `index`(0..3),所以 `Plate::Front.index() == 0` 与裁判端
/// 的四格顺序一一对应。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum Plate {
/// 前装甲板,WS2812 = GPIO10,压电片 = ADC1_CH0。
Front = 0,
/// 后装甲板,WS2812 = GPIO5,压电片 = ADC1_CH4。
Rear = 1,
/// 左装甲板,WS2812 = GPIO6,压电片 = ADC1_CH3。
Left = 2,
/// 右装甲板,WS2812 = GPIO7,压电片 = ADC1_CH1。
Right = 3,
}
impl Plate {
/// 按 index 顺序的四块板,也是灯带刷新和 ADC 通道表里用的顺序。
pub const ALL: [Plate; 4] = [Plate::Front, Plate::Rear, Plate::Left, Plate::Right];
/// 上报里的 `index`。
#[must_use]
pub const fn index(self) -> u8 {
self as u8
}
/// 日志里用的短名(英文,和本仓库其余固件的日志一致)。
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Plate::Front => "front",
Plate::Rear => "rear",
Plate::Left => "left",
Plate::Right => "right",
}
}
/// 从 `index` 还原,越界返回 `None`。
#[must_use]
pub const fn from_index(value: u8) -> Option<Plate> {
match value {
0 => Some(Plate::Front),
1 => Some(Plate::Rear),
2 => Some(Plate::Left),
3 => Some(Plate::Right),
_ => None,
}
}
/// 数组下标。
#[must_use]
const fn slot(self) -> usize {
self as usize
}
}
/// 颜色,`0x00RRGGBB`(与参考实现里 `Adafruit_NeoPixel::Color(r, g, b)` 一致)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl Rgb {
/// 队伍一的颜色。
pub const RED: Rgb = Rgb(0xFF, 0x00, 0x00);
/// 队伍二的颜色。
pub const BLUE: Rgb = Rgb(0x00, 0x00, 0xFF);
/// 全黑,也就是熄灭。
pub const BLACK: Rgb = Rgb(0x00, 0x00, 0x00);
/// 队伍对应的颜色:队伍二蓝,其余(含误配的值)红。
#[must_use]
pub const fn for_team(team: u8) -> Rgb {
if team == TEAM_2 {
Rgb::BLUE
} else {
Rgb::RED
}
}
/// 打包成 `0x00RRGGBB`,用于写 NVS 或日志。
#[must_use]
pub const fn to_u32(self) -> u32 {
((self.0 as u32) << 16) | ((self.1 as u32) << 8) | (self.2 as u32)
}
/// 从 `0x00RRGGBB` 还原。
#[must_use]
pub const fn from_u32(value: u32) -> Rgb {
Rgb(
((value >> 16) & 0xFF) as u8,
((value >> 8) & 0xFF) as u8,
(value & 0xFF) as u8,
)
}
/// WS2812 的线上字节序:GRB,MSB first。
///
/// 交错顺序错了不会报错,只会显示成另一种颜色(红蓝互换),所以单独有测试钉住。
#[must_use]
pub const fn to_grb(self) -> [u8; 3] {
[self.1, self.0, self.2]
}
}
/// 一条灯带这一刻要显示的内容。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strip {
/// 正常颜色。
Color(Rgb),
/// 全黑:受击的 50 ms 窗口内,或者整车血量已经归零。
Black,
}
impl Strip {
/// 是否熄灭。
#[must_use]
pub const fn is_black(self) -> bool {
matches!(self, Strip::Black)
}
/// 实际颜色,`Black` 就是 [`Rgb::BLACK`]。
#[must_use]
pub const fn color(self) -> Rgb {
match self {
Strip::Color(rgb) => rgb,
Strip::Black => Rgb::BLACK,
}
}
}
/// 规则参数。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rules {
/// 整车初始血量。
pub max_hp: u16,
/// 每次受击扣多少血。
pub damage_per_hit: u16,
/// 受击后该路灯带全黑的时长。
pub hit_blink: Duration,
/// 同一块板重复受击的抑制窗口(压电片一次击打会在多个采样点上过阈值)。
pub hit_cooldown: Duration,
/// 原始 12 bit ADC 采样值超过它才算受击。
pub threshold: u16,
}
impl Default for Rules {
/// 现场取值:100 血、每击 −3、50 ms 变暗/冷却、阈值 2000(与参考实现一致)。
fn default() -> Self {
Self {
max_hp: 100,
damage_per_hit: 3,
hit_blink: Duration::from_millis(50),
hit_cooldown: Duration::from_millis(50),
threshold: 2000,
}
}
}
/// 一次被计入的受击。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HitEvent {
/// 哪块装甲板。
pub plate: Plate,
/// 触发这次判定的原始 ADC 值,用来现场校准阈值。
pub raw: u16,
/// 扣血之后的整车血量。
pub hp: u16,
/// 这一击是否把血量打到 0。
pub dead: bool,
/// 判定时刻。
pub at: Duration,
}
/// 装甲板状态机:血量、受击冷却、灯带输出。
#[derive(Debug, Clone)]
pub struct Armor {
rules: Rules,
color: Rgb,
hp: u16,
hits: u32,
plate_hits: [u32; 4],
last_hit: [Option<Duration>; 4],
}
impl Armor {
/// 满血、`color` 为正常色。
#[must_use]
pub fn new(rules: Rules, color: Rgb) -> Self {
Self {
rules,
color,
hp: rules.max_hp,
hits: 0,
plate_hits: [0; 4],
last_hit: [None; 4],
}
}
/// 规则参数。
#[must_use]
pub fn rules(&self) -> Rules {
self.rules
}
/// 当前整车血量。
#[must_use]
pub fn hp(&self) -> u16 {
self.hp
}
/// 血量是否归零。
#[must_use]
pub fn dead(&self) -> bool {
self.hp == 0
}
/// 累计计入的受击次数。
#[must_use]
pub fn hits(&self) -> u32 {
self.hits
}
/// 某块装甲板各自被计中的次数。
#[must_use]
pub fn plate_hits(&self, plate: Plate) -> u32 {
self.plate_hits[plate.slot()]
}
/// 四块板各自被计中的次数,按 index 排列。
#[must_use]
pub fn plate_hits_all(&self) -> [u32; 4] {
self.plate_hits
}
/// 正常显示颜色。
#[must_use]
pub fn color(&self) -> Rgb {
self.color
}
/// 换正常显示颜色(队伍变了、或从 NVS 读回)。
pub fn set_color(&mut self, color: Rgb) {
self.color = color;
}
/// 喂一个 ADC 采样值。
///
/// 只有"过阈值 + 该板不在冷却期内"才计入受击并扣血;其余返回 `None`。
/// 一次击打在 40 kHz 采样下会连续多个点过阈值,冷却窗口就是把这些点算成一次。
pub fn on_sample(&mut self, plate: Plate, raw: u16, now: Duration) -> Option<HitEvent> {
if raw <= self.rules.threshold {
return None;
}
let slot = plate.slot();
if let Some(last) = self.last_hit[slot] {
if now.saturating_sub(last) < self.rules.hit_cooldown {
return None;
}
}
self.last_hit[slot] = Some(now);
self.hits = self.hits.saturating_add(1);
self.plate_hits[slot] = self.plate_hits[slot].saturating_add(1);
let before = self.hp;
self.hp = self.hp.saturating_sub(self.rules.damage_per_hit);
Some(HitEvent {
plate,
raw,
hp: self.hp,
// 是"这一击打死了",不是"打完之后已经死了":已经 0 血以后继续挨打不该
// 每次都报一次死亡,否则现场日志会被刷屏、上报也会抖。
dead: self.hp == 0 && before > 0,
at: now,
})
}
/// 这块装甲板这一刻该显示什么。
///
/// 整车死亡优先于一切:死了四路全黑。否则只有被打的那一路在 50 ms 窗口内全黑。
#[must_use]
pub fn strip(&self, plate: Plate, now: Duration) -> Strip {
if self.dead() {
return Strip::Black;
}
if let Some(last) = self.last_hit[plate.slot()] {
if now.saturating_sub(last) < self.rules.hit_blink {
return Strip::Black;
}
}
Strip::Color(self.color)
}
/// 四路灯带这一刻的输出,按 index 排列 —— 固件照着它刷 RMT。
#[must_use]
pub fn strips(&self, now: Duration) -> [Strip; 4] {
Plate::ALL.map(|plate| self.strip(plate, now))
}
/// 复位成"新一局":满血、清计数、清冷却(颜色不动)。
pub fn reset(&mut self) {
self.hp = self.rules.max_hp;
self.hits = 0;
self.plate_hits = [0; 4];
self.last_hit = [None; 4];
}
/// 要上报给裁判端的四条记录。
///
/// 整车一条血量,所以四条 `hp` 相同、`index` 分别是 0..3;裁判端按
/// [`protocol::ArmorHealth::team`] 把这里的数据归到对应队伍。
#[must_use]
pub fn reports(&self, team: u8) -> [ArmorHealth; 4] {
Plate::ALL.map(|plate| ArmorHealth {
team,
index: plate.index(),
hp: self.hp,
})
}
/// 四条记录编码成可以直接 `sendto` 的字节串。
#[must_use]
pub fn datagrams(&self, team: u8) -> Vec<Vec<u8>> {
self.datagrams_from(team, 0)
}
/// 同 [`datagrams`](Self::datagrams),但帧头带上板子自己的 id(由 MAC 派生)。
///
/// 用 `sender_id` 而不是默认的 0:0 是裁判终端的保留 id,抓包时一块装甲板发的
/// 包不该长得像裁判端发的。
#[must_use]
pub fn datagrams_from(&self, team: u8, sender_id: u32) -> Vec<Vec<u8>> {
self.reports(team)
.iter()
.map(|report| report.encode_from(sender_id))
.collect()
}
}
/// 上报节奏:血量一变立刻发,没变就按心跳周期重发。
///
/// 裁判终端只看得到最后一条,"每 200 ms 重发"是它判断板子还在线的唯一依据 ——
/// 没有心跳,一次丢包就会让界面显示成"装甲板未接入"。
#[derive(Debug, Clone)]
pub struct ReportSchedule {
heartbeat: Duration,
last: Option<(u16, Duration)>,
}
impl ReportSchedule {
/// `heartbeat` 是血量不变时的重发周期。
#[must_use]
pub fn new(heartbeat: Duration) -> Self {
Self {
heartbeat,
last: None,
}
}
/// 这一刻该不该发。
#[must_use]
pub fn due(&self, hp: u16, now: Duration) -> bool {
match self.last {
None => true,
Some((last_hp, last_at)) => {
last_hp != hp || now.saturating_sub(last_at) >= self.heartbeat
}
}
}
/// 记下"这一刻用这个血量发过了"。
pub fn mark_sent(&mut self, hp: u16, now: Duration) {
self.last = Some((hp, now));
}
}
/// 按键事件。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ButtonEvent {
/// 什么都没发生。
None,
/// 短按(按下后很快松开):固件用它打印一次状态并立刻重发上报。
Click,
/// 长按(按住到阈值):固件用它复位血量,也就是"新一局"。
LongPress,
}
/// 按键状态机:去抖 + 短按 / 长按,事件只发一次。
///
/// 参考实现移植了 OneButton 2.6.1 的整套状态机(它还带双击、多击、idle 回调)。
/// 这里只有两种动作,所以按同样的语义重写了一个本地版本:电平去抖,长按在按住
/// 到阈值时立刻产生一次且不重复,松开时若在短按窗口内则产生一次短按。
#[derive(Debug, Clone)]
pub struct Button {
debounce: Duration,
click_max: Duration,
long_press: Duration,
stable_pressed: bool,
candidate: bool,
candidate_since: Duration,
pressed_since: Duration,
long_fired: bool,
}
impl Default for Button {
/// BOOT 键(GPIO9,低有效)的现场取值:去抖 20 ms、短按 <500 ms、长按 ≥500 ms。
fn default() -> Self {
Self::new(
Duration::from_millis(20),
Duration::from_millis(500),
Duration::from_millis(500),
)
}
}
impl Button {
/// 自定义时间参数,主要给测试用。
#[must_use]
pub fn new(debounce: Duration, click_max: Duration, long_press: Duration) -> Self {
Self {
debounce,
click_max,
long_press,
stable_pressed: false,
candidate: false,
candidate_since: Duration::ZERO,
pressed_since: Duration::ZERO,
long_fired: false,
}
}
/// 喂一次电平(已换算成"是否按下")与当前时刻,最多产生一个事件。
pub fn tick(&mut self, pressed: bool, now: Duration) -> ButtonEvent {
let mut event = ButtonEvent::None;
if pressed != self.stable_pressed {
if self.candidate != pressed {
// 抖动的新电平:重新开始计稳定时间。
self.candidate = pressed;
self.candidate_since = now;
} else if now.saturating_sub(self.candidate_since) >= self.debounce {
self.stable_pressed = pressed;
if pressed {
self.pressed_since = now;
self.long_fired = false;
} else if !self.long_fired
&& now.saturating_sub(self.pressed_since) < self.click_max
{
event = ButtonEvent::Click;
}
}
} else {
// 电平回到稳定值,候选作废。
self.candidate = pressed;
}
if event == ButtonEvent::None
&& self.stable_pressed
&& !self.long_fired
&& now.saturating_sub(self.pressed_since) >= self.long_press
{
self.long_fired = true;
event = ButtonEvent::LongPress;
}
event
}
}
/// 解析编译期注入的 `ARMOR_TEAM`。
///
/// 只接受 "0" 与 "1":把队伍认错会让裁判端把血记到对手头上,现场很难发现,
/// 所以宁可启动就报错。
#[must_use]
pub fn parse_team(value: &str) -> Option<u8> {
match value.trim() {
"0" => Some(TEAM_1),
"1" => Some(TEAM_2),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ms(v: u64) -> Duration {
Duration::from_millis(v)
}
fn armor() -> Armor {
Armor::new(Rules::default(), Rgb::for_team(TEAM_1))
}
// ---------------------------------------------------------------- 血量 --
#[test]
fn starts_at_full_hp_and_is_not_dead() {
let a = armor();
assert_eq!(a.hp(), 100);
assert!(!a.dead());
assert_eq!(a.hits(), 0);
}
#[test]
fn each_hit_costs_three_and_floors_at_zero() {
let mut a = armor();
let mut hits = 0;
// 100 / 3 = 33.33,所以第 34 击才归零。
for i in 0..34 {
let at = ms(i * 1000);
a.on_sample(Plate::Front, 2500, at).expect("hit should count");
hits += 1;
}
assert_eq!(hits, 34);
assert_eq!(a.hp(), 0);
assert!(a.dead());
// 已经归零之后继续打,不会变负,也不算"新的一击导致死亡"。
let event = a
.on_sample(Plate::Rear, 2500, ms(40_000))
.expect("still counts as a hit");
assert_eq!(event.hp, 0);
assert!(!event.dead, "0 -> 0 不是新的死亡事件");
assert_eq!(a.hp(), 0);
}
#[test]
fn sample_below_threshold_is_not_a_hit() {
let mut a = armor();
assert!(a.on_sample(Plate::Front, 2000, ms(0)).is_none(), "阈值是严格大于");
assert!(a.on_sample(Plate::Front, 1999, ms(0)).is_none());
assert!(a.on_sample(Plate::Front, 2001, ms(0)).is_some());
assert_eq!(a.hp(), 97);
}
#[test]
fn per_plate_hit_counts_are_separate() {
let mut a = armor();
a.on_sample(Plate::Front, 3000, ms(0));
a.on_sample(Plate::Left, 3000, ms(1000));
a.on_sample(Plate::Left, 3000, ms(2000));
assert_eq!(a.plate_hits(Plate::Front), 1);
assert_eq!(a.plate_hits(Plate::Left), 2);
assert_eq!(a.plate_hits(Plate::Rear), 0);
assert_eq!(a.plate_hits(Plate::Right), 0);
assert_eq!(a.hits(), 3);
// 整车一条血量:三击共扣 9。
assert_eq!(a.hp(), 91);
}
// ------------------------------------------------------------ 受击去抖 --
#[test]
fn repeat_samples_inside_the_cooldown_are_one_hit() {
let mut a = armor();
// 40 kHz 采样下,一次击打会连续几十个点过阈值。
for i in 0..49 {
a.on_sample(Plate::Left, 2600, ms(i));
}
assert_eq!(a.plate_hits(Plate::Left), 1, "49 ms 内的连续采样算一次");
assert_eq!(a.hp(), 97);
}
#[test]
fn a_sample_after_the_cooldown_is_a_new_hit() {
let mut a = armor();
a.on_sample(Plate::Left, 2600, ms(0));
assert!(a.on_sample(Plate::Left, 2600, ms(49)).is_none());
assert!(a.on_sample(Plate::Left, 2600, ms(50)).is_some(), "50 ms 是边界,算新受击");
assert_eq!(a.plate_hits(Plate::Left), 2);
assert_eq!(a.hp(), 94);
}
#[test]
fn cooldown_is_per_plate_not_global() {
let mut a = armor();
// 参考实现用全局冷却:一块板挨打后,别块板在这 50 ms 内的击打会被吃掉。
// 每板独立冷却才是"每面各自挨打"的正确语义。
a.on_sample(Plate::Front, 3000, ms(0));
assert!(a.on_sample(Plate::Left, 3000, ms(1)).is_some());
assert!(a.on_sample(Plate::Rear, 3000, ms(2)).is_some());
assert_eq!(a.hits(), 3);
assert_eq!(a.hp(), 91);
}
// ------------------------------------------------------------ 灯带输出 --
#[test]
fn hit_plate_goes_black_for_fifty_ms_only() {
let mut a = armor();
a.on_sample(Plate::Left, 3000, ms(1000));
assert!(a.strip(Plate::Left, ms(1000)).is_black(), "受击瞬间");
assert!(a.strip(Plate::Left, ms(1049)).is_black(), "窗口内");
assert_eq!(
a.strip(Plate::Left, ms(1050)),
Strip::Color(Rgb::RED),
"窗口结束恢复原色"
);
// 其余三路全程不受影响 —— 这正是每面独立灯带的意义。
for plate in [Plate::Front, Plate::Rear, Plate::Right] {
assert_eq!(a.strip(plate, ms(1000)), Strip::Color(Rgb::RED));
assert_eq!(a.strip(plate, ms(1049)), Strip::Color(Rgb::RED));
}
}
#[test]
fn dead_blacks_out_every_strip() {
let mut a = armor();
let last = 34 * 1000;
for i in 0..34 {
a.on_sample(Plate::Front, 3000, ms(i * 1000));
}
assert!(a.dead());
for plate in Plate::ALL {
assert!(a.strip(plate, ms(last)).is_black(), "{plate:?} 死了要全黑");
}
// 冷却窗口早就过了,仍然全黑:死优先于一切。
assert!(a.strip(Plate::Left, ms(last + 10_000)).is_black());
}
#[test]
fn strips_returns_all_four_in_index_order() {
let mut a = armor();
a.on_sample(Plate::Rear, 3000, ms(0));
let strips = a.strips(ms(0));
assert_eq!(strips.len(), 4);
assert!(strips[Plate::Rear.index() as usize].is_black());
assert_eq!(strips[Plate::Front.index() as usize].color(), Rgb::RED);
}
// ---------------------------------------------------------------- 复位 --
#[test]
fn reset_restores_full_hp_and_keeps_the_colour() {
let mut a = armor();
a.on_sample(Plate::Front, 3000, ms(0));
a.on_sample(Plate::Right, 3000, ms(100));
a.set_color(Rgb::BLUE);
a.reset();
assert_eq!(a.hp(), 100);
assert_eq!(a.hits(), 0);
assert_eq!(a.plate_hits_all(), [0; 4]);
assert_eq!(a.color(), Rgb::BLUE);
assert_eq!(a.strip(Plate::Front, ms(100)), Strip::Color(Rgb::BLUE));
}
// ---------------------------------------------------------------- 上报 --
#[test]
fn reports_carry_the_shared_hp_on_all_four_indices() {
let mut a = armor();
a.on_sample(Plate::Front, 3000, ms(0));
a.on_sample(Plate::Front, 3000, ms(1000));
let reports = a.reports(TEAM_2);
assert_eq!(reports.len(), 4);
for (i, report) in reports.iter().enumerate() {
assert_eq!(report.team, TEAM_2);
assert_eq!(report.index, i as u8, "四格按 index 排列");
assert_eq!(report.hp, 94, "整车一条血量,四格同值");
}
}
#[test]
fn datagrams_round_trip_through_the_shared_protocol() {
let mut a = armor();
a.on_sample(Plate::Left, 3000, ms(0));
let datagrams = a.datagrams(TEAM_1);
assert_eq!(datagrams.len(), 4);
for (i, bytes) in datagrams.iter().enumerate() {
// 裁判端就是用这个函数解回来的,编码错了这里立刻挂。
let decoded = ArmorHealth::decode(bytes).expect("裁判端要能解出来");
assert_eq!(decoded.team, TEAM_1);
assert_eq!(decoded.index, i as u8);
assert_eq!(decoded.hp, 97);
assert_eq!(bytes.len(), protocol::HEADER_LEN + 4);
assert_eq!(bytes[4], MsgType::ArmorHealth as u8);
}
}
#[test]
fn datagrams_can_carry_the_board_id() {
let a = armor();
let datagrams = a.datagrams_from(TEAM_2, 0xABCD_1234);
for bytes in &datagrams {
let header = protocol::decode_header(bytes).expect("帧头可解");
assert_eq!(header.sender_id, 0xABCD_1234);
assert_eq!(ArmorHealth::decode(bytes).map(|r| r.team), Some(TEAM_2));
}
// 默认入口仍然是裁判终端的保留 id 0。
let plain = a.datagrams(TEAM_2);
assert_eq!(
protocol::decode_header(&plain[0]).map(|h| h.sender_id),
Some(0)
);
}
#[test]
fn report_schedule_fires_immediately_then_heartbeats() {
let mut s = ReportSchedule::new(ms(200));
assert!(s.due(100, ms(0)), "第一次一定发");
s.mark_sent(100, ms(0));
assert!(!s.due(100, ms(199)));
assert!(s.due(100, ms(200)), "心跳到期");
// 血量变化不等心跳。
assert!(s.due(97, ms(201)));
s.mark_sent(97, ms(201));
assert!(!s.due(97, ms(250)));
assert!(s.due(94, ms(251)));
}
// ---------------------------------------------------------------- 按键 --
#[test]
fn button_needs_the_debounce_window() {
let mut b = Button::new(ms(20), ms(500), ms(500));
assert_eq!(b.tick(true, ms(0)), ButtonEvent::None);
assert_eq!(b.tick(false, ms(5)), ButtonEvent::None, "5 ms 的毛刺不算按下");
assert_eq!(b.tick(true, ms(10)), ButtonEvent::None);
// 从 10 ms 起连续按下到 30 ms 才稳定。
assert_eq!(b.tick(true, ms(29)), ButtonEvent::None);
assert_eq!(b.tick(true, ms(30)), ButtonEvent::None, "按下本身不产生事件");
}
#[test]
fn short_press_is_a_click() {
let mut b = Button::new(ms(20), ms(500), ms(500));
b.tick(true, ms(0));
b.tick(true, ms(20)); // 稳定按下
b.tick(false, ms(100));
assert_eq!(b.tick(false, ms(120)), ButtonEvent::Click);
// 只发一次。
assert_eq!(b.tick(false, ms(200)), ButtonEvent::None);
}
#[test]
fn holding_produces_one_long_press_and_no_click_on_release() {
let mut b = Button::new(ms(20), ms(500), ms(500));
b.tick(true, ms(0));
b.tick(true, ms(20)); // 稳定按下
assert_eq!(b.tick(true, ms(400)), ButtonEvent::None);
assert_eq!(b.tick(true, ms(520)), ButtonEvent::LongPress, "按住到阈值");
assert_eq!(b.tick(true, ms(900)), ButtonEvent::None, "不回弹重复长按");
b.tick(false, ms(1000));
assert_eq!(
b.tick(false, ms(1020)),
ButtonEvent::None,
"长按过的这一下不该再算短按"
);
}
// ---------------------------------------------------------------- 其它 --
#[test]
fn colours_match_the_robo_master_convention() {
assert_eq!(Rgb::for_team(TEAM_1), Rgb::RED);
assert_eq!(Rgb::for_team(TEAM_2), Rgb::BLUE);
// 存 NVS / 打印与 Adafruit_NeoPixel::Color 的打包一致。
assert_eq!(Rgb::RED.to_u32(), 0x00FF_0000);
assert_eq!(Rgb::BLUE.to_u32(), 0x0000_00FF);
assert_eq!(Rgb::from_u32(0x0012_3456), Rgb(0x12, 0x34, 0x56));
}
#[test]
fn ws2812_byte_order_is_grb() {
assert_eq!(Rgb::RED.to_grb(), [0x00, 0xFF, 0x00]);
assert_eq!(Rgb::BLUE.to_grb(), [0x00, 0x00, 0xFF]);
assert_eq!(Rgb(0x11, 0x22, 0x33).to_grb(), [0x22, 0x11, 0x33]);
}
#[test]
fn plate_indices_are_stable() {
assert_eq!(Plate::Front.index(), 0);
assert_eq!(Plate::Rear.index(), 1);
assert_eq!(Plate::Left.index(), 2);
assert_eq!(Plate::Right.index(), 3);
for plate in Plate::ALL {
assert_eq!(Plate::from_index(plate.index()), Some(plate));
}
assert_eq!(Plate::from_index(4), None);
}
#[test]
fn team_env_parsing_rejects_anything_but_zero_and_one() {
assert_eq!(parse_team("0"), Some(TEAM_1));
assert_eq!(parse_team("1"), Some(TEAM_2));
assert_eq!(parse_team(" 1\n"), Some(TEAM_2));
assert_eq!(parse_team("2"), None);
assert_eq!(parse_team("team1"), None);
assert_eq!(parse_team(""), None);
}
}