# 你这个 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 配置外传,也看不懂。** 因此你采用两层保护: * L1:Meshtastic **Channel** 加密(CH-ENC) * L2:应用层 **E2EE**(TeamKey 派生的 PosKey/WpKey,TeamEncrypted 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) * 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//tracks/.gpx`(GPX 1.1,增量追加 ``) ### 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):** 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 2:UI 显示「Team 已创建 / 分享码」 **UI 行为** * 显示 Team Code / QR * 显示「成员数:1」 **协议动作** * **进入 Active 状态** * 开始周期性(或一次性)广播: ``` CH0 → TEAM_ADVERTISE ``` ```text 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_REQUEST` * `TEAM_JOIN_ACCEPT` * `TEAM_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 ``` ```text TEAM_JOIN_REQUEST { team_id, nonce } ``` --- #### Step 3:Leader UI 出现「加入请求」 **UI 行为(Leader)** * 显示“新成员请求加入” * [Accept] / [Ignore] 📌 UI 事件 **直接绑定** 下一条报文是否发送 --- #### Step 4:Leader 点击「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:点击「解散 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 ``` ```text TEAM_STATUS { team_id, payload = E2EE(MgmtKey, { state = DISBAND }) } ``` 📌 这是 **通知性质**,不是安全关键 📌 即使收不到,也不影响安全性 --- #### Step 3:所有设备进入 Disband **本地动作(MUST,所有成员)** * 立即销毁: * `TeamKey` * `ChannelPSK` * `MgmtKey / 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) 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*) └── ``` 说明: * `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 不关心任何协议或密钥** --- ## 三、状态 2:Team 进行中(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 / 交互状态 | ❌ 不应 | 完全是表现层 | --- ## 二、按状态机逐状态分析 --- ## 状态 0:Idle(未建队) ### 持久化数据 **无。** ```text (no Team-related persistent data) ``` ### 原则 * Idle 状态下 **设备上不存在任何 Team 痕迹** * 这是安全与心理边界的起点 --- ## 状态 1:Create(本地建队,尚未 Active) > 这是一个**非常短暂的状态** > 只存在于一次 UI 操作中 ### 是否需要持久化? ❌ **不需要** ### 原因 * Create 是一次**原子操作** * 要么成功进入 Active * 要么失败回到 Idle 👉 **Create 阶段失败 = 不留下任何痕迹** --- ## 状态 2:Active(Team 正在进行) 这是**唯一需要持久化的核心状态**。 --- ### 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 ``` 📌 **不要持久化“正在轮换中”** 📌 **要么成功覆盖,要么保持旧密钥** 这是**避免掉电/异常导致“半轮换”**的关键。 --- ## 状态 4:Disband(解散) ### 行为规则(强制) > **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. 普通成员离开 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 是否存在”的真相源** ```text PersistentTeamState ``` * 有它 → Team = Active * 没有它 → Team = Idle ❌ 不使用多个 flag ❌ 不在不同 namespace 里拆散 --- ### 2️⃣ 写入次数极少,是“生命周期级别”的 | 操作 | 写 Flash? | | ----------- | -------- | | 建队成功 | ✅ 一次 | | 成员加入成功 | ✅ 一次 | | Rotate Key | ✅ 一次 | | 正常使用(位置/聊天) | ❌ | | 解散 / 离开 | ✅ 删除 | > **不是高频写场景** > 所以重点是 **正确性 > 极限性能** --- ## 二、推荐存储方式(ESP32 实践) ### ✅ 推荐:NVS(Non-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 被 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_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 磨损问题直接变小) * 文件系统好用(导出、查看、同步) ### 只有 Flash(NVS/自定义分区) * 也能做,但要用**环形日志(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_/ 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_` 目录)。 --- # 10) 最小实现版本(你可以先落地这个) 如果你要先快速跑起来,我建议 MVP: * 只要三个文件: * `meta.json` * `keys.bin` * `events.log` * `events.log`:LogRecord + AEAD + trailer_crc32 * 不做快照、不做 idx * 位置事件做强制降采样(例如 10s 一条) 后续再加: * `snapshots.bin` + `snapshot.idx` ---