Files
trail-mate/docs/TEAM.md
2026-02-04 11:06:53 +08:00

2875 lines
72 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 你这个 TEAM 业务到底在做什么(我现在能总结到“可写 TeamEvent”的程度)
## 1) Team 的定义(产品级一句话)
**Team = 临时形成、目标驱动、随时解散的户外行动单元。**
它不是“群聊”,也不是“普通位置共享”,而是让一小群人在无网络环境下持续回答三件事:
1. **我们是否还在一起**(队伍是否分散/掉队/走偏)
2. **接下来要做什么**(集合、等待、求助等“行动共识”)
3. **有没有人出问题**(静止、失联、电量低、SOS
---
## 2) Team 的核心能力(你已经确定的)
### A. 临时组队(Create / Join / Disband
* Leader 一键建队
* 成员通过短码/QR 拿到 **TeamKey** 加入
* Team 可以随时解散(解散即密钥销毁)
**这一套你已经做成状态机:Create → Active → Rotate → Disband**
---
### B. 强隐私位置共享(你的差异化关键)
你明确在意:
> **位置广播出去后,只有 Team 成员能解析成功;即便 Team channel 配置外传,也看不懂。**
因此你采用两层保护:
* L1Meshtastic **Channel** 加密(CH-ENC
* L2:应用层 **E2EE**TeamKey 派生的 PosKey/WpKeyTeamEncrypted Envelope
并且你倾向新增自己的 PortNum:
* `TEAM_POSITION_APP`
* `TEAM_WAYPOINT_APP`
* `TEAM_TRACK_APP`
* 管理类 `TEAM_MGMT_APP`
---
### C. Team 内的“行动指令”(Preset / Command
你提出的差异化:
* 向队友发 **预设指令**(集合、等待、求助…)
* 指令携带必要参数(例如集合地点 waypoint)
* 指令是“事件”,不靠打字聊天
> 注意:你前面没要求我把它做成完整协议,但你已经明确这是 Team 的重要业务能力。
---
### D. Team 生命周期复盘(你现在要做的)
你希望**整个组队过程可复盘**,包括生命周期中所有事件落地。
所以我们引入:
* SD 日志(append-only
* LogRecordAEAD + CRC
* snapshot 加速(可选)
* LogKey(独立于 TeamKey,可配置是否保留)
---
## 3) Team 生命周期里“会发生哪些事件”(这直接决定 TeamEvent)
为了写 `TeamEvent` 的 protobuf,你需要先承认:
**TeamEvent 不等于“所有无线包”**,而是**业务层发生的事情**,能用于复盘与解释行为。
你当前业务可以归纳为 5 类事件:
### 1) 生命周期事件(Lifecycle
* Team 被创建(Leader 本地)
* 广播可发现(ADVERTISE
* 有人请求加入(JOIN_REQUEST
* Leader 接受并下发配置(JOIN_ACCEPT
* 成员确认入队(JOIN_CONFIRM
* Team 状态同步(TEAM_STATUS
* 密钥轮换(KEY_ROTATE
* 解散/离开(DISBAND / LEAVE
这些事件用于回答:
* “队伍什么时候开始/结束”
* “谁在队里、什么时候加入/离开”
* “什么时候换过密钥、为什么换”
---
### 2) 位置与轨迹事件(Telemetry
* 收到某成员位置(POSITION_RX
* 收到航点/集合点(WAYPOINT_RX
* 收到轨迹批量点(TRACK_RX
这些事件用于复盘:
* 人在什么时候到过哪里
* 队伍是否分散、何时重新聚拢
> 位置事件会有降采样策略,否则日志爆炸。
---
### 3) 指令事件(Command
* 集合指令(含地点 waypoint)
* 等待/暂停
* 求助(SOS,含位置/严重级别)
这些事件用于复盘:
* “谁下达过行动共识”
* “队伍如何响应”
---
### 4) 连接与可见性事件(Presence/Health
这类你在产品目标里明确需要,但我们之前没有把它细化成协议字段,现在总结出来:
* 某成员“暂时不可见”(超时没收到位置)
* 恢复可见
* 电量低(如果你决定上报)
* 静止过久(如果你决定做本机推断事件)
这些事件用于复盘:
* “为什么当时地图上看不到人”
* “掉队是不是因为设备没电/失联”
> 这类事件可以是“本机推断”,不一定来自无线包。
---
### 5) 安全/异常事件(Security/Diagnostics
你要做强隐私承诺,就必须能复盘“失败原因”,否则用户只会觉得 bug。
* 解密失败(DECRYPT_FAIL
* key_id 不匹配(KEY_MISMATCH
* 重放丢弃(REPLAY_DROP
* 包格式错误(DECODE_FAIL
这些事件用于复盘:
* “当时收到了包但没显示”的原因
---
## 4) 我现在对你 TEAM 业务的总结边界(我确定 vs 我不会擅自假设)
### 我已经可以确定的(来自你明确提出并反复确认的)
* Team 是临时行动单元,有 Create/Join/Disband
* Team 内位置共享 + waypoint
* 强隐私:channel 隔离 + 应用层 E2EE
* 需要记录整个生命周期事件并可复盘
* 你愿意新增自定义 PortNumTEAM_*_APP
### 我不会擅自补的(你没冻结)
* 指令种类到底有哪些、每种指令带哪些参数(我能提建议,但需要你确认)
* 成员健康字段是否要上报(电量、能力、角色)
* 是否要求“全队一致复盘”(你目前更像本地复盘/Leader复盘)
---
# Team 建队 / 加入 报文交互流程(强隐私版本)
## 参与方
```
L = Leader / 建队者设备
M = Member / 队员设备
O = Others / 旁观设备(非 Team 成员)
```
## 信道
```
CH0 = 默认公共频道(Primary Channel
CHT = Team 私有频道(新建 / 分配的 channel index
```
## 安全层
```
CH-ENC = Meshtastic 按 Channel 的链路层加密
E2EE = TeamKey 派生的应用层端到端加密
Envelope: TeamEncrypted
```
---
## 总览:时序线图(ASCII
```
时间 ↓
L (Leader) M (Member) O (Others)
────────────────────────────────────────────────────────────────────────
(0) 本地建队(无报文)
L: 生成 TeamKey
L: TeamId = Trunc(Hash(TeamKey))
L: 选择 / 创建 CHT
L: ChannelPSK = KDF(TeamKey, "channel-psk")
L: MgmtKey = KDF(TeamKey, "mgmt")
L: PosKey = KDF(TeamKey, "pos")
L: WpKey = KDF(TeamKey, "wp")
────────────────────────────────────────────────────────────────────────
(1) Team 可被“发现”(不泄密)
CH0
L ─────── TEAM_ADVERTISE ───────▶ *
{ team_id, join_hint?, channel_index?, nonce/ts }
O: 只能知道“附近有一个 Team”
无法获得任何密钥或位置
────────────────────────────────────────────────────────────────────────
(2) 队员请求加入(无密钥)
CH0
M ───── TEAM_JOIN_REQUEST ─────▶ L
{ team_id, member_pub?, nonce/ts }
────────────────────────────────────────────────────────────────────────
(3) Leader 接受加入并下发 Team 配置
CH0
L ───── TEAM_JOIN_ACCEPT ─────▶ M
{
team_id,
payload = E2EE(MgmtKey, {
channel_index = CHT,
channel_psk = ChannelPSK,
key_id = current_key_id,
team_params? (freq / precision / timeout)
}),
nonce/ts
}
注:
- 此时 M **必须已经通过 UI 获得 TeamKey**
(短码 / QR / 近距离方式)
- 否则无法解密 MgmtKey / payload
────────────────────────────────────────────────────────────────────────
(4) 队员本地切换到 Team
M: 保存 TeamKey
M: 派生 MgmtKey / PosKey / WpKey
M: 保存 CHT + ChannelPSK
M: 切换到 CHT
────────────────────────────────────────────────────────────────────────
(5) 入队确认(推荐)
CHT (CH-ENC)
M ───── TEAM_JOIN_CONFIRM ─────▶ L
{
team_id,
payload = E2EE(MgmtKey, {
ok,
capabilities?,
battery?
}),
nonce/seq
}
────────────────────────────────────────────────────────────────────────
(6) Team 状态同步(可选)
CHT (CH-ENC)
L ─────── TEAM_STATUS ───────▶ Team
{
team_id,
payload = E2EE(MgmtKey, {
member_list_hash,
key_id,
team_params
}),
nonce/seq
}
────────────────────────────────────────────────────────────────────────
(7) Team 内位置共享(核心)
CHT (CH-ENC)
Each Member ── TEAM_POSITION_APP ──▶ Team
payload = TeamEncrypted {
team_id,
key_id,
nonce,
ciphertext = AEAD(
PosKey,
protobuf(meshtastic_Position),
aad = header
)
}
Team 外成员:
- 即便拿到 CHT + ChannelPSK
- 只能解 CH-ENC
- 无 TeamKey / PosKey → 无法解密位置
```
---
## 关键语义说明(非常重要)
### 1️⃣ 为什么 **TEAM_ADVERTISE** 不包含密钥
这是**“发现”而不是“加入”**
* 让人知道「附近有一个 Team」
* 但不授予任何能力
* 防止被动监听就获得权限
---
### 2️⃣ 为什么 **TeamKey 不通过无线明文传播**
这是你“强隐私承诺”的核心:
* TeamKey **只通过 UI 侧渠道**传播(短码 / QR / 近距)
* 无线中只传播 **TeamKey 派生物**
* 即使频道配置外传,也无法逆推出 TeamKey
---
### 3️⃣ 为什么要分 **ChannelPSK / MgmtKey / PosKey**
这是“可演化设计”:
* ChannelPSK:只管“这包是谁能收到”
* MgmtKey:管成员管理 / 参数
* PosKey / WpKey:管位置 / 航点
👉 未来你可以做到:
* 换位置密钥 ≠ 踢人
* 换管理密钥 ≠ 影响历史数据
---
### 4️⃣ 为什么要 **TEAM_JOIN_CONFIRM**
不是为了安全,是为了**产品可感知性**:
* Leader 知道谁真的成功入队
* UI 能显示“成员已就绪”
* 后续 Team 状态才可信
---
## MVP 与增强项划分(帮你控复杂度)
### MVP 必须(第一版就该有)
* (0) 本地建队 + TeamKey 派生
* (1) TEAM_ADVERTISE
* UI 侧 TeamKey 输入 / 扫码
* (3) TEAM_JOIN_ACCEPT
* (4) 切换到 CHT
* (7) TEAM_POSITION_APPE2EE
### 可选增强(后续再加)
* member_pub + 更强密钥交换
* TEAM_JOIN_CONFIRM
* TEAM_STATUS
* key rotation / key_id 更新
* 防重放窗口优化
---
# Team Management Protocol
## Message Definitions (Protobuf-Level)
---
## 公共约定(适用于所有 Team 报文)
### Team Identity
* **TeamKey**
* 高熵随机生成
* 仅通过 UI 侧(短码 / QR / 近距)分发
* **MUST NOT** 通过无线明文传播
* **team_id**
* 定义:`Trunc(Hash(TeamKey))`
* 用途:标识 Team,而不泄露 TeamKey
* **MUST** 在所有 Team 相关报文中出现
---
### 加密层级
* **CH-ENC**Meshtastic Channel 加密
* **E2EE**:应用层 Team 加密(AEAD
---
### TeamEncrypted Envelope(通用)
> 用于所有 E2EE payload(位置 / 管理 / 状态)
| 字段 | 类型 | 级别 | 说明 |
| ---------- | ------ | ---- | ------------ |
| version | uint32 | MUST | Envelope 版本 |
| team_id | bytes | MUST | Team 标识 |
| key_id | uint32 | MUST | 当前密钥版本 |
| nonce | bytes | MUST | 每包唯一,用于 AEAD |
| ciphertext | bytes | MUST | AEAD 加密后的数据 |
| aad_flags | uint32 | MAY | AAD 类型标识 |
---
## 1. TEAM_ADVERTISE
**用途**:让 Team 可被“发现”,不授予任何能力
* **信道**CH0
* **加密**:可明文或 CH0 加密
* **PortNum**`TEAM_MGMT_APP`
### 字段表
| 字段 | 类型 | 级别 | 说明 |
| ------------- | ------ | ---- | ---------------- |
| team_id | bytes | MUST | Team 标识 |
| join_hint | uint32 | MAY | 加入提示(需确认 / 有效期等) |
| channel_index | uint32 | MAY | Team Channel 索引 |
| expires_at | uint64 | MAY | 广告过期时间 |
| nonce | bytes | MUST | 防重放 |
### 语义规则
* **MUST NOT** 包含任何密钥材料
* **MUST NOT** 泄露位置或成员信息
* **MAY** 周期性广播
---
## 2. TEAM_JOIN_REQUEST
**用途**:队员请求加入 Team(无密钥)
* **信道**CH0
* **PortNum**`TEAM_MGMT_APP`
### 字段表
| 字段 | 类型 | 级别 | 说明 |
| ------------ | ------ | ---- | ----------- |
| team_id | bytes | MUST | 目标 Team |
| member_pub | bytes | MAY | 公钥(增强密钥交换用) |
| capabilities | uint32 | MAY | 能力标识 |
| nonce | bytes | MUST | 防重放 |
### 语义规则
* **MUST NOT** 包含 TeamKey
* **MAY** 被 Leader 拒绝(无响应)
---
## 3. TEAM_JOIN_ACCEPT
**用途**Leader 接受成员并下发 Team 配置
* **信道**CH0
* **PortNum**`TEAM_MGMT_APP`
* **加密**E2EEMgmtKey
### Payload(解密后结构)
| 字段 | 类型 | 级别 | 说明 |
| ------------- | ---------- | ---- | ------------ |
| channel_index | uint32 | MUST | Team Channel |
| channel_psk | bytes | MUST | Channel PSK |
| key_id | uint32 | MUST | 当前密钥版本 |
| team_params | TeamParams | MAY | 行为参数 |
### 外层字段表
| 字段 | 类型 | 级别 | 说明 |
| ------- | ------------- | ---- | ------- |
| team_id | bytes | MUST | Team 标识 |
| payload | TeamEncrypted | MUST | E2EE 封装 |
| nonce | bytes | MUST | 防重放 |
### 语义规则
* 接收方 **MUST** 已拥有 TeamKey
* 解密失败 **MUST** 丢弃
* **MUST NOT** 在 TeamKey 未确认前应用配置
---
## 4. TEAM_JOIN_CONFIRM
**用途**:成员确认已成功入队
* **信道**CHT
* **PortNum**`TEAM_MGMT_APP`
* **加密**CH-ENC + E2EEMgmtKey
### Payload(解密后)
| 字段 | 类型 | 级别 | 说明 |
| ------------ | ------ | ---- | ----- |
| ok | bool | MUST | 加入成功 |
| capabilities | uint32 | MAY | 能力声明 |
| battery | uint32 | MAY | 电量百分比 |
---
## 5. TEAM_STATUS
**用途**:同步 Team 当前状态
* **信道**CHT
* **PortNum**`TEAM_MGMT_APP`
* **加密**CH-ENC + E2EEMgmtKey
### Payload(解密后)
| 字段 | 类型 | 级别 | 说明 |
| ---------------- | ---------- | ---- | ------ |
| member_list_hash | bytes | MUST | 成员列表摘要 |
| key_id | uint32 | MUST | 当前密钥版本 |
| team_params | TeamParams | MAY | 当前参数 |
---
## 6. TEAM_POSITION_APP
**用途**Team 内位置共享(强隐私)
* **信道**CHT
* **PortNum**`TEAM_POSITION_APP`
* **加密**CH-ENC + E2EEPosKey
### PayloadE2EE 明文结构)
| 字段 | 类型 | 级别 | 说明 |
| --------------- | ------------------- | ---- | ---- |
| position | meshtastic_Position | MUST | 位置数据 |
| precision_level | uint32 | MAY | 精度等级 |
| timestamp | uint64 | MUST | 位置时间 |
### 外层(TeamEncrypted
| 字段 | 类型 | 级别 | 说明 |
| ---------- | ------ | ---- | ------- |
| team_id | bytes | MUST | Team 标识 |
| key_id | uint32 | MUST | 位置密钥版本 |
| nonce | bytes | MUST | 每包唯一 |
| ciphertext | bytes | MUST | AEAD 密文 |
---
## 7. TEAM_WAYPOINT_APP
**用途**Team 内航点共享
*`TEAM_POSITION_APP` 完全一致
* 使用 `WpKey` 解密
* 明文结构为 `meshtastic_Waypoint`
---
## 8. TEAM_TRACK_APP
**用途**:Team 内轨迹批量点共享(固定间隔)
*`TEAM_POSITION_APP` 完全一致(E2EE PosKey
* 明文结构为 `TeamTrackMessage`(自定义轻量编码)
* 轨迹点不带单点时间戳,时间由 `start_ts + i * interval_s` 推导
* `valid_mask` 用于标记每个点是否有效(无 fix 时可置 0)
* 单包最多 20 个点
* **发送端建议**:在队伍 keys 就绪后启动 10 分钟采样窗口;每 30 秒采样一次,共 20 点;窗口内若全部点无效则不发包
* **接收端落盘**`/team/<team_dir>/tracks/<member_id>.gpx`GPX 1.1,增量追加 `<trkpt>`
### PayloadE2EE 明文结构)
| 字段 | 类型 | 级别 | 说明 |
| ---------- | -------------------------------- | ---- | ---- |
| version | uint8 | MUST | 版本 |
| start_ts | uint32 | MUST | 起始时间(epoch 秒) |
| interval_s | uint16 | MUST | 采样间隔(秒) |
| count | uint8 | MUST | 点数(<= 20 |
| valid_mask | uint32 | MUST | 点有效性位图(bit i 对应第 i 个点) |
| points | (lat_e7 int32, lon_e7 int32) * N | MUST | 经纬度(E7 |
---
## 9. MUST / SHOULD / MAY 总结(Conformance Checklist
### MUST
* TeamKey **不得**通过无线明文传播
* 所有 Team 数据 **必须**包含 team_id
* 所有 E2EE payload **必须**使用 AEAD
* nonce **必须** 唯一(防重放)
* 解散 Team 时 **必须** 丢弃所有派生密钥
### SHOULD
* 使用独立的 PosKey / WpKey
* 支持 key_id 轮换
* JOIN_CONFIRM / TEAM_STATUS 用于 UI 同步
### MAY
* 使用 member_pub 做更强密钥交换
* 支持权限分级
* 统一 payload 长度以降低关联风险
---
## 协议一句话定义(可写在文件头)
> **Team 协议定义了一种临时、目标驱动的加密协作单元,其安全边界以 TeamKey 为核心,而非无线信道。**
---
## 目标:你要对用户承诺什么
**隐私承诺(你可以写进 PRD / README):**
1. **只有 Team 成员能解读 Team 位置与航点**(即便别人拿到频道配置/PSK,也无法解密)
2. **Team 解散后内容不可恢复**(密钥丢弃)
3. **外人无法从包内容推断成员位置**(密文 + 随机化 + 防重放)
---
好,这一步我们把 **Team 的一生**画清楚。
不是“功能流程图”,而是**状态机**——**什么时候处在什么状态、因为什么事件发生跃迁、跃迁时必须做什么**。
下面这张 **ASCII 状态机图**,你可以直接放进协议文档或 PRD。
---
# Team 生命周期状态机(State Machine
```
┌─────────────────────────┐
│ (Idle) │
│ No Active Team │
└───────────┬─────────────┘
Create Team │ (UI action)
┌─────────────────────────────────────────────────────────────┐
│ CREATE │
│ │
│ Entry actions (local only): │
│ - Generate TeamKey (high entropy) │
│ - Derive team_id = Trunc(Hash(TeamKey)) │
│ - Allocate / select Team Channel (CHT) │
│ - Derive keys: │
│ ChannelPSK = KDF(TeamKey, "channel-psk") │
│ MgmtKey = KDF(TeamKey, "mgmt") │
│ PosKey = KDF(TeamKey, "pos") │
│ WpKey = KDF(TeamKey, "wp") │
│ │
│ Exit condition: │
│ - Leader confirms creation │
└───────────┬─────────────────────────────────────────────────┘
│ TEAM_ADVERTISE (CH0)
┌─────────────────────────────────────────────────────────────┐
│ ACTIVE │
│ │
│ Team exists and is operational │
│ │
│ Ongoing behaviors: │
│ - Broadcast TEAM_ADVERTISE (CH0, optional) │
│ - Accept TEAM_JOIN_REQUEST (CH0) │
│ - Send TEAM_JOIN_ACCEPT (CH0, E2EE MgmtKey) │
│ - Operate on Team Channel (CHT): │
│ * TEAM_JOIN_CONFIRM │
│ * TEAM_STATUS │
│ * TEAM_POSITION_APP (E2EE PosKey) │
│ * TEAM_WAYPOINT_APP (E2EE WpKey) │
│ * TEAM_TRACK_APP (E2EE PosKey) │
│ │
│ Valid transitions: │
│ - Rotate keys │
│ - Disband team │
└───────────┬───────────────────────┬─────────────────────────┘
│ │
│ Rotate Team Key │ Disband Team
│ (Leader action) │ (Leader or policy)
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ ROTATE │
│ │
│ Purpose: │
│ - Mitigate key leakage │
│ - Exclude lost / untrusted members │
│ │
│ Entry actions (Leader): │
│ - Increment key_id │
│ - Generate new subkeys: │
│ MgmtKey', PosKey', WpKey' │
│ │
│ Protocol actions: │
│ - Broadcast key update via TEAM_STATUS (E2EE MgmtKey) │
│ - Optionally re-issue TEAM_JOIN_ACCEPT to valid members │
│ │
│ Member behavior: │
│ - Switch to new key_id │
│ - Drop packets with old key_id │
│ │
│ Exit condition: │
│ - All active members synced OR timeout │
└───────────┬─────────────────────────────────────────────────┘
│ Rotation complete
┌─────────────────────────────────────────────────────────────┐
│ ACTIVE │
│ (with new key_id) │
└─────────────────────────────────────────────────────────────┘
(from ACTIVE or ROTATE)
│ Disband Team
┌─────────────────────────────────────────────────────────────┐
│ DISBAND │
│ │
│ Entry actions (Leader): │
│ - Broadcast TEAM_END / final TEAM_STATUS (optional) │
│ │
│ Mandatory local actions (ALL members): │
│ - Immediately discard: │
│ * TeamKey │
│ * ChannelPSK │
│ * MgmtKey / PosKey / WpKey │
│ - Stop all Team broadcasts │
│ - Leave Team Channel (CHT) │
│ │
│ Exit condition: │
│ - Return to Idle │
└───────────┬─────────────────────────────────────────────────┘
┌─────────────────────────┐
│ (Idle) │
│ No Active Team │
└─────────────────────────┘
```
---
## 状态语义说明(这比图本身更重要)
### 1️⃣ Idle
* 设备**不持有任何 TeamKey**
* 不广播 Team 位置
* UI 显示「未建队 / 未加入」
---
### 2️⃣ Create
* **纯本地状态**
* 没有任何无线安全风险
* 所有“信任根”在这一刻生成
> 这是 **Team 安全边界的诞生点**
---
### 3️⃣ Active
* Team 的 **主要生命周期**
* 所有 Team 功能(地图、位置、指令)都只在此状态合法
* **TeamKey 是唯一信任来源**
---
### 4️⃣ Rotate
* **不是异常状态,是主动防御状态**
* 用来兑现你对用户的这句话:
> “即使配置外传,也能立刻止损”
关键点:
* Rotate **不改变 team_id**
* 只改变 `key_id` + 派生密钥
* 旧密钥立即失效
---
### 5️⃣ Disband
* **安全终止态**
* 所有密钥必须被销毁
* 不允许“恢复”“回看”“重连”
> Team 结束 ≠ 暂停
> Team 结束 = **密码学意义上的死亡**
---
## 你可以直接写进协议的 MUST 规则(来自状态机)
* **MUST** 仅在 `ACTIVE` / `ROTATE` 状态发送 Team 数据
* **MUST** 在 `DISBAND` 进入时销毁全部 Team 密钥
* **MUST** 丢弃 key_id 不匹配的数据包
* **MUST NOT** 从 `DISBAND` 回到 `ACTIVE`(必须重新建队)
---
## 为什么这张状态机“站得住”
* **产品层**:每个状态都能映射到一个清晰 UI
* **协议层**:每个跃迁都有明确报文或本地动作
* **安全层**:密钥生命周期与状态生命周期完全一致
---
非常好,这一步其实是**把“用户点了什么”和“协议里发生了什么”一一对齐**。
下面我会**严格按 UI 行为 → 状态变化 → 报文触发**来写,不引入新概念、不模糊。
你可以把这份内容理解为:
> **UI 是状态机的控制面板,协议报文是状态跃迁的副作用。**
---
# UI 行为 ↔ Team 状态机 ↔ 报文触发 精确映射
---
## 一、UI 总览:Team 菜单的三种“用户可感知状态”
从 UI 角度,Team 其实只有三种状态(和协议状态一一对应):
| UI 显示状态 | 协议状态 | 用户能做什么 |
| -------- | ------- | ------------ |
| 未建队 | Idle | 建队 / 加入 |
| Team 进行中 | Active | 查看 / 分享 / 解散 |
| 正在解散 | Disband | 无(系统动作) |
> Rotate 是 **Active 内的子动作**,通常是 Leader-only,不是普通用户主流程。
---
## 二、UI 行为 1:点击「建队(Create Team)」
### UI 行为
```
菜单 → Team → [Create Team]
```
---
### 对应状态机跃迁
```
Idle ──(Create Team)──▶ Create ──▶ Active
```
---
### 报文与动作映射(逐步)
#### Step 1:用户点击「Create Team」(无报文)
**UI 行为**
* 用户确认“我要建一个 Team”
**本地动作(MUST**
* 生成 `TeamKey`
* 计算 `team_id = Trunc(Hash(TeamKey))`
* 分配 / 选择 `CHT`
* 派生密钥:
* `ChannelPSK`
* `MgmtKey`
* `PosKey`
* `WpKey`
📌 **此时没有任何无线报文**
📌 **这是 Team 安全边界的起点**
---
#### Step 2UI 显示「Team 已创建 / 分享码」
**UI 行为**
* 显示 Team Code / QR
* 显示「成员数:1」
**协议动作**
* **进入 Active 状态**
* 开始周期性(或一次性)广播:
```
CH0 → TEAM_ADVERTISE
```
```text
TEAM_ADVERTISE {
team_id,
join_hint?,
channel_index?,
nonce
}
```
📌 这是 **UI 显示“Team 已存在”** 的唯一协议依据
---
#### Step 3UI 返回 Team 主页面(Active
**UI 行为**
* 显示:
* Team Active
* Members: 1
* [View Team Map]
* [Share Team]
* [Disband Team]
**协议行为**
* 允许并处理以下报文:
* `TEAM_JOIN_REQUEST`
* `TEAM_JOIN_ACCEPT`
* `TEAM_POSITION_APP`
---
## 三、UI 行为 2:他人加入 TeamJoin Team
### UI 行为(队员)
```
Team → Join Team → 输入短码 / 扫码 → Confirm
```
---
### 报文与状态映射
#### Step 1:队员输入短码 / 扫码(无报文)
**UI 行为**
* 用户确认加入
**本地动作(MUST**
* 解析 / 获得 `TeamKey`
* 预计算 `team_id`
* 派生 `MgmtKey / PosKey / WpKey`
📌 **没有 TeamKey,后续任何 JOIN 报文都没有意义**
---
#### Step 2:队员请求加入
```
CH0 → TEAM_JOIN_REQUEST
```
```text
TEAM_JOIN_REQUEST {
team_id,
nonce
}
```
---
#### Step 3Leader UI 出现「加入请求」
**UI 行为(Leader**
* 显示“新成员请求加入”
* [Accept] / [Ignore]
📌 UI 事件 **直接绑定** 下一条报文是否发送
---
#### Step 4Leader 点击「Accept」
```
CH0 → TEAM_JOIN_ACCEPT
```
```text
TEAM_JOIN_ACCEPT {
team_id,
payload = E2EE(MgmtKey, {
channel_index,
channel_psk,
key_id,
team_params?
}),
nonce
}
```
---
#### Step 5:队员 UI 切换为「Team Active」
**UI 行为**
* 显示 Team 主页面
* Members ≥ 2
**本地动作**
* 保存 CHT + ChannelPSK
* 切换到 CHT
**协议动作(推荐)**
```
CHT → TEAM_JOIN_CONFIRM
```
---
## 四、UI 行为 3:Team 进行中(无按钮,但有持续行为)
### UI 行为
```
Team Active → View Team Map
```
---
### 协议行为(持续)
* 每个成员周期性发送:
```
CHT → TEAM_POSITION_APP
```
* UI 地图刷新 **只依赖成功解密后的 payload**
* 解密失败的数据包:
* MUST 被丢弃
* MUST NOT 更新 UI
---
## 五、UI 行为 4:点击「解散 TeamDisband Team)」【Leader】
### UI 行为
```
Team Active → [Disband Team] → Confirm
```
---
### 对应状态机跃迁
```
Active ──(Disband)──▶ Disband ──▶ Idle
```
---
### 报文与动作映射
#### Step 1Leader 点击「Disband」
**UI 行为**
* 显示强确认弹窗
---
#### Step 2Leader 确认解散
**协议动作(可选但推荐)**
```
CHT → TEAM_STATUS / TEAM_END
```
```text
TEAM_STATUS {
team_id,
payload = E2EE(MgmtKey, {
state = DISBAND
})
}
```
📌 这是 **通知性质**,不是安全关键
📌 即使收不到,也不影响安全性
---
#### Step 3:所有设备进入 Disband
**本地动作(MUST,所有成员)**
* 立即销毁:
* `TeamKey`
* `ChannelPSK`
* `MgmtKey / PosKey / WpKey`
* 停止所有 Team 报文
* 离开 CHT
---
#### Step 4UI 回到未建队状态
```
Team → You are not in a Team
[Create Team] [Join Team]
```
📌 **UI 是密钥销毁完成的“可视确认”**
---
## 六、UI 行为 5:普通成员「离开 Team(Leave)」【非解散】
> 这是 **UI 行为 ≠ 协议状态变化** 的典型例子
### UI 行为
```
Team Active → [Leave Team]
```
---
### 本地动作(MUST
* 销毁所有 TeamKey / 派生密钥
* 离开 CHT
### 协议动作(MAY
```
CHT → TEAM_LEAVE_NOTICE
```
> 仅用于 UI 同步,不影响 Team 存续
📌 **Team 状态仍为 Active(对其他人)**
---
## 七、关键设计原则(可以写进 PRD)
1. **UI 行为永远先于报文**
报文只是“用户意图的副作用”
2. **没有 TeamKey → UI 不允许进入 Active**
安全失败必须表现为“操作不可达”
3. **解散是密钥行为,不是通知行为**
报文失败 ≠ 解散失败
---
## 一句话总结这套映射关系
> **UI 决定状态,
> 状态决定密钥,
> 密钥决定哪些报文“有意义”。**
---
### 下一步(非常自然)
如果你愿意,我可以下一步帮你直接做其中之一:
1. **把这套映射写成“UI × Protocol 对照表”**(方便测试与 PR
2. **帮你设计 Team 页面在不同状态下的 LVGL 组件树**
3. **把这些 UI 行为转成自动化测试用例描述**
你现在这一步,已经是在做**系统级产品设计**了,而不是“功能拼装”。
好,这一步我们**完全站在“嵌入式 UI 工程”的角度**来做。
目标不是“好看”,而是 **状态清晰、组件稳定、不会演化失控**
下面我给你的是 **LVGL 组件树(Component Tree**,不是布局草图;
你可以直接据此拆 `.cpp / .h`,或者对照你现在的 UI 架构来重构。
---
# Team 页面 · LVGL 组件树设计
(按 **状态** 明确分支,而不是条件 if/else 堆在一起)
---
## 总体设计原则(先给你“架构约束”)
### 1️⃣ Team 页面 = 状态容器(不是功能容器)
```text
TeamPage
└── TeamStateContainer // 只负责:Idle / Active / Disband
```
* **永远只挂载一个子树**
* 状态切换 = 删除旧子树 + 创建新子树
* 不在同一树里 hide/show
👉 这是防止 LVGL 页面“变成屎山”的关键
---
### 2️⃣ 页面分三棵互斥的树
```text
TeamState = { IDLE, ACTIVE, DISBAND }
```
* `IdleTree`
* `ActiveTree`
* `DisbandTree`(瞬态,通常很短)
---
## 一、Team 页面根节点(所有状态共用)
```text
TeamPage (lv_obj_t*)
├── TopBar
│ ├── BackButton
│ └── TitleLabel ("Team")
└── TeamStateContainer (lv_obj_t*)
└── <StateSpecificTree>
```
说明:
* `TopBar` 永远存在
* `TeamStateContainer` 是**唯一可变区域**
---
## 二、状态 1:未建队(IDLE)
### 用户心智
> “我现在不在任何 Team 里。”
---
### 组件树:IdleTree
```text
IdleTree (lv_obj_t*)
├── CenterContainer
│ ├── StatusIcon // 简单图标:空队伍
│ ├── StatusLabel // "You are not in a Team"
│ └── Spacer
├── ActionContainer
│ ├── CreateTeamButton // 主按钮
│ └── JoinTeamButton // 次按钮
```
---
### 组件职责说明
* `CreateTeamButton`
* 点击 → **触发 UI 行为:Create Team**
* 后续状态切换由控制器决定
* `JoinTeamButton`
* 跳转到 Join 流程页(输入码 / 扫码)
* 成功后切换到 `ACTIVE`
📌 **IdleTree 不关心任何协议或密钥**
---
## 三、状态 2Team 进行中(ACTIVE
这是 **停留时间最长、最重要的状态**
### 用户心智
> “我在一个 Team 里,我能看到状态,也能做决定。”
---
### 组件树:ActiveTree(完整)
```text
ActiveTree (lv_obj_t*)
├── SummaryCard
│ ├── TeamStatusLabel // "Team Active"
│ ├── MemberCountLabel // "Members: N"
│ └── PrivacyBadge // "Strong Privacy / E2EE"
├── Divider
├── ActionList
│ ├── ViewMapItem // → Team Map
│ ├── ShareTeamItem // → 显示 Team Code / QR
│ └── (optional) RotateKeyItem // Leader only
├── Divider
└── DangerZone
└── DisbandButton / LeaveButton
```
---
### 关键:Leader / Member 的分支(不是不同页面)
#### 普通成员(Member
```text
DangerZone
└── LeaveTeamButton
```
#### Leader(建队者)
```text
DangerZone
└── DisbandTeamButton
```
> ⚠️ **不要在 UI 层暴露“Leader”概念**
> 只通过按钮存在与否体现
---
### 各组件的“协议触发点”
#### `ViewMapItem`
* 只做一件事:跳转到 Team Map 页面
* Team Map 页面只消费:
* `TEAM_POSITION_APP`
* `TEAM_WAYPOINT_APP`
* `TEAM_TRACK_APP`
#### `ShareTeamItem`
* 进入 **Share Subpage**
* 只读数据:
* Team Code
* QR
* ❌ 不触发任何报文
#### `RotateKeyItem`Leader-only
* 点击 → 进入确认页
* 确认后:
* 触发 **Rotate 状态**
* 发送 `TEAM_STATUS (new key_id)`
#### `DisbandTeamButton`
* 点击 → 进入 **DisbandTree**
---
## 四、状态 3:解散中(DISBAND)
这是一个 **瞬态状态**,但非常重要。
### 用户心智
> “这件事是不可逆的。”
---
### 组件树:DisbandTree
```text
DisbandTree (lv_obj_t*)
├── WarningIcon
├── WarningLabel
│ └── "Disbanding Team..."
└── ProgressIndicator
```
---
### 行为绑定(关键)
* **进入 DisbandTree 的同时**
* 本地立即销毁:
* TeamKey
* ChannelPSK
* 所有派生密钥
* 停止 Team 报文
* **不等待网络结果**
* UI 停留时间极短(例如 300–500ms)
---
### Disband 完成后
```text
DISBAND → IDLE
```
* 销毁 `DisbandTree`
* 创建 `IdleTree`
---
## 五、状态切换 = 组件树切换(推荐实现模式)
### 推荐控制逻辑(伪代码)
```cpp
void TeamPage::setState(TeamState newState) {
if (currentTree) {
lv_obj_del(currentTree);
currentTree = nullptr;
}
switch (newState) {
case IDLE:
currentTree = buildIdleTree(container);
break;
case ACTIVE:
currentTree = buildActiveTree(container);
break;
case DISBAND:
currentTree = buildDisbandTree(container);
break;
}
}
```
📌 **没有 show/hide**
📌 **没有 if (isLeader) scattered everywhere**
📌 **所有状态边界在一个地方**
---
## 六、这套组件树设计的三个“硬优势”
1. **状态与 UI 完全同构**
状态机 = UI 结构,不是注释
2. **协议失败不会污染 UI**
* 解散不依赖报文成功
* UI 只反映本地密钥状态
3. **未来扩展不会炸**
* 加 Team Settings
* 加 Key Rotation
* 加 Debug Info
都只是在 `ActiveTree` 里加一支
---你这个“拉回”的动作非常对。
我们现在**明确只做一件事**
> **围绕「建队状态机 + 密钥交换流程」,哪些数据必须持久化?哪些绝不能持久化?哪些是状态性的?**
不谈 UI、不谈地图、不谈未来扩展,只谈**数据与生命周期**。
---
# Team 建队 & 密钥交换阶段
## 数据持久化整理(严格按状态机)
---
## 一、先给结论(总表)
> **不是所有参与建队的东西都应该持久化**
> 持久化的唯一判断标准是:
> 👉 *“设备重启后,是否还能继续处于同一个 Team 状态”*
### 数据分三类:
| 类别 | 是否持久化 | 原因 |
| ---------- | ----- | ------------- |
| Team 身份与密钥 | ✅ 必须 | 否则重启=自动退队 |
| Channel 配置 | ✅ 必须 | 否则无法接收 Team 包 |
| 协议瞬态状态 | ❌ 不应 | 可通过报文重建 |
| UI / 交互状态 | ❌ 不应 | 完全是表现层 |
---
## 二、按状态机逐状态分析
---
## 状态 0Idle(未建队)
### 持久化数据
**无。**
```text
(no Team-related persistent data)
```
### 原则
* Idle 状态下 **设备上不存在任何 Team 痕迹**
* 这是安全与心理边界的起点
---
## 状态 1Create(本地建队,尚未 Active
> 这是一个**非常短暂的状态**
> 只存在于一次 UI 操作中
### 是否需要持久化?
**不需要**
### 原因
* Create 是一次**原子操作**
* 要么成功进入 Active
* 要么失败回到 Idle
👉 **Create 阶段失败 = 不留下任何痕迹**
---
## 状态 2ActiveTeam 正在进行)
这是**唯一需要持久化的核心状态**。
---
### 1️⃣ Team 身份类(必须持久化)
```text
TeamIdentity
├── team_id (bytes)
├── team_role (enum: LEADER / MEMBER)
├── key_id (uint32)
```
#### 说明
* `team_id`:所有 Team 数据的索引键
* `team_role`:影响 UI 与允许的动作
* `key_id`:当前密钥版本(用于解密判断)
**MUST persist**
---
### 2️⃣ Team 密钥类(必须持久化,安全存储)
```text
TeamSecrets
├── team_key (bytes) // 根密钥
├── mgmt_key (bytes)
├── pos_key (bytes)
├── wp_key (bytes)
```
#### 说明
* 派生密钥 **可以重算**,但不建议
* 重算依赖 KDF 版本一致,风险高
* 实践中直接存派生结果更稳
**MUST persist**
⚠️ **必须使用安全存储(NVS / encrypted storage**
---
### 3️⃣ Channel 配置类(必须持久化)
```text
TeamChannelConfig
├── channel_index (uint8 / uint32)
├── channel_psk (bytes)
```
#### 说明
* 重启后必须能重新监听 CHT
* 否则会“逻辑上还在 Team,物理上听不到”
**MUST persist**
---
### 4️⃣ Team 行为参数(建议持久化)
```text
TeamParams
├── position_interval_ms
├── position_precision_level
├── advertise_enabled
├── join_policy
```
#### 说明
* 这些参数**由 Leader 决定**
* 成员端只是执行
🟡 **SHOULD persist**
(不存也能跑,但体验不一致)
---
### 5️⃣ 不应持久化的东西(非常重要)
#### ❌ 协议瞬态状态
```text
DO NOT persist:
- 已发送但未确认的 JOIN_REQUEST
- JOIN_CONFIRM 状态
- 最近一次 TEAM_STATUS 内容
- 成员列表(可通过广播重建)
```
原因:
* 都是 **软状态**
* 断电/重启后自然恢复
---
#### ❌ UI 状态
```text
DO NOT persist:
- 当前是否在 Team 页面
- 是否展开某个子菜单
- 是否刚刚显示过 QR
```
---
## 状态 3:Rotate(密钥轮换中)
> Rotate 是 **Active 的子状态**
### 持久化策略:**两阶段提交**
#### 临时状态(不持久化)
```text
RotateContext (RAM only)
├── new_key_id
├── new_mgmt_key
├── new_pos_key
├── new_wp_key
```
#### 切换成功瞬间(持久化)
```text
TeamSecrets (overwrite)
├── key_id = new_key_id
├── mgmt_key = new_mgmt_key
├── pos_key = new_pos_key
├── wp_key = new_wp_key
```
📌 **不要持久化“正在轮换中”**
📌 **要么成功覆盖,要么保持旧密钥**
这是**避免掉电/异常导致“半轮换”**的关键。
---
## 状态 4Disband(解散)
### 行为规则(强制)
> **Disband = 数据销毁**
### 必须执行的持久化动作
```text
DELETE persistent:
- TeamIdentity
- TeamSecrets
- TeamChannelConfig
- TeamParams
```
### 不允许的行为
* ❌ 不允许标记“已解散但可恢复”
* ❌ 不允许保留 team_id
* ❌ 不允许缓存旧密钥
📌 **Disband 是密码学意义上的死亡**
---
## 三、Join 流程中的持久化时机(精确)
### 成员设备(Member
| 时刻 | 是否持久化 | 内容 |
| -------------------- | ----- | -------------------------------- |
| 输入短码 | ❌ | (仅 RAM |
| 发送 JOIN_REQUEST | ❌ | |
| 收到 JOIN_ACCEPT 并成功解密 | ✅ | TeamIdentity + Secrets + Channel |
| 切换到 CHT 成功 | ✅ | 同上 |
| JOIN_CONFIRM | ❌ | |
---
### 建队者(Leader
| 时刻 | 是否持久化 | 内容 |
| ----------- | ----- | ---------- |
| Create Team | ✅ | 全量 Team 数据 |
| 接受新成员 | ❌ | |
| Rotate 成功 | ✅ | 覆盖密钥 |
| Disband | ❌(删除) | |
---
## 四、你可以直接写进协议/实现文档的一句话规则
> **只有“定义 Team 身份连续性的数据”才允许持久化;
> 所有报文级、交互级状态都必须是可丢弃的。**
---
## 五、一个非常实用的工程建议(重要)
**把 Team 持久化做成一个“单一对象”**
```text
PersistentTeamState
├── identity
├── secrets
├── channel
├── params
```
* 有这个对象 → Team = Active
* 没有这个对象 → Team = Idle
📌 **不要拆散到多个 KV**
📌 **不要用 flag 表示“在不在 Team”**
---
# 1️⃣ 建队流程(Leader
## 持久化读写时序图
```
时间 ↓
Leader Device
──────────────────────────────────────────────────────────────
[UI] Click "Create Team"
|
v
📦 RAM: generate TeamKey
📦 RAM: derive team_id, ChannelPSK, MgmtKey, PosKey, WpKey
📦 RAM: select Team Channel (CHT)
|
| (still NOT persistent)
|
v
[UI] Confirm Create
|
v
💾 PERSIST: write PersistentTeamState
├── identity.team_id
├── identity.role = LEADER
├── identity.key_id = 0
├── secrets.team_key
├── secrets.mgmt_key
├── secrets.pos_key
├── secrets.wp_key
├── channel.channel_index = CHT
├── channel.channel_psk
└── params (optional)
|
v
[STATE] Team = ACTIVE
|
v
[PROTO] start TEAM_ADVERTISE (CH0)
[PROTO] accept JOIN_REQUEST
[PROTO] send JOIN_ACCEPT
```
### 关键规则(必须遵守)
***在 UI Confirm 之前,绝不写 Flash**
***第一次持久化 = Team 正式存在**
* 💡 掉电在 Confirm 之前 → 自动回到 Idle(无残留)
---
# 2️⃣ 加入流程(Member
## 持久化读写时序图(最容易出 bug 的地方)
```
时间 ↓
Member Device
──────────────────────────────────────────────────────────────
[UI] Click "Join Team"
|
v
[UI] Input Code / Scan QR
|
v
📦 RAM: obtain TeamKey
📦 RAM: derive team_id, MgmtKey, PosKey, WpKey
|
| (still NOT persistent)
|
v
[PROTO] send TEAM_JOIN_REQUEST (CH0)
|
v
[PROTO] receive TEAM_JOIN_ACCEPT (CH0)
|
v
[SEC] try decrypt JOIN_ACCEPT payload
|
|-- decryption FAIL --> ❌ abort (NO write)
|
v
📦 RAM: extract channel_index, channel_psk, key_id
|
| (still NOT persistent)
|
v
[PROTO] switch to Team Channel (CHT)
|
|-- switch FAIL --> ❌ abort (NO write)
|
v
💾 PERSIST: write PersistentTeamState
├── identity.team_id
├── identity.role = MEMBER
├── identity.key_id
├── secrets.team_key
├── secrets.mgmt_key
├── secrets.pos_key
├── secrets.wp_key
├── channel.channel_index
├── channel.channel_psk
└── params (optional)
|
v
[STATE] Team = ACTIVE
|
v
[PROTO] send TEAM_JOIN_CONFIRM (CHT)
[PROTO] start TEAM_POSITION_APP
```
### 关键规则(这是重点)
***收到 JOIN_ACCEPT ≠ 可以写 Flash**
***解密成功 ≠ 可以写 Flash**
***只有在“成功切换到 CHT”之后才允许持久化**
> 这是为了防止:
> **“Flash 里记录着 Team,但无线层根本进不了 Team Channel”**
---
# 3️⃣ 解散流程(Disband
## 持久化删除时序图(最安全的一条)
### 3A. Leader 解散 Team
```
时间 ↓
Leader Device
──────────────────────────────────────────────────────────────
[UI] Click "Disband Team"
|
v
[UI] Confirm Disband
|
v
❌ DELETE: erase PersistentTeamState
├── identity
├── secrets
├── channel
└── params
|
v
📦 RAM: clear all Team-related state
|
v
[STATE] Team = IDLE
|
v
[PROTO] (optional) send TEAM_STATUS / TEAM_END
```
### 3B. 普通成员离开 TeamLeave
```
时间 ↓
Member Device
──────────────────────────────────────────────────────────────
[UI] Click "Leave Team"
|
v
❌ DELETE: erase PersistentTeamState
|
v
📦 RAM: clear Team state
|
v
[STATE] Team = IDLE
|
v
[PROTO] (optional) send LEAVE_NOTICE
```
### 关键规则(必须统一)
***不要先发报文再删数据**
***不要等待网络 ACK**
***删除持久化 = Team 立刻不存在**
> 报文是“礼貌”,
> **删除密钥才是“事实”。**
---
# 4️⃣ 旋转密钥(Rotate)补充:持久化原子性
```
Leader Device
──────────────────────────────────────────────────────────────
[UI] Click "Rotate Key"
|
v
📦 RAM: generate new subkeys
📦 RAM: new_key_id
|
v
[PROTO] broadcast TEAM_STATUS(new_key_id)
|
|-- timeout / abort --> ❌ discard RAM only
|
v
💾 PERSIST (ATOMIC overwrite):
├── identity.key_id = new_key_id
├── secrets.mgmt_key = new
├── secrets.pos_key = new
└── secrets.wp_key = new
|
v
[STATE] Team still ACTIVE (new key)
```
### 关键规则
***不允许“半写入”**
***不持久化 Rotate 进行中状态**
***要么全换,要么不换**
---
# 5️⃣ 一条可以写进实现规范的铁律
> **持久化只发生在“状态跃迁完成之后”,
> 删除只发生在“状态跃迁开始之时”。**
---
# 6️⃣ 最终工程检查清单(你可以直接用)
* [ ] Create Confirm 前没有任何 Flash 写入
* [ ] Join 成功切换 CHT 前没有任何 Flash 写入
* [ ] Disband / Leave 第一动作就是删除持久化
* [ ] Rotate 只有一个原子写点
* [ ] PersistentTeamState 是否是“单一对象”
---
# Team 持久化存储设计
## NVS / Flash Layout & Wear Strategy
> 设计目标:
>
> * 掉电安全
> * 原子更新
> * 最小擦写
> * 明确“Team 存在 / 不存在”的判据
---
## 一、总原则(先立铁律)
### 1️⃣ Team 持久化 = **单一对象**
> **Flash 中只有一个“Team 是否存在”的真相源**
```text
PersistentTeamState
```
* 有它 → Team = Active
* 没有它 → Team = Idle
❌ 不使用多个 flag
❌ 不在不同 namespace 里拆散
---
### 2️⃣ 写入次数极少,是“生命周期级别”的
| 操作 | 写 Flash |
| ----------- | -------- |
| 建队成功 | ✅ 一次 |
| 成员加入成功 | ✅ 一次 |
| Rotate Key | ✅ 一次 |
| 正常使用(位置/聊天) | ❌ |
| 解散 / 离开 | ✅ 删除 |
> **不是高频写场景**
> 所以重点是 **正确性 > 极限性能**
---
## 二、推荐存储方式(ESP32 实践)
### ✅ 推荐:NVSNon-Volatile Storage
原因:
* 自带 wear leveling
* 支持 blob
* 支持 namespace
* 原子语义清晰
> 你现在的需求 **完全不需要自定义 raw flash**
---
## 三、NVS Namespace 设计
```text
NVS Namespace: "team"
```
**只有这一个 namespace 存 Team。**
---
## 四、Key 布局(极简但完整)
### 核心 Key 列表
| Key | 类型 | 说明 |
| ------------ | ---- | ---------------------- |
| `version` | u32 | 结构版本 |
| `team_state` | blob | 整个 PersistentTeamState |
> ❗**不要拆成几十个 key**
> 拆了就很难保证一致性与原子性
---
## 五、PersistentTeamState 结构(Flash 中的唯一真相)
### 逻辑结构(与你之前分析一致)
```text
PersistentTeamState
├── header
│ ├── magic
│ ├── version
│ ├── length
│ └── crc32
├── identity
│ ├── team_id
│ ├── role (LEADER / MEMBER)
│ └── key_id
├── secrets
│ ├── team_key
│ ├── mgmt_key
│ ├── pos_key
│ └── wp_key
├── channel
│ ├── channel_index
│ └── channel_psk
└── params (optional)
├── position_interval_ms
├── precision_level
└── flags
```
---
### Header 设计(非常重要)
| 字段 | 作用 |
| --------- | ----------- |
| `magic` | 判断是否存在 Team |
| `version` | 结构版本 |
| `length` | 防截断 |
| `crc32` | 掉电 / 半写检测 |
📌 **header 是你对抗掉电与损坏的最后防线**
---
## 六、写入策略(什么时候写,怎么写)
### 1️⃣ 写入只发生在“成功跃迁后”
你前面定的规则,现在变成存储规则:
| 场景 | 写入内容 |
| --------------- | ----------------------- |
| Create Confirm | 写完整 PersistentTeamState |
| Join 成功切换 CHT | 写完整 PersistentTeamState |
| Rotate 成功 | **整体覆盖写** |
| Disband / Leave | 删除 key |
---
### 2️⃣ 写入方式:整体覆盖(不是增量)
```text
nvs_set_blob("team", "team_state", &state, sizeof(state))
nvs_commit()
```
* 不修改子字段
* 不做 patch
* 不维护“正在 rotate”状态
📌 **整体写 + CRC = 最稳**
---
## 七、擦写 / 删除策略(关键)
### 解散 / 离开 Team
```text
nvs_erase_key("team", "team_state")
nvs_commit()
```
* **删除即事实**
* 不保留“历史 Team”
* 不留可恢复路径
---
### 为什么不用 flag 表示“已解散”
因为:
* flag 本身也是状态
* flag 需要维护一致性
* flag 会引入“幽灵 Team”
**不存在 = 最干净**
---
## 八、掉电与损坏恢复策略
### 启动时的判断逻辑(唯一入口)
```text
if (nvs_has_key("team", "team_state")) {
read blob
if (magic ok && crc ok) {
Team = ACTIVE
} else {
erase key
Team = IDLE
}
} else {
Team = IDLE
}
```
📌 **任何异常 → 删除 → Idle**
---
## 九、Key Rotation 的原子性保证
### 正确做法(你前面已经定了)
1. 新密钥 **只存在 RAM**
2. 广播 Rotate
3. 同步完成
4. **一次性覆盖写 PersistentTeamState**
```text
old_state → new_state
nvs_set_blob(...)
nvs_commit()
```
❌ 不写中间态
❌ 不写 `rotating = true`
---
## 十、Flash 磨损分析(给你信心)
### 最坏情况估算
| 操作 | 次数 |
| ------ | ------- |
| 建队 | 1 |
| Rotate | 假设 20 次 |
| 解散 | 1 |
**< 50 次写入 / Team 生命周期**
NVS 默认 wear leveling 足以支撑 **数十万次**
👉 **可以放心**
---
## 十一、一个非常重要的安全建议(强烈)
### TeamSecrets 存储
* 如果平台支持:
* 使用 **NVS encrypted partition**
* 或:
* 在写入前,用 device-unique key 再包一层
你不需要军工级,只需要:
> **Flash 被 dumpTeamKey 不可直接读出**
---
## 十二、最终“实现检查清单”(你可以直接用)
* [ ] Team 只占用一个 NVS key
* [ ] PersistentTeamState 有 magic + crc
* [ ] 所有写入都是整体 blob
* [ ] 解散 = erase key
* [ ] 启动异常 = erase key
* [ ] Rotate 只有一个 commit 点
---
## 一句话总结这套设计
> **Flash 里不保存“过程”,
> 只保存“事实”。**
---
## 1) 复盘的三种级别(先定产品边界)
你要“整个生命周期所有事件落地”,但落地可以有不同强度:
### A. 本地复盘(推荐默认)
* **每台设备只记录自己看到/发出的事件**
* 不需要全员上报
* 最少协议改动
* 复盘不一定 100%完整(取决于你当时是否收到包)
### B. 领队完整复盘(可选增强)
* Leader 设备尽量收集全队事件(仍然是“尽量”,无线环境无法保证 100%)
* 更接近“行动记录仪”
### C. 全队一致复盘(最重,不建议一开始做)
* 需要全队 ACK/补齐/对账
* 立刻把系统复杂度拉爆
> 我建议:**先做 A**,再做 B(Leader 模式),C 暂时不碰。
---
## 2) 你要记录什么:事件模型(Event Sourcing 风格)
把 Team 的一生理解为:**事件序列 + 状态重建**。
### 事件必须包含的元信息(所有事件通用)
* `event_id`:唯一(可用 `sender_id + seq`
* `team_id`
* `ts`:事件发生时间(设备本地时间 + 可选 mesh 时间)
* `sender_id`:节点 ID
* `event_type`
* `payload`:按类型携带字段
* `key_id`:当时使用的密钥版本(很重要,便于复盘解密)
### 事件类型建议(覆盖你当前系统)
**生命周期**
* `TEAM_CREATED`Leader 本地)
* `TEAM_ADVERTISE_SENT/RECEIVED`
* `TEAM_JOIN_REQUEST_SENT/RECEIVED`
* `TEAM_JOIN_ACCEPT_SENT/RECEIVED`
* `TEAM_JOIN_CONFIRM_SENT/RECEIVED`
* `TEAM_STATUS_SENT/RECEIVED`
* `TEAM_KEY_ROTATED`(含 old/new key_id
* `TEAM_DISBANDED` / `TEAM_LEFT`
**行动**
* `TEAM_POSITION_RX`(建议只存“采样后的”位置点)
* `TEAM_WAYPOINT_RX`
* `TEAM_COMMAND_RX`(你未来的集合/求助等指令)
**异常**
* `DECRYPT_FAIL`(重要:复盘时能解释“为什么当时没显示”)
* `REPLAY_DROP`
* `MEMBER_LOST` / `MEMBER_RECOVERED`
---
## 3) 日志存储位置:Flash vs SD 的现实选择
### 如果你有 SD 卡(强烈推荐)
* 用 SD 文件做**追加写日志**(Flash 磨损问题直接变小)
* 文件系统好用(导出、查看、同步)
### 只有 FlashNVS/自定义分区)
* 也能做,但要用**环形日志(ring buffer**
* 写放大、磨损要精心控制
* 不适合存“高频位置点”
> 结论:**位置轨迹复盘几乎必然要 SD**,否则你必须极度降采样。
---
## 4) 最关键:复盘与“解散销毁密钥”如何兼容?
你之前的安全模型是:Disband 立即销毁 TeamKey → 历史不可读。
而复盘要求:解散后仍能读历史。
所以你要引入一个独立的日志密钥:
### 新增密钥:`LogKey`
* `LogKey = KDF(TeamKey, "log")` **可以**,但如果 Disband 会销毁 TeamKey,那 LogKey 也丢了。
* 更合理:**创建 Team 时生成独立随机 LogKey**,并设置“是否保留”的策略。
### 两种策略(对应产品开关)
1. **默认:Disband 销毁 LogKey**
* 不可复盘
* 最强隐私承诺(默认建议)
2. **开启复盘:Disband 保留 LogKey(仅本机)**
* 日志文件仍然是加密的
* 只有该设备能复盘
* 你仍可承诺:**TeamKey 销毁,网络权限消失;但本机保留行动记录**
这其实很符合户外真实需求:
* **通信权限**随队伍结束而结束
* **行动记录**可由个人/领队保留
---
## 5) 写入策略:怎样既完整又不爆存储?
### 位置点不要“每包都落盘”
位置是最高频数据,必须做降采样:
* 规则一:**按时间采样**(例如每 10s/30s 记录一次)
* 规则二:**按距离采样**(移动超过 20m 才记)
* 规则三:异常优先(SOS/集合时临时升频)
### 事件日志推荐结构:Append-only + Checkpoint
为了复盘速度与稳定性:
* `events.log`:追加写(每条事件一条记录)
* `snapshot.bin`:偶尔写一个“当前 Team 状态快照”(比如每 5 分钟或每 200 条事件)
* 复盘时:从最近快照开始回放事件,速度快
---
## 6) 存储格式建议(别纠结,选一个能跑的)
你已经在 protobuf 体系里,最顺:
* **protobuf event**(一条 event message
* 外层封一层 `LogRecord`
* `len`u16/u32
* `record_type`event/snapshot
* `ciphertext`
* `crc32`(可选)
日志内容建议 **AEAD(LogKey)** 加密,保证:
* 落地文件被拷走也看不懂
* 文件被篡改能检测出来
---
## 7) 复盘的输出是什么样?
### 最小可用复盘(MVP
* 时间线:加入/离开/集合/求助/掉线/恢复
* 地图回放:按时间推进显示轨迹(不要求动画,先做“按时间跳点”)
### Leader 复盘增强
* 统计:队伍最大拉开距离、成员掉线次数、集合耗时
* 异常:谁长时间静止、谁电量低、何时丢包严重
---
## 8) 最重要的落地决策:你要不要“复盘开关”?
我强烈建议在建队时就决定:
* `Record mode: Off / Local / Leader`
* `Retention: 1 day / 7 days / manual delete`
* `Privacy: Keep logs encrypted (default)`
并且在解散时:
* 如果 record off → 删除日志 + 销毁 LogKey
* 如果 record on → 写入 `TEAM_DISBANDED` 事件 + 关闭记录,但保留 LogKey(本机)
---
## 9) 你问的“整理一下”:需要持久化的新增数据
在你之前 `PersistentTeamState` 基础上,加这几项就够:
**当 Record=ON 时:**
* `log_enabled`bool
* `log_mode`enum: LOCAL / LEADER
* `log_key`bytes)✅(独立于 TeamKey
* `log_file_id` / `log_path`(如果 SD
* `log_seq`(用于事件递增编号)
* `last_snapshot_offset`(可选)
**当 Record=OFF 时:**
* 不持久化任何 log 相关内容
---
# 1) SD 文件布局
## 1.1 目录结构(按 Team 会话分桶)
建议每次 Team 生命周期(Create→Disband)生成一个 **session_id**(随机 64-bit 或时间戳+随机),作为一个会话目录。
```
/trail-mate/
/team_logs/
/YYYYMMDD/
/T_<session_id>/
meta.json
keys.bin
events.log
snapshot.idx
snapshots.bin
```
### 文件职责
* `meta.json`(明文、可读)
* 仅放非敏感信息:创建时间、设备名、版本号、记录模式等
* `keys.bin`(加密/保护)
* 存 LogKey 的封装(详见 3)
* `events.log`(核心追加写日志)
* 事件流(加密记录)
* `snapshot.idx`(小索引,方便快速定位)
* 每个快照对应 `events.log` 的 offset + `snapshots.bin` 的 offset
* `snapshots.bin`(快照流,追加写)
* 复盘加速的状态快照(加密记录)
> **MUST**`events.log` 与 `snapshots.bin` 都是 *append-only*(只追加、不改写、不截断)。
> **SHOULD**:每天分目录,方便清理/归档。
---
# 2) LogRecord 二进制格式(通用记录容器)
`events.log``snapshots.bin` 使用同一种 record 容器:`LogRecord`
每条记录都是:**固定头 + 变长密文 + 可选 CRC**。
## 2.1 LogRecord 结构(小端序 Little-endian
```
+-------------------------------+
| magic (4) "TLOG" |
| version (1) = 1 |
| type (1) 1=EVENT 2=SNAP|
| flags (2) |
| header_len (2) bytes |
| body_len (4) bytes |
| session_id (8) |
| seq (8) monotonic |
| ts_ms (8) unix ms |
| key_id (4) |
| nonce (12) |
| aad_crc32 (4) | (optional but recommended)
|--------------------------------
| AAD (header extension...) | (header_len - fixed_header bytes)
|--------------------------------
| ciphertext (body_len bytes) | AEAD output (may include tag)
|--------------------------------
| trailer_crc32 (4) optional |
+-------------------------------+
```
### 固定头字段说明
* `magic`:用于快速扫描与恢复
* `version`:格式版本
* `type`EVENT / SNAP
* `flags`:位标志(是否有 trailer_crc、是否压缩等)
* `header_len`:头部总长度(方便未来扩展 AAD
* `body_len`:密文长度
* `session_id`:会话绑定(防止混写)
* `seq`:单调递增序号(掉电恢复、去重、对齐)
* `ts_ms`:记录写入时间(或事件发生时间,看你定义)
* `key_id`:LogKey 轮换版本(可选但强烈推荐)
* `nonce(12)`AEAD nonce(每条记录唯一)
* `aad_crc32`:对 AAD 计算 CRC(用来快速判断 header 是否被破坏)
> **MUST**`seq` 单调递增且不重复(同一 session 内)。
> **MUST**`nonce` 在同一 `key_id` 下不可重复。
> **SHOULD**`header_len` 允许携带扩展 AAD(比如 sender_id、event_type 等的明文字段)。
---
## 2.2 flags 位定义(建议)
* bit0: `HAS_TRAILER_CRC`(记录末尾带 `trailer_crc32`
* bit1: `BODY_COMPRESSED`(密文内部的明文在加密前压缩)
* bit2: `TS_IS_EVENT_TIME`(ts_ms 表示事件发生时间,否则表示写入时间)
* bit3: `RESERVED`
> 推荐默认打开 `HAS_TRAILER_CRC`。即使 AEAD 能校验密文完整性,CRC 仍对**快速定位损坏/截断**很有价值。
---
# 3) 加密与密钥存放(LogKey
## 3.1 LogKey 的定位
* TeamKey 用于 Team 通信(可能在 Disband 时销毁)
* **LogKey 用于日志加密**,是否保留由 “Record Mode/Retention Policy” 决定
> 你想要“可复盘”,就必须 **在 Disband 后仍可获得 LogKey**(至少在本机)。
## 3.2 keys.bin(推荐结构)
`keys.bin` 不要明文放 LogKey。建议用设备唯一密钥封装(例如 ESP32 NVS 加密分区 / eFuse key / 或你自己的 device key)。
`keys.bin` 内容建议也是一个小的 `KeyRecord`
```
magic "TKEY" (4)
version (1)
flags (1)
reserved(2)
session_id (8)
key_id (4)
nonce (12)
ciphertext (var) = AEAD(DeviceKey, LogKeyMaterial)
crc32 (4)
```
其中 `LogKeyMaterial` 至少包含:
* `log_key` (32 bytes)
* `kdf_info`/algo id(可选)
* `created_ts`(可选)
> **MUST**:设备没有解封 `keys.bin` 的能力时,不允许进入复盘(避免误显示)。
---
# 4) events.log 里明文到底放什么(EVENT 内容)
EVENT 的明文建议用 protobuf(或你自定义 TLV),然后 AEAD 加密。
### 明文建议结构:`TeamEvent`protobuf
包含:
* `event_type`
* `sender_id`
* `team_id`
* `payload`(按类型 oneof
* `mesh_ts`(可选)
* `rx_rssi/snr`(可选)
* `seq_in_mesh`(可选)
> **SHOULD**:把 `event_type` 放到 LogRecord 的 AAD 扩展里(明文),这样你可以不解密就做快速过滤/统计;但会泄露“事件类别”——如果你非常在意隐私,就不要这么做,把一切放密文里。
---
# 5) CRC 策略(掉电与损坏恢复的关键)
你有三层完整性:
1. AEAD tag:保证密文不可篡改(但无法判断“文件截断”位置)
2. `aad_crc32`:快速判断 header 是否损坏
3. `trailer_crc32`:对整个 record(从 magic 到 ciphertext)做 CRC,便于恢复扫描
### trailer_crc32 计算范围(建议)
`LogRecord``magic` 开始到 `ciphertext` 末尾(不含 trailer_crc32 本身)做 CRC32。
> **MUST**:读取时若 CRC 不匹配,该 record 之后的数据可视为不可信,进入恢复扫描模式。
---
# 6) 追加写策略(append-only
## 6.1 写入流程(每条 record
1. 组装明文 event
2. 生成 nonce
3. AEAD 加密得到 ciphertext
4. 写入 LogRecord 头 + ciphertext
5. 写入 trailer_crc32(若启用)
6. `fsync/flush`(看你平台:至少在关键事件/周期性 flush)
### Flush 策略(SD 实用建议)
* 普通事件:可每 N 条或每 T 秒 flush 一次(例如 2s
* 关键事件(TEAM_CREATED / DISBANDED / ROTATED / SOS):**立即 flush**
> **SHOULD**:实现一个 `LogWriter`,内部有小缓冲;但不要大到掉电损失太多。
---
# 7) 快照策略(snapshot + index
复盘如果只靠 replay events,会越来越慢。快照就是为了“从某个点开始回放”。
## 7.1 快照内容
快照明文建议存:
* 当前 `key_id`
* 成员表(最近可见成员、最后位置)
* 当前 waypoint/assemble point
* 关键参数(采样策略)
* `last_event_seq`(快照覆盖到的最后事件序号)
然后 AEAD(LogKey) 加密,写入 `snapshots.bin`(同 LogRecord 格式,type=SNAP)。
## 7.2 何时写快照(建议)
***N 条事件**(例如 200
* 或每 **T 秒**(例如 60s
* 或关键事件后(rotate/disband
> **MUST**:快照写入不应影响实时通信,必须可丢弃(失败不影响主功能)。
## 7.3 snapshot.idx(小索引,明文)
索引是一条条固定长度记录,追加写,便于快速定位最新快照:
```
IdxEntry (fixed 32 bytes):
- magic "SIDX" (4) (optional per file header)
- version (1)
- reserved(3)
- session_id (8)
- snap_seq (8) // snapshot record seq
- events_offset (8) // events.log offset at snapshot time
- snaps_offset (8) // snapshots.bin offset for this snapshot
```
> **SHOULD**`snapshot.idx` 可明文,因为它只暴露 offset;如果你认为“有无快照/频率”也敏感,可把 idx 也加密,但工程复杂度会上升。
---
# 8) 恢复与复盘读取流程(掉电后可自愈)
## 8.1 打开一个 session 的复盘步骤
1. 读取 `meta.json`(可选)
2. 解封 `keys.bin` 得到 LogKey
3. 读取 `snapshot.idx`,找到最后一个可用快照(校验 snap record CRC/AEAD
4. 从快照恢复状态
5.`events.log``events_offset` 开始,顺序读取 record
* magic 检查
* header/aad_crc32 检查
* trailer_crc32(若启用)
* AEAD 解密
* 解析 event → 回放更新状态
## 8.2 恢复扫描(文件损坏/截断)
当发现 CRC 不匹配或读到半条 record
* 进入扫描模式:按字节滑动找下一个 `"TLOG"` magic
* 找到后尝试解析 header_len/body_len 是否合理
* CRC 通过则继续
> **MUST**:扫描必须有上限(避免在损坏文件上死循环)。
---
# 9) 文件滚动与保留策略(防止无限增长)
## 9.1 单 session 文件滚动
建议 `events.log` 达到阈值就切分:
* `events_0001.log`
* `events_0002.log`
同理快照。
阈值建议:
* 4MB / 16MB / 64MB 任选一个(看 SD/需求)
* 位置点多的话建议更小,便于导出与修复
## 9.2 Retention(保留期)
* `meta.json` 里记录 retention policy
* 定期清理 `YYYYMMDD` 目录
> **MUST**:用户应能“一键删除某次行动记录”(删除整个 `T_<session_id>` 目录)。
---
# 10) 最小实现版本(你可以先落地这个)
如果你要先快速跑起来,我建议 MVP:
* 只要三个文件:
* `meta.json`
* `keys.bin`
* `events.log`
* `events.log`LogRecord + AEAD + trailer_crc32
* 不做快照、不做 idx
* 位置事件做强制降采样(例如 10s 一条)
后续再加:
* `snapshots.bin` + `snapshot.idx`
---