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,7 @@
|
||||
# 这个 crate 在主机上跑单元测试,不交叉编译。
|
||||
#
|
||||
# 它坐在 armor/ 目录树里,cargo 会向上继承 armor/.cargo/config.toml 的 RISC-V
|
||||
# 目标与 ldproxy linker —— 把主机测试编译成 ESP32-C3 固件会直接失败在链接上。
|
||||
# 把 target 覆盖回 host triple 就够了,别的键对非 ESP 目标没有影响。
|
||||
[build]
|
||||
target = "aarch64-apple-darwin"
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "armor-protocol"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# 只依赖错误检查用的东西:规则层是纯计算(血量、去抖、按键状态机),
|
||||
# 这样它能在 PC 上跑单元测试,不需要 ESP32-C3 硬件。
|
||||
[dependencies]
|
||||
protocol = { path = "../../protocol" }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user