72 KiB
你这个 TEAM 业务到底在做什么(我现在能总结到“可写 TeamEvent”的程度)
1) Team 的定义(产品级一句话)
Team = 临时形成、目标驱动、随时解散的户外行动单元。
它不是“群聊”,也不是“普通位置共享”,而是让一小群人在无网络环境下持续回答三件事:
- 我们是否还在一起(队伍是否分散/掉队/走偏)
- 接下来要做什么(集合、等待、求助等“行动共识”)
- 有没有人出问题(静止、失联、电量低、SOS)
2) Team 的核心能力(你已经确定的)
A. 临时组队(Create / Join / Disband)
- Leader 一键建队
- 成员通过短码/QR 拿到 TeamKey 加入
- Team 可以随时解散(解散即密钥销毁)
这一套你已经做成状态机:Create → Active → Rotate → Disband
B. 强隐私位置共享(你的差异化关键)
你明确在意:
位置广播出去后,只有 Team 成员能解析成功;即便 Team channel 配置外传,也看不懂。
因此你采用两层保护:
- L1:Meshtastic Channel 加密(CH-ENC)
- L2:应用层 E2EE(TeamKey 派生的 PosKey/WpKey,TeamEncrypted Envelope)
并且你倾向新增自己的 PortNum:
TEAM_POSITION_APPTEAM_WAYPOINT_APPTEAM_TRACK_APP- 管理类
TEAM_MGMT_APP
C. Team 内的“行动指令”(Preset / Command)
你提出的差异化:
- 向队友发 预设指令(集合、等待、求助…)
- 指令携带必要参数(例如集合地点 waypoint)
- 指令是“事件”,不靠打字聊天
注意:你前面没要求我把它做成完整协议,但你已经明确这是 Team 的重要业务能力。
D. Team 生命周期复盘(你现在要做的)
你希望整个组队过程可复盘,包括生命周期中所有事件落地。
所以我们引入:
- SD 日志(append-only)
- LogRecord(AEAD + 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
- 需要记录整个生命周期事件并可复盘
- 你愿意新增自定义 PortNum(TEAM_*_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_APP(E2EE)
可选增强(后续再加)
- 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 - 加密:E2EE(MgmtKey)
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 + E2EE(MgmtKey)
Payload(解密后)
| 字段 | 类型 | 级别 | 说明 |
|---|---|---|---|
| ok | bool | MUST | 加入成功 |
| capabilities | uint32 | MAY | 能力声明 |
| battery | uint32 | MAY | 电量百分比 |
5. TEAM_STATUS
用途:同步 Team 当前状态
- 信道:CHT
- PortNum:
TEAM_MGMT_APP - 加密:CH-ENC + E2EE(MgmtKey)
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 + E2EE(PosKey)
Payload(E2EE 明文结构)
| 字段 | 类型 | 级别 | 说明 |
|---|---|---|---|
| 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>)
Payload(E2EE 明文结构)
| 字段 | 类型 | 级别 | 说明 |
|---|---|---|---|
| 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):
- 只有 Team 成员能解读 Team 位置与航点(即便别人拿到频道配置/PSK,也无法解密)
- Team 解散后内容不可恢复(密钥丢弃)
- 外人无法从包内容推断成员位置(密文 + 随机化 + 防重放)
好,这一步我们把 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 -
派生密钥:
ChannelPSKMgmtKeyPosKeyWpKey
📌 此时没有任何无线报文 📌 这是 Team 安全边界的起点
Step 2:UI 显示「Team 已创建 / 分享码」
UI 行为
- 显示 Team Code / QR
- 显示「成员数:1」
协议动作
- 进入 Active 状态
- 开始周期性(或一次性)广播:
CH0 → TEAM_ADVERTISE
TEAM_ADVERTISE {
team_id,
join_hint?,
channel_index?,
nonce
}
📌 这是 UI 显示“Team 已存在” 的唯一协议依据
Step 3:UI 返回 Team 主页面(Active)
UI 行为
-
显示:
- Team Active
- Members: 1
- [View Team Map]
- [Share Team]
- [Disband Team]
协议行为
-
允许并处理以下报文:
TEAM_JOIN_REQUESTTEAM_JOIN_ACCEPTTEAM_POSITION_APP
三、UI 行为 2:他人加入 Team(Join Team)
UI 行为(队员)
Team → Join Team → 输入短码 / 扫码 → Confirm
报文与状态映射
Step 1:队员输入短码 / 扫码(无报文)
UI 行为
- 用户确认加入
本地动作(MUST)
- 解析 / 获得
TeamKey - 预计算
team_id - 派生
MgmtKey / PosKey / WpKey
📌 没有 TeamKey,后续任何 JOIN 报文都没有意义
Step 2:队员请求加入
CH0 → TEAM_JOIN_REQUEST
TEAM_JOIN_REQUEST {
team_id,
nonce
}
Step 3:Leader UI 出现「加入请求」
UI 行为(Leader)
- 显示“新成员请求加入”
- [Accept] / [Ignore]
📌 UI 事件 直接绑定 下一条报文是否发送
Step 4:Leader 点击「Accept」
CH0 → TEAM_JOIN_ACCEPT
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:点击「解散 Team(Disband Team)」【Leader】
UI 行为
Team Active → [Disband Team] → Confirm
对应状态机跃迁
Active ──(Disband)──▶ Disband ──▶ Idle
报文与动作映射
Step 1:Leader 点击「Disband」
UI 行为
- 显示强确认弹窗
Step 2:Leader 确认解散
协议动作(可选但推荐)
CHT → TEAM_STATUS / TEAM_END
TEAM_STATUS {
team_id,
payload = E2EE(MgmtKey, {
state = DISBAND
})
}
📌 这是 通知性质,不是安全关键 📌 即使收不到,也不影响安全性
Step 3:所有设备进入 Disband
本地动作(MUST,所有成员)
-
立即销毁:
TeamKeyChannelPSKMgmtKey / PosKey / WpKey
-
停止所有 Team 报文
-
离开 CHT
Step 4:UI 回到未建队状态
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)
-
UI 行为永远先于报文 报文只是“用户意图的副作用”
-
没有 TeamKey → UI 不允许进入 Active 安全失败必须表现为“操作不可达”
-
解散是密钥行为,不是通知行为 报文失败 ≠ 解散失败
一句话总结这套映射关系
UI 决定状态, 状态决定密钥, 密钥决定哪些报文“有意义”。
下一步(非常自然)
如果你愿意,我可以下一步帮你直接做其中之一:
- 把这套映射写成“UI × Protocol 对照表”(方便测试与 PR)
- 帮你设计 Team 页面在不同状态下的 LVGL 组件树
- 把这些 UI 行为转成自动化测试用例描述
你现在这一步,已经是在做系统级产品设计了,而不是“功能拼装”。
好,这一步我们完全站在“嵌入式 UI 工程”的角度来做。 目标不是“好看”,而是 状态清晰、组件稳定、不会演化失控。
下面我给你的是 LVGL 组件树(Component Tree),不是布局草图;
你可以直接据此拆 .cpp / .h,或者对照你现在的 UI 架构来重构。
Team 页面 · LVGL 组件树设计
(按 状态 明确分支,而不是条件 if/else 堆在一起)
总体设计原则(先给你“架构约束”)
1️⃣ Team 页面 = 状态容器(不是功能容器)
TeamPage
└── TeamStateContainer // 只负责:Idle / Active / Disband
- 永远只挂载一个子树
- 状态切换 = 删除旧子树 + 创建新子树
- 不在同一树里 hide/show
👉 这是防止 LVGL 页面“变成屎山”的关键
2️⃣ 页面分三棵互斥的树
TeamState = { IDLE, ACTIVE, DISBAND }
IdleTreeActiveTreeDisbandTree(瞬态,通常很短)
一、Team 页面根节点(所有状态共用)
TeamPage (lv_obj_t*)
├── TopBar
│ ├── BackButton
│ └── TitleLabel ("Team")
│
└── TeamStateContainer (lv_obj_t*)
└── <StateSpecificTree>
说明:
TopBar永远存在TeamStateContainer是唯一可变区域
二、状态 1:未建队(IDLE)
用户心智
“我现在不在任何 Team 里。”
组件树:IdleTree
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 不关心任何协议或密钥
三、状态 2:Team 进行中(ACTIVE)
这是 停留时间最长、最重要的状态。
用户心智
“我在一个 Team 里,我能看到状态,也能做决定。”
组件树:ActiveTree(完整)
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)
DangerZone
└── LeaveTeamButton
Leader(建队者)
DangerZone
└── DisbandTeamButton
⚠️ 不要在 UI 层暴露“Leader”概念 只通过按钮存在与否体现
各组件的“协议触发点”
ViewMapItem
-
只做一件事:跳转到 Team Map 页面
-
Team Map 页面只消费:
TEAM_POSITION_APPTEAM_WAYPOINT_APPTEAM_TRACK_APP
ShareTeamItem
-
进入 Share Subpage
-
只读数据:
- Team Code
- QR
-
❌ 不触发任何报文
RotateKeyItem(Leader-only)
-
点击 → 进入确认页
-
确认后:
- 触发 Rotate 状态
- 发送
TEAM_STATUS (new key_id)
DisbandTeamButton
- 点击 → 进入 DisbandTree
四、状态 3:解散中(DISBAND)
这是一个 瞬态状态,但非常重要。
用户心智
“这件事是不可逆的。”
组件树:DisbandTree
DisbandTree (lv_obj_t*)
├── WarningIcon
├── WarningLabel
│ └── "Disbanding Team..."
│
└── ProgressIndicator
行为绑定(关键)
-
进入 DisbandTree 的同时
-
本地立即销毁:
- TeamKey
- ChannelPSK
- 所有派生密钥
-
停止 Team 报文
-
-
不等待网络结果
-
UI 停留时间极短(例如 300–500ms)
Disband 完成后
DISBAND → IDLE
- 销毁
DisbandTree - 创建
IdleTree
五、状态切换 = 组件树切换(推荐实现模式)
推荐控制逻辑(伪代码)
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 📌 所有状态边界在一个地方
六、这套组件树设计的三个“硬优势”
-
状态与 UI 完全同构 状态机 = UI 结构,不是注释
-
协议失败不会污染 UI
- 解散不依赖报文成功
- UI 只反映本地密钥状态
-
未来扩展不会炸
- 加 Team Settings
- 加 Key Rotation
- 加 Debug Info
都只是在
ActiveTree里加一支
---你这个“拉回”的动作非常对。 我们现在明确只做一件事:
围绕「建队状态机 + 密钥交换流程」,哪些数据必须持久化?哪些绝不能持久化?哪些是状态性的?
不谈 UI、不谈地图、不谈未来扩展,只谈数据与生命周期。
Team 建队 & 密钥交换阶段
数据持久化整理(严格按状态机)
一、先给结论(总表)
不是所有参与建队的东西都应该持久化 持久化的唯一判断标准是: 👉 “设备重启后,是否还能继续处于同一个 Team 状态”
数据分三类:
| 类别 | 是否持久化 | 原因 |
|---|---|---|
| Team 身份与密钥 | ✅ 必须 | 否则重启=自动退队 |
| Channel 配置 | ✅ 必须 | 否则无法接收 Team 包 |
| 协议瞬态状态 | ❌ 不应 | 可通过报文重建 |
| UI / 交互状态 | ❌ 不应 | 完全是表现层 |
二、按状态机逐状态分析
状态 0:Idle(未建队)
持久化数据
无。
(no Team-related persistent data)
原则
- Idle 状态下 设备上不存在任何 Team 痕迹
- 这是安全与心理边界的起点
状态 1:Create(本地建队,尚未 Active)
这是一个非常短暂的状态 只存在于一次 UI 操作中
是否需要持久化?
❌ 不需要
原因
- Create 是一次原子操作
- 要么成功进入 Active
- 要么失败回到 Idle
👉 Create 阶段失败 = 不留下任何痕迹
状态 2:Active(Team 正在进行)
这是唯一需要持久化的核心状态。
1️⃣ Team 身份类(必须持久化)
TeamIdentity
├── team_id (bytes)
├── team_role (enum: LEADER / MEMBER)
├── key_id (uint32)
说明
team_id:所有 Team 数据的索引键team_role:影响 UI 与允许的动作key_id:当前密钥版本(用于解密判断)
✅ MUST persist
2️⃣ Team 密钥类(必须持久化,安全存储)
TeamSecrets
├── team_key (bytes) // 根密钥
├── mgmt_key (bytes)
├── pos_key (bytes)
├── wp_key (bytes)
说明
- 派生密钥 可以重算,但不建议
- 重算依赖 KDF 版本一致,风险高
- 实践中直接存派生结果更稳
✅ MUST persist ⚠️ 必须使用安全存储(NVS / encrypted storage)
3️⃣ Channel 配置类(必须持久化)
TeamChannelConfig
├── channel_index (uint8 / uint32)
├── channel_psk (bytes)
说明
- 重启后必须能重新监听 CHT
- 否则会“逻辑上还在 Team,物理上听不到”
✅ MUST persist
4️⃣ Team 行为参数(建议持久化)
TeamParams
├── position_interval_ms
├── position_precision_level
├── advertise_enabled
├── join_policy
说明
- 这些参数由 Leader 决定
- 成员端只是执行
🟡 SHOULD persist (不存也能跑,但体验不一致)
5️⃣ 不应持久化的东西(非常重要)
❌ 协议瞬态状态
DO NOT persist:
- 已发送但未确认的 JOIN_REQUEST
- JOIN_CONFIRM 状态
- 最近一次 TEAM_STATUS 内容
- 成员列表(可通过广播重建)
原因:
- 都是 软状态
- 断电/重启后自然恢复
❌ UI 状态
DO NOT persist:
- 当前是否在 Team 页面
- 是否展开某个子菜单
- 是否刚刚显示过 QR
状态 3:Rotate(密钥轮换中)
Rotate 是 Active 的子状态
持久化策略:两阶段提交
临时状态(不持久化)
RotateContext (RAM only)
├── new_key_id
├── new_mgmt_key
├── new_pos_key
├── new_wp_key
切换成功瞬间(持久化)
TeamSecrets (overwrite)
├── key_id = new_key_id
├── mgmt_key = new_mgmt_key
├── pos_key = new_pos_key
├── wp_key = new_wp_key
📌 不要持久化“正在轮换中” 📌 要么成功覆盖,要么保持旧密钥
这是**避免掉电/异常导致“半轮换”**的关键。
状态 4:Disband(解散)
行为规则(强制)
Disband = 数据销毁
必须执行的持久化动作
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 持久化做成一个“单一对象”
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. 普通成员离开 Team(Leave)
时间 ↓
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 是否存在”的真相源
PersistentTeamState
- 有它 → Team = Active
- 没有它 → Team = Idle
❌ 不使用多个 flag ❌ 不在不同 namespace 里拆散
2️⃣ 写入次数极少,是“生命周期级别”的
| 操作 | 写 Flash? |
|---|---|
| 建队成功 | ✅ 一次 |
| 成员加入成功 | ✅ 一次 |
| Rotate Key | ✅ 一次 |
| 正常使用(位置/聊天) | ❌ |
| 解散 / 离开 | ✅ 删除 |
不是高频写场景 所以重点是 正确性 > 极限性能
二、推荐存储方式(ESP32 实践)
✅ 推荐:NVS(Non-Volatile Storage)
原因:
- 自带 wear leveling
- 支持 blob
- 支持 namespace
- 原子语义清晰
你现在的需求 完全不需要自定义 raw flash
三、NVS Namespace 设计
NVS Namespace: "team"
只有这一个 namespace 存 Team。
四、Key 布局(极简但完整)
核心 Key 列表
| Key | 类型 | 说明 |
|---|---|---|
version |
u32 | 结构版本 |
team_state |
blob | 整个 PersistentTeamState |
❗不要拆成几十个 key 拆了就很难保证一致性与原子性
五、PersistentTeamState 结构(Flash 中的唯一真相)
逻辑结构(与你之前分析一致)
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️⃣ 写入方式:整体覆盖(不是增量)
nvs_set_blob("team", "team_state", &state, sizeof(state))
nvs_commit()
- 不修改子字段
- 不做 patch
- 不维护“正在 rotate”状态
📌 整体写 + CRC = 最稳
七、擦写 / 删除策略(关键)
解散 / 离开 Team
nvs_erase_key("team", "team_state")
nvs_commit()
- 删除即事实
- 不保留“历史 Team”
- 不留可恢复路径
为什么不用 flag 表示“已解散”
因为:
- flag 本身也是状态
- flag 需要维护一致性
- flag 会引入“幽灵 Team”
不存在 = 最干净
八、掉电与损坏恢复策略
启动时的判断逻辑(唯一入口)
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 的原子性保证
正确做法(你前面已经定了)
- 新密钥 只存在 RAM
- 广播 Rotate
- 同步完成
- 一次性覆盖写 PersistentTeamState
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 被 dump,TeamKey 不可直接读出
十二、最终“实现检查清单”(你可以直接用)
- 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_idts:事件发生时间(设备本地时间 + 可选 mesh 时间)sender_id:节点 IDevent_typepayload:按类型携带字段key_id:当时使用的密钥版本(很重要,便于复盘解密)
事件类型建议(覆盖你当前系统)
生命周期
TEAM_CREATED(Leader 本地)TEAM_ADVERTISE_SENT/RECEIVEDTEAM_JOIN_REQUEST_SENT/RECEIVEDTEAM_JOIN_ACCEPT_SENT/RECEIVEDTEAM_JOIN_CONFIRM_SENT/RECEIVEDTEAM_STATUS_SENT/RECEIVEDTEAM_KEY_ROTATED(含 old/new key_id)TEAM_DISBANDED/TEAM_LEFT
行动
TEAM_POSITION_RX(建议只存“采样后的”位置点)TEAM_WAYPOINT_RXTEAM_COMMAND_RX(你未来的集合/求助等指令)
异常
DECRYPT_FAIL(重要:复盘时能解释“为什么当时没显示”)REPLAY_DROPMEMBER_LOST/MEMBER_RECOVERED
3) 日志存储位置:Flash vs SD 的现实选择
如果你有 SD 卡(强烈推荐)
- 用 SD 文件做追加写日志(Flash 磨损问题直接变小)
- 文件系统好用(导出、查看、同步)
只有 Flash(NVS/自定义分区)
- 也能做,但要用环形日志(ring buffer)
- 写放大、磨损要精心控制
- 不适合存“高频位置点”
结论:位置轨迹复盘几乎必然要 SD,否则你必须极度降采样。
4) 最关键:复盘与“解散销毁密钥”如何兼容?
你之前的安全模型是:Disband 立即销毁 TeamKey → 历史不可读。 而复盘要求:解散后仍能读历史。
所以你要引入一个独立的日志密钥:
新增密钥:LogKey
LogKey = KDF(TeamKey, "log")可以,但如果 Disband 会销毁 TeamKey,那 LogKey 也丢了。- 更合理:创建 Team 时生成独立随机 LogKey,并设置“是否保留”的策略。
两种策略(对应产品开关)
-
默认:Disband 销毁 LogKey
- 不可复盘
- 最强隐私承诺(默认建议)
-
开启复盘: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)ciphertextcrc32(可选)
日志内容建议 AEAD(LogKey) 加密,保证:
- 落地文件被拷走也看不懂
- 文件被篡改能检测出来
7) 复盘的输出是什么样?
最小可用复盘(MVP)
- 时间线:加入/离开/集合/求助/掉线/恢复
- 地图回放:按时间推进显示轨迹(不要求动画,先做“按时间跳点”)
Leader 复盘增强
- 统计:队伍最大拉开距离、成员掉线次数、集合耗时
- 异常:谁长时间静止、谁电量低、何时丢包严重
8) 最重要的落地决策:你要不要“复盘开关”?
我强烈建议在建队时就决定:
Record mode: Off / Local / LeaderRetention: 1 day / 7 days / manual deletePrivacy: 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 / SNAPflags:位标志(是否有 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_typesender_idteam_idpayload(按类型 oneof)mesh_ts(可选)rx_rssi/snr(可选)seq_in_mesh(可选)
SHOULD:把
event_type放到 LogRecord 的 AAD 扩展里(明文),这样你可以不解密就做快速过滤/统计;但会泄露“事件类别”——如果你非常在意隐私,就不要这么做,把一切放密文里。
5) CRC 策略(掉电与损坏恢复的关键)
你有三层完整性:
- AEAD tag:保证密文不可篡改(但无法判断“文件截断”位置)
aad_crc32:快速判断 header 是否损坏trailer_crc32:对整个 record(从 magic 到 ciphertext)做 CRC,便于恢复扫描
trailer_crc32 计算范围(建议)
对 LogRecord 从 magic 开始到 ciphertext 末尾(不含 trailer_crc32 本身)做 CRC32。
MUST:读取时若 CRC 不匹配,该 record 之后的数据可视为不可信,进入恢复扫描模式。
6) 追加写策略(append-only)
6.1 写入流程(每条 record)
- 组装明文 event
- 生成 nonce
- AEAD 加密得到 ciphertext
- 写入 LogRecord 头 + ciphertext
- 写入 trailer_crc32(若启用)
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 的复盘步骤
-
读取
meta.json(可选) -
解封
keys.bin得到 LogKey -
读取
snapshot.idx,找到最后一个可用快照(校验 snap record CRC/AEAD) -
从快照恢复状态
-
从
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.logevents_0002.log
同理快照。
阈值建议:
- 4MB / 16MB / 64MB 任选一个(看 SD/需求)
- 位置点多的话建议更小,便于导出与修复
9.2 Retention(保留期)
meta.json里记录 retention policy- 定期清理
YYYYMMDD目录
MUST:用户应能“一键删除某次行动记录”(删除整个
T_<session_id>目录)。
10) 最小实现版本(你可以先落地这个)
如果你要先快速跑起来,我建议 MVP:
-
只要三个文件:
meta.jsonkeys.binevents.log
-
events.log:LogRecord + AEAD + trailer_crc32 -
不做快照、不做 idx
-
位置事件做强制降采样(例如 10s 一条)
后续再加:
snapshots.bin+snapshot.idx