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

72 KiB
Raw Blame History

你这个 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:应用层 E2EETeamKey 派生的 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-ENCMeshtastic 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 加密
  • PortNumTEAM_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
  • PortNumTEAM_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
  • PortNumTEAM_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
  • PortNumTEAM_MGMT_APP
  • 加密CH-ENC + E2EEMgmtKey

Payload(解密后)

字段 类型 级别 说明
ok bool MUST 加入成功
capabilities uint32 MAY 能力声明
battery uint32 MAY 电量百分比

5. TEAM_STATUS

用途:同步 Team 当前状态

  • 信道CHT
  • PortNumTEAM_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
  • PortNumTEAM_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>.gpxGPX 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 数据
  • MUSTDISBAND 进入时销毁全部 Team 密钥
  • MUST 丢弃 key_id 不匹配的数据包
  • MUST NOTDISBAND 回到 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
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
TEAM_JOIN_REQUEST {
  team_id,
  nonce
}

Step 3Leader UI 出现「加入请求」

UI 行为(Leader

  • 显示“新成员请求加入”
  • [Accept] / [Ignore]

📌 UI 事件 直接绑定 下一条报文是否发送


Step 4Leader 点击「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:点击「解散 TeamDisband Team)」【Leader】

UI 行为

Team Active → [Disband Team] → Confirm

对应状态机跃迁

Active ──(Disband)──▶ Disband ──▶ Idle

报文与动作映射

Step 1Leader 点击「Disband」

UI 行为

  • 显示强确认弹窗

Step 2Leader 确认解散

协议动作(可选但推荐)

CHT → TEAM_STATUS / TEAM_END
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 页面 = 状态容器(不是功能容器)

TeamPage
└── TeamStateContainer   // 只负责:Idle / Active / Disband
  • 永远只挂载一个子树
  • 状态切换 = 删除旧子树 + 创建新子树
  • 不在同一树里 hide/show

👉 这是防止 LVGL 页面“变成屎山”的关键


2️⃣ 页面分三棵互斥的树

TeamState = { IDLE, ACTIVE, DISBAND }
  • IdleTree
  • ActiveTree
  • DisbandTree(瞬态,通常很短)

一、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 不关心任何协议或密钥


三、状态 2Team 进行中(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_APP
    • TEAM_WAYPOINT_APP
    • TEAM_TRACK_APP

ShareTeamItem

  • 进入 Share Subpage

  • 只读数据:

    • Team Code
    • QR
  • 不触发任何报文

RotateKeyItemLeader-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 📌 所有状态边界在一个地方


六、这套组件树设计的三个“硬优势”

  1. 状态与 UI 完全同构 状态机 = UI 结构,不是注释

  2. 协议失败不会污染 UI

    • 解散不依赖报文成功
    • UI 只反映本地密钥状态
  3. 未来扩展不会炸

    • 加 Team Settings
    • 加 Key Rotation
    • 加 Debug Info 都只是在 ActiveTree 里加一支

---你这个“拉回”的动作非常对。 我们现在明确只做一件事

围绕「建队状态机 + 密钥交换流程」,哪些数据必须持久化?哪些绝不能持久化?哪些是状态性的?

不谈 UI、不谈地图、不谈未来扩展,只谈数据与生命周期


Team 建队 & 密钥交换阶段

数据持久化整理(严格按状态机)


一、先给结论(总表)

不是所有参与建队的东西都应该持久化 持久化的唯一判断标准是: 👉 “设备重启后,是否还能继续处于同一个 Team 状态”

数据分三类:

类别 是否持久化 原因
Team 身份与密钥 必须 否则重启=自动退队
Channel 配置 必须 否则无法接收 Team 包
协议瞬态状态 不应 可通过报文重建
UI / 交互状态 不应 完全是表现层

二、按状态机逐状态分析


状态 0Idle(未建队)

持久化数据

无。

(no Team-related persistent data)

原则

  • Idle 状态下 设备上不存在任何 Team 痕迹
  • 这是安全与心理边界的起点

状态 1:Create(本地建队,尚未 Active

这是一个非常短暂的状态 只存在于一次 UI 操作中

是否需要持久化?

不需要

原因

  • Create 是一次原子操作
  • 要么成功进入 Active
  • 要么失败回到 Idle

👉 Create 阶段失败 = 不留下任何痕迹


状态 2ActiveTeam 正在进行)

这是唯一需要持久化的核心状态


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

状态 3Rotate(密钥轮换中)

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

📌 不要持久化“正在轮换中” 📌 要么成功覆盖,要么保持旧密钥

这是**避免掉电/异常导致“半轮换”**的关键。


状态 4Disband(解散)

行为规则(强制)

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. 普通成员离开 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 是否存在”的真相源

PersistentTeamState
  • 有它 → Team = Active
  • 没有它 → Team = Idle

不使用多个 flag 不在不同 namespace 里拆散


2️⃣ 写入次数极少,是“生命周期级别”的

操作 写 Flash
建队成功 一次
成员加入成功 一次
Rotate Key 一次
正常使用(位置/聊天)
解散 / 离开 删除

不是高频写场景 所以重点是 正确性 > 极限性能


二、推荐存储方式(ESP32 实践)

推荐:NVSNon-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 的原子性保证

正确做法(你前面已经定了)

  1. 新密钥 只存在 RAM
  2. 广播 Rotate
  3. 同步完成
  4. 一次性覆盖写 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 被 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_CREATEDLeader 本地)
  • 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

    • lenu16/u32
    • record_typeevent/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_enabledbool
  • log_modeenum: LOCAL / LEADER
  • log_keybytes(独立于 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(快照流,追加写)

    • 复盘加速的状态快照(加密记录)

MUSTevents.logsnapshots.bin 都是 append-only(只追加、不改写、不截断)。 SHOULD:每天分目录,方便清理/归档。


2) LogRecord 二进制格式(通用记录容器)

events.logsnapshots.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:格式版本
  • typeEVENT / 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 是否被破坏)

MUSTseq 单调递增且不重复(同一 session 内)。 MUSTnonce 在同一 key_id 下不可重复。 SHOULDheader_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 加密。

明文建议结构:TeamEventprotobuf

包含:

  • 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 计算范围(建议)

LogRecordmagic 开始到 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

SHOULDsnapshot.idx 可明文,因为它只暴露 offset;如果你认为“有无快照/频率”也敏感,可把 idx 也加密,但工程复杂度会上升。


8) 恢复与复盘读取流程(掉电后可自愈)

8.1 打开一个 session 的复盘步骤

  1. 读取 meta.json(可选)

  2. 解封 keys.bin 得到 LogKey

  3. 读取 snapshot.idx,找到最后一个可用快照(校验 snap record CRC/AEAD

  4. 从快照恢复状态

  5. events.logevents_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.logLogRecord + AEAD + trailer_crc32

  • 不做快照、不做 idx

  • 位置事件做强制降采样(例如 10s 一条)

后续再加:

  • snapshots.bin + snapshot.idx