diff --git a/docs/team/IMPLEMENTATION_CHECKLIST.md b/docs/team/IMPLEMENTATION_CHECKLIST.md
deleted file mode 100644
index d6ff051e..00000000
--- a/docs/team/IMPLEMENTATION_CHECKLIST.md
+++ /dev/null
@@ -1,85 +0,0 @@
-# Team 实现清单(按你的架构分层)
-
-目标:参考现有 chat 的分层方式,在 Team 功能上采用同样的分层结构,并显式对齐「解释即架构」的开发思想。以下清单按层拆解,便于逐项落地与验收。
-
-## 1) Domain(纯模型层)
-
-- team/domain/team_types.h
- - 基础类型:TeamId, TeamKey, TeamRole, TeamState, TeamMember, TeamParams
- - 事件类型枚举:TeamEventType
- - 事件载体:TeamEvent(包含 event_id/team_id/ts/sender_id/key_id/payload)
-- team/domain/team_model.h/.cpp
- - 状态机:Idle/Create/Active/Rotate/Disband
- - 成员可见性/last_seen/health
- - 位置降采样策略
- - 复盘事件生成(纯内存结构,不涉及存储)
-
-## 2) Ports(接口层)
-
-- team/ports/i_team_store.h
- - 读写 PersistentTeamState(单一对象)
- - load/save/erase
-- team/ports/i_team_log_store.h
- - 追加写 TeamEvent(LogRecord)
- - appendEvent/appendSnapshot(snapshot 可选)
-- team/ports/i_team_mesh_adapter.h
- - sendAppData(portnum, payload, len, dest, want_ack)
- - pollIncomingData 返回 MeshIncomingData
-- team/ports/i_team_crypto.h
- - kdf, aeadEncrypt, aeadDecrypt
-
-## 3) Usecase(用例层)
-
-- team/usecase/team_service.h/.cpp
- - createTeam/joinTeam/acceptJoin/confirmJoin
- - rotateKey/disband/leave
- - processIncoming:消费 TEAM_*_APP 数据包
- - 严格遵守持久化时机(成功跃迁后写入,解散开始即删除)
- - 事件落盘(LogStore)与 EventBus 通知
-
-## 4) Infra(基础设施层)
-
-- 存储
- - team/infra/store/team_flash_store.*:NVS/Preferences 单 blob(含 magic/version/length/crc)
- - team/infra/store/team_log_store.*:SD append-only LogRecord(AEAD + CRC)
-- Meshtastic 适配
- - team/infra/meshtastic/team_mt_adapter.* 或复用 MtAdapter 新增 pollIncomingData/sendAppData
- - 解析 meshtastic_Data 的 portnum,将非 TEXT 的 payload 入队
- - 定义 TEAM_*_APP 自定义 portnum(基于 PortNum_PRIVATE_APP + n)
-- 密码学
- - team/infra/crypto/team_crypto_*:KDF + AEAD(与 TeamEncrypted 结构匹配)
-
-## 5) AppContext 与任务流
-
-- app/app_context.*
- - 新增 TeamModel, TeamService, TeamStore, TeamLogStore
- - AppContext::update 调用 team_service_->processIncoming
-- sys/event_bus.h
- - 新增 Team 相关事件类型(状态变化、成员变化、指令、诊断)
-
-## 6) 协议与编码
-
-- TeamEncrypted 统一 Envelope(version/team_id/key_id/nonce/ciphertext/aad_flags)
-- TEAM_MGMT_APP
- - TEAM_ADVERTISE, JOIN_REQUEST, JOIN_ACCEPT, JOIN_CONFIRM, TEAM_STATUS
-- TEAM_POSITION_APP / TEAM_WAYPOINT_APP
- - 明文 payload 为 meshtastic Position/Waypoint(再用 TeamEncrypted AEAD)
-
-## 7) 持久化与日志
-
-- PersistentTeamState 单对象
- - identity + secrets + channel + params + log_*
-- 启动恢复
- - magic/crc 失败 -> erase -> Idle
-- LogRecord
- - AEAD(LogKey) + trailer_crc32
- - 关键事件即时 flush
-
-## 8) MVP 优先级(建议)
-
-1. TeamService + TeamModel(状态机)
-2. Meshtastic adapter 支持 pollIncomingData/sendAppData
-3. TEAM_ADVERTISE / JOIN_REQUEST / JOIN_ACCEPT / TEAM_POSITION_APP
-4. NVS 持久化单对象
-5. SD LogRecord 追加写(无 snapshot)
-6. JOIN_CONFIRM / TEAM_STATUS / ROTATE
diff --git a/docs/team/persist.md b/docs/team/persist.md
new file mode 100644
index 00000000..7aab95ad
--- /dev/null
+++ b/docs/team/persist.md
@@ -0,0 +1,601 @@
+# Pager Team Core|SD-only 持久化方案(完整版)
+
+## 0. 目标与三条原则
+
+### 目标
+
+* **简单**:文件少、格式少、逻辑少
+* **可信**:断电可恢复、日志不会写坏全局
+* **资源消耗低**:避免高频随机写;控制日志增长
+
+### 核心原则
+
+* **SD-only**(不磨损 flash)
+* **append-only**(日志只追加,不回写不 seek)
+* **无每条 CRC**:靠 `magic + len` 判定完整记录;**尾巴不完整直接丢弃**
+* **“意图 vs 事实”分离**:UI 触发 **Intent**;落盘写入 **Committed Fact**
+
+---
+
+## 1. 目录结构(定稿)
+
+```text
+/team/
+ current.txt # 当前队伍目录名,一行文本
+ current.tmp # 原子写临时文件
+ T_A7K3/ # team_id 的短码/hash(稳定目录名)
+ keys.bin # 当前 team_psk(可选,单独持久化)
+ snapshot.bin # 当前世界快照(低频原子写)
+ snapshot.tmp # 原子写临时文件
+ events.log # 关键事件日志(追加写,sync 依据)
+ posring.log # 位置 ring(固定大小,覆盖最旧)
+ chatlog.log # 聊天日志(滚动上限)
+```
+
+### current.txt 格式
+
+* 内容示例:`T_A7K3\n`
+* 原子写:写 `current.tmp` → flush → rename 覆盖 `current.txt`
+
+---
+
+## 2. 数据模型分层(必须坚持的边界)
+
+### 2.1 关键事件(Key Events)
+
+**改变团队结构 / epoch / waypoint 的事实**,必须:
+
+* 写入 `events.log`
+* 带 `event_seq`(单调递增)
+* 作为 SYNC 的依据
+
+**必须写入**的关键事件类型:
+
+* `TeamCreated`
+* `MemberAccepted`
+* `MemberKicked`
+* `LeaderTransferred`
+* `EpochRotated`
+ (Waypoint 可选,后续加同样走 key event)
+
+### 2.2 高频态势(Non-key)
+
+* Presence:心跳/在线状态(不落盘)
+* Position:落 `posring.log`(ring + 节流)
+* Chat:落 `chatlog.log`(滚动上限)
+
+> 关键事件 = “世界规则变化”;位置/聊天 = “现场噪声与记录”。
+
+---
+
+## 3. event_seq:权威来源与落盘规则(答疑合并)
+
+### 3.1 event_seq 谁维护?
+
+✅ **Leader 维护并分配 event_seq**(推荐在 TeamCore/Usecase 层维护;TeamService 保持 IO 职责)
+
+* Leader 产生 key event 时:`event_seq = last_event_seq + 1`
+* Member 只接受带 seq 的 key event,并校验连续性
+
+### 3.2 UI 触发还是接收后落盘?
+
+✅ **两者都有,但落盘只能写“已提交事实(Committed Fact)”**
+
+* UI 触发的是 **Intent**(踢人/转让/轮换等请求)
+* 只有当 Leader / Member **确认提交**后,才写入 `events.log`
+
+具体:
+
+* **Leader**:UI Intent → 校验权限/状态 → Commit(分配 seq)→ append events.log → apply 内存状态 → 发送消息(失败靠后续 SYNC 修复)
+* **Member**:收到 leader 的 key event → 校验 team_id/epoch/权限/seq 连续性 → append events.log → apply
+
+> 避免出现“UI 点了但网络没发出去/没人承认,日志却写死了”的分裂。
+
+---
+
+## 4. 文件格式(team_id 统一 8 字节,无 CRC)
+
+通用约定:Little-endian、紧凑写、不依赖 struct padding。
+
+---
+
+### 4.1 TeamId
+
+* `team_id`:`uint64_t`(8 bytes)
+* 目录名建议:`T_` + base32(team_id)(或短 hash),保证 FAT 兼容
+
+---
+
+### 4.2 snapshot.bin(当前世界快照)
+
+#### Header(固定)
+
+```c
+SnapshotHeaderV1 {
+ char magic[4] = "TMS1";
+ uint8 version = 1;
+ uint8 flags; // bit0: in_team
+ uint16 reserved;
+
+ uint32 updated_ts; // 写快照时间(秒)
+ uint64 team_id; // 8B
+ uint32 epoch; // 当前 epoch
+ uint32 last_event_seq; // 已应用到的最后 seq
+
+ uint32 self_node_id;
+ uint32 leader_node_id;
+
+ uint8 self_role; // 0 None, 1 Member, 2 Leader
+ uint8 reserved2[3];
+
+ uint16 member_count;
+ uint16 reserved3;
+}
+```
+
+#### 成员表(重复 member_count 次,变长)
+
+```c
+MemberRecV1 {
+ uint32 node_id;
+ uint8 role; // 1 Member, 2 Leader
+ uint8 flags; // bit0: has_name
+ uint16 name_len; // 建议 <= 24
+ char name[name_len]; // UTF-8
+}
+```
+
+#### 原子写
+
+写 `snapshot.tmp` → flush → rename 覆盖 `snapshot.bin`
+
+#### 写入节流(低消耗)
+
+* 每 **10 条** key event 或 **60 秒**写一次(取先到)
+* epoch rotate / kick / transfer / join 成功 / 离队:**强制写一次**
+
+---
+
+### 4.3 events.log(关键事件日志:真相源 + SYNC 依据)
+
+#### Record header(固定)
+
+```c
+EventRecHeaderV1 {
+ char magic[2] = "EV";
+ uint8 version = 1;
+ uint8 type; // KeyEventType
+
+ uint32 event_seq; // 单调递增
+ uint32 ts; // 秒
+
+ uint16 payload_len;
+ uint16 reserved;
+}
+payload[payload_len]
+```
+
+#### 无 CRC 的可信规则
+
+扫描时若:
+
+* magic/version 不匹配 → 停止(最简单策略)
+* 文件剩余长度 < header + payload_len → 停止(尾部半条丢弃)
+
+因为只 append,停止点之前的数据可信。
+
+#### KeyEventType
+
+* `1 TeamCreated`
+* `2 MemberAccepted`
+* `3 MemberKicked`
+* `4 LeaderTransferred`
+* `5 EpochRotated`
+
+#### Payload 定义
+
+**TeamCreated**
+
+```c
+uint64 team_id
+uint32 leader_node_id
+uint32 epoch // 初始 1
+```
+
+**MemberAccepted**
+
+```c
+uint32 member_node_id
+uint8 role // 默认 1 Member
+uint8 reserved[3]
+```
+
+**MemberKicked**
+
+```c
+uint32 member_node_id
+```
+
+**LeaderTransferred**
+
+```c
+uint32 new_leader_node_id
+```
+
+**EpochRotated**
+
+```c
+uint32 new_epoch
+```
+
+> payload 尽量固定字段、少字符串:更省空间,更低 IO。
+
+---
+
+### 4.3.1 keys.bin(team_psk 独立持久化,避免重启后 keys 未就绪)
+
+> snapshot 不保存密钥,密钥单独落盘。
+> 仅保存当前生效的 team_psk + key_id(epoch),不做历史。
+
+#### 格式(固定)
+
+```c
+TeamKeysFileV1 {
+ char magic[4] = "TMK1";
+ uint8 version = 1;
+ uint8 psk_len; // 16
+ uint16 reserved;
+
+ uint64 team_id;
+ uint32 key_id; // epoch/key_id
+ uint8 psk[16]; // team_psk
+}
+```
+
+#### 原子写
+
+写 `keys.tmp` → flush → rename 覆盖 `keys.bin`
+
+---
+
+### 4.4 posring.log(位置 ring,高频但节流)
+
+#### Header(固定)
+
+```c
+PosRingHeaderV1 {
+ char magic[4] = "PSR1";
+ uint8 version = 1;
+ uint8 reserved1[3];
+
+ uint32 data_capacity; // data 区大小(固定)
+ uint32 write_offset; // 下一条写入位置
+ uint32 rec_size; // 固定 = sizeof(PosRecV1)
+ uint32 reserved2;
+}
+data[data_capacity]
+```
+
+#### Record(固定)
+
+```c
+PosRecV1 {
+ uint16 magic = 0x5053; // 'PS'
+ uint8 ver = 1;
+ uint8 flags;
+
+ uint32 ts;
+ uint32 member_id;
+ int32 lat_e7;
+ int32 lon_e7;
+
+ int16 alt_m;
+ uint16 speed_dmps;
+}
+```
+
+#### 写入节流(必须)
+
+* 每成员 15–30 秒最多写一条,或位移 > 20m 才写
+* ring 满了覆盖最旧,文件大小恒定
+
+---
+
+### 4.5 chatlog.log(聊天追加写 + 上限滚动)
+
+#### Record(变长)
+
+```c
+ChatRecHeaderV1 {
+ char magic[2] = "CH";
+ uint8 version = 1;
+ uint8 flags; // bit0 incoming/outgoing
+
+ uint32 ts;
+ uint32 peer_id;
+
+ uint16 text_len;
+ uint16 reserved;
+}
+text[text_len] // UTF-8
+```
+
+#### Team chat????????V2, TEAM_CHAT_APP?
+???? TeamChat ?????????? V2 ?? chatlog.log?
+
+```c
+ChatRecHeaderV2 {
+ char magic[2] = "CH";
+ uint8 version = 2;
+ uint8 flags; // bit0 incoming/outgoing
+
+ uint32 ts;
+ uint32 peer_id; // sender node_id
+
+ uint8 msg_type; // 1=Text 2=Location 3=Command
+ uint8 reserved1[3];
+
+ uint16 payload_len;
+ uint16 reserved2;
+}
+payload[payload_len] // decoded TeamChat payload
+```
+```
+
+#### 上限策略(简单)
+
+* 上限:**256KB** 或 **1000 条**(二选一)
+* 超限:
+
+ * rename `chatlog.log` → `chatlog.old`(可选)
+ * 新建空 `chatlog.log`
+
+---
+
+## 5. 启动恢复流程(有 current.txt,最快)
+
+1. 读 `/team/current.txt`
+
+* 不存在/空 → 当前无队伍
+
+2. 打开 `/team/
/snapshot.bin`
+
+* 若不存在 → 从空状态开始(但通常 join 后会写一次)
+
+3. 增量回放 `events.log`
+
+* 从 `snapshot.last_event_seq + 1` 开始顺序扫描应用
+* 遇到尾部不完整 → 停止
+
+4. UI Ready
+
+---
+
+## 6. SYNC 流程(依赖 event_seq)
+
+* Presence(不落盘)携带:`team_id, epoch, last_event_seq`
+* 发现对方 `last_event_seq > my_last_event_seq`:
+
+ * 发送 `SYNC_REQ(from_seq = my_last_event_seq + 1)`
+* 对方从 `events.log` 读 seq 范围(最多 N 条)回 `SYNC_RSP`
+* 本地 apply + append(对已存在 seq 可跳过),更新 snapshot
+
+---
+
+## 7. event_seq + commit 流程(ASCII 状态/时序图)
+
+### 7.1 Leader:UI Intent → Commit → Log → Broadcast
+
+```text
+UI TeamCore/Usecase TeamStore(SD) TeamService(IO) Mesh
+ | intentKick(target) | | | |
+ |------------------------------>| check(role==Leader) | | |
+ | | build KeyEvent(Kicked) | | |
+ | | seq = last_seq + 1 | | |
+ | |----------------------------->| append events.log(EV,seq)| |
+ | |<-----------------------------| ok | |
+ | | apply to in-mem state | | |
+ | | maybe snapshot throttle | | |
+ | |------------------------------>| save snapshot.tmp->bin | |
+ | |<-----------------------------| ok | |
+ | |----------------------------------------------------------->| sendKick(seq,...) |
+ | | |------------------->|
+ | | | (may fail) |
+ | | NOTE: even if send fails, state is committed; | |
+ | | later Status/SYNC will repair delivery | |
+```
+
+### 7.2 Member:RX KeyEvent → Verify seq → Log → Apply(缺 seq 触发 SYNC)
+
+```text
+Mesh TeamService(IO) TeamCore/Usecase TeamStore(SD)
+ | RX Kick(seq,...) | | |
+ |------------------------>| decode/decrypt | |
+ | | sink_.onTeamKick(...) ---->| verify team_id/epoch |
+ | | | verify seq == last+1 ? |
+ | | | NO -> request SYNC |
+ | | | YES -> commit apply |
+ | | |------------------------> append events.log
+ | | |<------------------------ ok
+ | | | apply in-mem state
+ | | | maybe snapshot throttle
+ | | |------------------------> save snapshot atomic
+ | | |<------------------------ ok
+```
+
+### 7.3 seq 不连续时(Member 触发 SYNC)
+
+```text
+Member Core Mesh Leader Core
+ | see seq gap (expected=41 got=45) |
+ |---------------- SYNC_REQ(from=41) --------------------------->|
+ | | read events.log seq>=41
+ |<---------------- SYNC_RSP(events 41..45) ---------------------|
+ | apply + append + snapshot |
+```
+
+---
+
+## 8. 工程接入点(与 TeamService / ITeamEventSink 对齐)
+
+你现在 `TeamService::processIncoming()` 会把消息解包后丢给 `sink_.onTeamXxx(event)`。
+
+推荐最小改动:
+
+* `sink_` 的实现(TeamCore/Usecase)负责:
+
+ * 权限判断
+ * seq 分配(leader)
+ * seq 连续性检查(member)
+ * 调用 `TeamStore.append_key_event(...)` 写 `events.log`
+ * 更新内存状态
+ * 触发 snapshot(节流)
+* `TeamService` 仅做 IO:decode/encode/send,不直接写 SD
+
+---
+
+## 9. PosRing / ChatLog 接入点(答疑合并入规范)
+
+### 9.1 posring.log:从接收写还是从发送写?
+组队模式下 GPS 从 posring.log 渲染队员位置(包含自己)。
+
+✅ 定稿:**两边都写,并统一走同一个节流器**
+
+* **收到 TeamPosition(队友)**:写入 `posring.log`(队友态势来源)
+* **本机 GPS fix(自己)**:也写入 `posring.log`(重启后仍能立即看到自己最后位置/轨迹片段)
+
+原因:
+
+* 只写接收 → 自己历史为空
+* 只写发送 → 队友态势重启后为空
+* posring 是“事实流缓存”,不参与一致性:不用 event_seq,不用严格去重
+ 只需做到:每成员节流 + ts 最新覆盖 UI
+
+**推荐接入(最干净)**:只在 **TeamCore(sink 实现)**里写 posring
+
+* `sink_.onTeamPosition(event)`:decode → `posring_append_throttled(from_id, pos, ts)`
+* 本机 GPS 更新处:`TeamCore::onLocalPositionFix(fix)` → `posring_append_throttled(self_id, pos, ts)` → 再决定是否 `TeamService.sendPosition(...)`
+
+### 9.2 chatlog.log:记录 Mesh chat(Team channel)还是仅 Team 协议?
+决策(v0.1):队伍聊天使用 TEAM_CHAT_APP;TeamChat 消息按 V2 写入 chatlog.log。
+地图交互:收到消息只弹系统通知;弹窗由 Chat 会话中选中地图标注后触发,显示该位置瓦片地图的裁剪图。
+不要把 TEAM_MGMT_APP 记录到 chatlog.log。
+
+先分清三种消息域:
+
+1. **Meshtastic 普通聊天**(你现有 chat 模块)
+2. **Team 管理消息(TEAM_MGMT_APP)**:Join/Kick/Rotate/Status…(不是聊天)
+3. **队内聊天**(可复用 Meshtastic text,也可未来自定义 Team chat port)
+
+✅ 定稿:**chatlog.log 只记录用户可见的“聊天文本”,不记录管理消息**
+
+* ❌ 不记录 `TEAM_MGMT_APP` 到 chatlog(它们属于 `events.log` / diagnostics)
+* ✅ 记录“队内聊天文本”
+
+ * 推荐走 **路线 A**:记录 **Meshtastic text 中属于当前 Team channel 的消息**
+
+路线 A(推荐):记录 Meshtastic 文本聊天(Team channel)
+
+* 优点:立刻兼容现有生态(安卓/ATAK/其他节点)
+* 缺点:聊天与 Team 语义解耦(可接受)
+
+路线 B(后续版本):Team 专用 chat 协议(TEAM_CHAT_APP / TEAM_MGMT.Chat)
+
+* 优点:聊天与 team_id/epoch 绑定、成员可控
+* 缺点:需要新增协议与兼容处理
+
+**chatlog 的接入点(工程)**
+
+* 因为 TeamService 不处理 text chat,所以 chatlog 应在 **chat 模块**接入:
+
+ * ChatService/ChatUsecase 收到/发送文本时:
+
+ * 若 `channel_id == current_team_channel` → `chatlog_append(...)`
+* TeamCore 只需要提供“当前 team channel_id”
+
+---
+
+## 10. 缺口补齐总结(当前约束)
+
+* team_id:✅ 8 字节(uint64)
+* 关键事件必须落盘:✅ events.log + type + payload 完整定义
+* event_seq:✅ leader 权威维护;snapshot 记录 last_seq;member 检查连续性;缺口走 SYNC
+* Intent vs Fact:✅ UI 触发 Intent;落盘只写 Committed Fact
+* posring/chatlog:✅ 接入点与记录范围明确(pos 双写、chat 记 team channel 文本)
+
+---
+
+# Team Position(对齐 Meshtastic POSITION_APP)
+
+## 1) 设计目标
+
+* **对齐 Meshtastic**:payload 直接使用 `meshtastic_Position` protobuf
+* **生态兼容**:与 Meshtastic POSITION_APP 解码一致
+* **扩展性**:保留 Meshtastic 字段的自然扩展空间
+
+---
+
+## 2) Wire Payload
+
+* **类型**:`meshtastic_Position`(protobuf)
+* **端口**:`meshtastic_PortNum_POSITION_APP`
+* **编码**:nanopb / pb_encode
+
+> 说明:不再使用自定义布局。Position payload 与 Meshtastic 完全一致。
+
+---
+
+## 3) 取值规则(最小集)
+
+* `latitude_i` / `longitude_i`:E7(int32)
+* `location_source`:默认 `LOC_INTERNAL`
+* `sats_in_view`:有则填
+* `timestamp`:有有效 epoch 秒则填
+
+其余字段按需填充,保持与 Meshtastic 语义一致。
+
+---
+
+# 5) 编码/解码接入点(对齐你现有 TeamService + sink)
+
+## 5.1 发送端(本机 GPS 更新)
+
+**推荐路径**(TeamCore 层做事):
+
+1. `TeamCore::onLocalPositionFix(fix)`
+2. 生成 `meshtastic_Position` payload(protobuf)
+3. **先** `posring_append_throttled(self_id, decoded_pos)`(同一个点落盘)
+4. 再调用 `TeamService.sendPosition(payload, team_channel)`
+
+> 你问过“posring 从发送写还是接收写”:这里就是“发送也写”。
+
+## 5.2 接收端(TEAM_POSITION_APP)
+
+你现在流程已经有:
+
+* `TeamService::processIncoming()` 解密 → `sink_.onTeamPosition(event{ctx, plain})`
+
+在 `sink` 的实现(TeamCore)里:
+
+1. decode `meshtastic_Position` → 得到标准化字段
+2. `posring_append_throttled(from_id, pos)`(队友点落盘)
+3. 更新内存 `last_seen`(presence/online 状态)
+
+---
+
+# 6) posring.log 落盘字段对齐建议
+
+你现有 `PosRecV1` 足够用(ts、member_id、lat/lon、alt、speed)。
+`course` 和 `vbat`:
+
+* **可以不落盘**(UI 大多用轨迹方向估计就够了;vbat 在 topbar 也不一定要显示队友)
+* 需要时再出 `PosRecV2`(加 2 字节 course、2 字节 vbat,不难)
+
+---
+
+# 7) 版本与兼容规则
+
+* payload 必须能解码为 `meshtastic_Position`,否则丢弃(或统计 error)
+* 不做自定义版本字段检查(以 Meshtastic 协议为准)
+
+
diff --git a/docs/team/team_chat_protocol.md b/docs/team/team_chat_protocol.md
new file mode 100644
index 00000000..0f7a95a9
--- /dev/null
+++ b/docs/team/team_chat_protocol.md
@@ -0,0 +1,167 @@
+# Team Chat 协议方案(方案 C,v0.1)
+
+本文件描述“队伍聊天”协议的最小可实施方案,用于承载结构化消息:
+Text / Location / Command,并与 Team 安全域绑定,支持地图与态势联动。
+
+---
+
+## 1. 目标与范围
+
+- 目标:让队伍聊天成为“可解析、可执行”的结构化消息流。
+- 覆盖媒体类型:
+ - Text:普通文本。
+ - Location:位置分享,可在 Chat/GPS 地图渲染。
+ - Command:指令类消息,可触发队伍行动提示与地图标注。
+- 安全:仅队伍成员可解密,不依赖 Meshtastic 普通聊天频道。
+
+非目标:
+- 不替换现有的普通聊天/广播消息。
+- 不实现复杂可靠传输(v0.1 先做 best-effort)。
+
+---
+
+## 2. 端口与加密
+
+- 新增端口:
+ - `TEAM_CHAT_APP = 303`(在 `src/team/protocol/team_portnum.h` 中定义)
+- 加密方式:
+ - 复用 `TeamEncrypted` envelope(`team_wire.h`)。
+ - 从 `team_psk` 派生 `team_chat` key。
+ - 建议在 `TeamService::setKeysFromPsk()` 中新增:
+ - `deriveKey(psk, "team_chat", keys.chat_key)`
+
+这样可实现“队伍聊天协议层隔离”,非队伍成员无法解密。
+
+---
+
+## 3. Payload 结构(v0.1)
+
+### 3.1 通用头部
+
+使用简洁 TLV 或固定头 + 变长 payload。建议固定头:
+
+```
+struct TeamChatHeader {
+ uint8_t version; // =1
+ uint8_t type; // 1=Text 2=Location 3=Command
+ uint16_t flags; // 预留
+ uint32_t msg_id; // 本地生成,防重放/去重
+ uint32_t ts; // 发送时间(unix)
+ uint32_t from; // 发送者 node_id
+};
+```
+
+### 3.2 Text
+
+```
+struct TeamChatText {
+ // UTF-8 text bytes
+ bytes text;
+};
+```
+
+### 3.3 Location
+
+```
+struct TeamChatLocation {
+ int32_t lat_e7;
+ int32_t lon_e7;
+ int16_t alt_m; // optional, 0=unknown
+ uint16_t acc_m; // optional, 0=unknown
+ uint32_t ts; // optional, 0=use header.ts
+ uint8_t source; // 0=GPS 1=Manual 2=CommandTarget
+ bytes label; // optional short label
+};
+```
+
+### 3.4 Command
+
+v0.1 仅做最小指令集,不做撤销/过期语义。
+
+```
+enum TeamCommandType : uint8_t {
+ RallyTo = 1, // 集结到目标点
+ MoveTo = 2, // 前往目标点
+ Hold = 3 // 原地待命
+};
+
+struct TeamChatCommand {
+ uint8_t cmd_type;
+ int32_t lat_e7; // 可选:用于 Rally/Move
+ int32_t lon_e7;
+ uint16_t radius_m; // 可选:集合半径
+ uint8_t priority; // 0=normal 1=high
+ bytes note; // 可选:简短备注
+};
+```
+
+---
+
+## 4. 发送与接收流程
+
+### 4.1 发送
+
+- UI 产生 `Text/Location/Command`。
+- 编码为 `TeamChatHeader + payload`。
+- 通过 `TeamService::sendTeamChat()`:
+ - 使用 `keys.chat_key` 加密封装为 `TeamEncrypted`。
+ - 通过 `TEAM_CHAT_APP` 发送(`mesh_.sendAppData(...)`)。
+
+### 4.2 接收
+
+- `TeamService::processIncoming()` 新增 `TEAM_CHAT_APP` 分支:
+ - 解密 `TeamEncrypted`。
+ - 解析 `TeamChatHeader`。
+ - 触发 `TeamChatEvent`(新增 EventBus 类型)。
+- UI 接收到 `TeamChatEvent`:
+ - 追加到 `team_ui_chatlog`(新增类型字段)。
+ - 若是 Location/Command,同步更新地图/GPS 页标注。
+
+---
+
+## 5. 与现有代码的改动点
+
+最小改动清单(v0.1):
+
+1. **协议与端口**
+ - `src/team/protocol/team_portnum.h` 新增 `TEAM_CHAT_APP = 303`
+ - 新增 `team_chat.h/.cpp`(编码/解码)
+
+2. **密钥派生**
+ - `TeamKeys` 增加 `chat_key`
+ - `TeamService::setKeysFromPsk()` 中派生 `"team_chat"`
+
+3. **TeamService**
+ - 新增 `sendTeamChat(...)`
+ - `processIncoming()` 处理 `TEAM_CHAT_APP`
+
+4. **事件总线**
+ - `sys/event_bus.h` 新增 `TeamChatEvent`
+
+5. **UI / 存储**
+ - `team_ui_chatlog_append()` 扩展为结构化消息(type + payload)
+ - `Contacts/Team` 聊天页改用 TeamChat 数据源渲染卡片
+
+---
+
+## 6. 兼容性与过渡
+
+- 保留现有普通聊天:
+ - Primary/Secondary 继续用 TEXT_MESSAGE_APP
+- Team Chat 作为独立通道:
+ - 不影响普通聊天历史与通知
+ - 可以逐步替换 Team 页的会话来源
+
+---
+
+## 7. v0.1 已确定事项
+- ACK/送达:不需要(尽力而为)。
+- Command 撤销/过期:不支持。
+- 地图交互:收到消息只弹系统通知;弹窗由 Chat 会话中选中地图标注后触发,显示该位置瓦片地图的裁剪图。
+- 组队模式 GPS:从 posring.log 渲染队员位置。
+
+## 8. 版本策略
+
+- `TeamChatHeader.version` = 1
+- 后续扩展通过 `flags` 或新 `type` 兼容
+
diff --git a/docs/team/uiux.md b/docs/team/uiux.md
index 87963fe1..95834713 100644
--- a/docs/team/uiux.md
+++ b/docs/team/uiux.md
@@ -2,6 +2,7 @@
> 设备:**2.33-inch 横屏 222×480**
> 约束:**固定 TopBar(Back / Title / Battery)**
+> 原则:**Radio + NFC 并行**;NFC 只在对应页面启用(省电/避免误触)
---
@@ -33,9 +34,9 @@
│ You are not in a team │
│ │
│ • No shared map │
-│ • No team awareness │
+│ • No team awareness │
│ │
-│ Create or join a team │
+│ Create or join a team │
│ │
├──────────────────────────────────────────────┤
│ [ Create Team ] [ Join Team ] │
@@ -52,11 +53,11 @@
┌──────────────────────────────────────────────┐
│ < Back Team Status 🔋 │
├──────────────────────────────────────────────┤
-│ Team: ALPHA-7 │
+│ Team: ALPHA-7 (ID: A7K3) │
│ Role: Member │
-│ Members: 5 │
-│ Online: 3 │
-│ Security: OK (Round 5) │
+│ Members: 5 Online: 3 │
+│ Security: OK (Epoch 5) │
+│ Sync: OK (Last event 128) │
│ │
├──────────────────────────────────────────────┤
│ Team Health │
@@ -70,7 +71,7 @@
└──────────────────────────────────────────────┘
```
-> 这是 **判断“队伍是否还可信”** 的页面
+> 这是 **判断“队伍是否还可信 / 是否对齐”** 的页面
> 不是管理页
---
@@ -83,22 +84,47 @@
┌──────────────────────────────────────────────┐
│ < Back Team · Leader 🔋 │
├──────────────────────────────────────────────┤
-│ Team: ALPHA-7 │
-│ Members: 3 Online: 2 │
-│ Security Round: 5 │
+│ Team: ALPHA-7 (ID: A7K3) │
+│ Members: 3 Online: 2 │
+│ Epoch: 5 Sync: OK (128) │
│ │
├──────────────────────────────────────────────┤
-│ Members │
+│ Requests: 2 pending > Open │ (仅 Leader 且有请求时显示)
│ ────────────────────────────────────────── │
-│ ● You (Leader) Online │
-│ ● Tom Online │
-│ ○ Jerry Last seen 2m ago │
+│ Members │
+│ ● You (Leader) Online │
+│ ● Tom Online │
+│ ○ Jerry Last seen 2m ago │
│ │
├──────────────────────────────────────────────┤
│ [ Invite ] [ Manage ] [ Leave ] │
└──────────────────────────────────────────────┘
```
+> **Requests 收件箱** 用来替代 “弹窗可能错过”的问题
+> 重要:户外场景下用户经常在地图/聊天页,不一定看得到 popup
+
+---
+
+## A3b. Join Request(Leader 弹窗)
+
+**Title:`Join request`**
+
+```
+┌──────────────────────────────────────────────┐
+│ < Back Join request 🔋 │
+├──────────────────────────────────────────────┤
+│ Tom wants to join │
+│ │
+├──────────────────────────────────────────────┤
+│ [ Accept ] [ Reject ] │
+└──────────────────────────────────────────────┘
+```
+
+**规则(v0.1)**
+* Leader 一次只处理 **1 个 pending join**,其余排队/稍后重试
+ (避免同时 join 导致 rotate / key_dist 混乱)
+
---
## A4. Invite 页面(Leader)
@@ -109,22 +135,62 @@
┌──────────────────────────────────────────────┐
│ < Back Invite 🔋 │
├──────────────────────────────────────────────┤
-│ Invite Code │
+│ Mode: Radio │
+│ Team: ALPHA-7 (ID: A7K3) │
│ │
-│ J4K – 9Q2 │
+│ Invite Code │
+│ J4K9Q2 │
+│ Time left: 08:45 │
│ │
-│ Expires in: 08:45 │
-│ │
-│ Nearby devices can join │
+│ Nearby devices can request to join │
│ │
├──────────────────────────────────────────────┤
-│ [ Refresh Code ] [ Stop Invite ] │
+│ [ Stop Invite ] [ Switch Mode ] │
└──────────────────────────────────────────────┘
```
+> `Refresh Code` 属于异常处理路径,v0.1 可放在长按/菜单里,不占主按钮位
+
---
-## A5. Join Team(扫描 / 输入邀请码)
+### Invite Code 格式(v0.1)
+
+* 6 位连续字符(无分隔符)
+* 字符集:`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`(不含 I/O/1/0)
+* 默认有效期:9 分钟
+* **绑定规则**:Invite Code 必须和 `team_id_short (A7K3)` 一起校验
+ (Invite Code 不是全局唯一,只在 team 上下文里成立)
+
+---
+
+## A4b. Invite via NFC(Leader)
+
+**Title:`Invite via NFC`**
+
+```
+┌──────────────────────────────────────────────┐
+│ < Back Invite via NFC 🔋 │
+├──────────────────────────────────────────────┤
+│ Mode: NFC │
+│ Team: ALPHA-7 (ID: A7K3) │
+│ │
+│ Invite Code │
+│ J4K9Q2 │
+│ Time left: 08:45 │
+│ │
+│ Tap another device to share key │
+│ Invite code protects the NFC key │
+│ │
+├──────────────────────────────────────────────┤
+│ [ Start NFC ] [ Stop Invite ] │
+└──────────────────────────────────────────────┘
+```
+
+> 进入此页面才开启 NFC(轮询/卡模拟),退出即关闭以省电/避免误触。
+
+---
+
+## A5. Join Team(选择方式)
**Title:`Join Team`**
@@ -134,13 +200,64 @@
├──────────────────────────────────────────────┤
│ Nearby Teams │
│ ────────────────────────────────────────── │
-│ ALPHA-7 Signal: ▮▮▮▯ │
-│ BETA-3 Signal: ▮▮▯▯ │
+│ ALPHA-7 (A7K3) Signal: ▮▮▮▯ [ Join ] │
+│ BETA-3 (B3Q1) Signal: ▮▮▯▯ [ Join ] │
│ │
-│ Or enter invite code │
+│ Other options │
+│ • Enter Invite Code (Radio) │
+│ • Tap to join (NFC, recommended) │
│ │
├──────────────────────────────────────────────┤
-│ [ Enter Code ] [ Refresh ] │
+│ [ Enter Invite Code ] [ Join via NFC ] │
+│ [ Refresh ] │
+└──────────────────────────────────────────────┘
+```
+
+> “Nearby Team + Join” 强调这是“直接申请加入该队”
+> “Enter Invite Code” 明确它是 Radio 模式的邀请码匹配
+
+---
+
+## A5b. Join via NFC(Member)
+
+**Title:`Join via NFC`**
+
+```
+┌──────────────────────────────────────────────┐
+│ < Back Join via NFC 🔋 │
+├──────────────────────────────────────────────┤
+│ │
+│ Hold device near leader/device │
+│ Scanning... 08s │
+│ │
+│ NFC is on only during this screen │
+│ │
+├──────────────────────────────────────────────┤
+│ [ Cancel ] │
+└──────────────────────────────────────────────┘
+```
+
+> NFC 读取成功后:自动跳转到 “Enter Code” 输入页(用于解密 NFC key,见协议 D2b)
+
+---
+
+## A5c. Enter Invite Code(Radio/NFC 共用)
+
+**Title:`Enter Code`**
+
+```
+┌──────────────────────────────────────────────┐
+│ < Back Enter Code 🔋 │
+├──────────────────────────────────────────────┤
+│ Team ID (if known): A7K3 │
+│ │
+│ Code: _ _ _ _ _ _ │
+│ │
+│ • Radio: used to request join │
+│ • NFC: used to decrypt the shared key │
+│ │
+├──────────────────────────────────────────────┤
+│ [ Cancel ] [ Confirm ] │
└──────────────────────────────────────────────┘
```
@@ -155,11 +272,11 @@
│ < Back Join Request 🔋 │
├──────────────────────────────────────────────┤
│ │
-│ Request sent to ALPHA-7 │
+│ Request sent to ALPHA-7 (A7K3) │
│ │
-│ Waiting for approval... │
+│ Waiting for approval... │
│ │
-│ This may take a moment │
+│ This may take a moment │
│ │
├──────────────────────────────────────────────┤
│ [ Cancel ] [ Retry ] │
@@ -176,13 +293,13 @@
┌──────────────────────────────────────────────┐
│ < Back Members 🔋 │
├──────────────────────────────────────────────┤
-│ ● You (Leader) │
+│ ● You (Leader) │
│ │
-│ ● Tom │
-│ > Select │
+│ ● Tom │
+│ > Select │
│ │
-│ ○ Jerry │
-│ > Select │
+│ ○ Jerry │
+│ > Select │
│ │
└──────────────────────────────────────────────┘
```
@@ -197,17 +314,16 @@
┌──────────────────────────────────────────────┐
│ < Back Member: Jerry 🔋 │
├──────────────────────────────────────────────┤
-│ Status: Last seen 2m ago │
-│ Role: Member │
+│ Status: Last seen 2m ago │
+│ Role: Member │
│ │
-│ Device: Pager │
-│ │
-│ Capability: │
-│ • Position │
-│ • Waypoint │
+│ Device: Pager │
+│ Capability: │
+│ • Position │
+│ • Waypoint │
│ │
├──────────────────────────────────────────────┤
-│ [ Kick ] [ Transfer Leader ] │
+│ [ Kick ] [ Transfer Leader ] │
└──────────────────────────────────────────────┘
```
@@ -221,20 +337,36 @@
┌──────────────────────────────────────────────┐
│ < Back Kick Member 🔋 │
├──────────────────────────────────────────────┤
-│ Remove Jerry from team? │
+│ Remove Jerry from team? │
│ │
-│ This will update the security round. │
-│ Jerry will no longer receive │
-│ team messages or waypoints. │
+│ This will update the security round (epoch). │
+│ Jerry will no longer receive team updates. │
│ │
├──────────────────────────────────────────────┤
-│ [ Cancel ] [ Confirm Kick ] │
+│ [ Cancel ] [ Confirm Kick ] │
└──────────────────────────────────────────────┘
```
---
-## A10. 被踢 / 失效状态页(Member)
+## A9b. Leave 确认弹窗
+
+```
+┌──────────────────────────────────────────────┐
+│ < Back Leave team? 🔋 │
+├──────────────────────────────────────────────┤
+│ This clears local keys. │
+│ │
+├──────────────────────────────────────────────┤
+│ [ Cancel ] [ Leave ] │
+└──────────────────────────────────────────────┘
+```
+
+> Leave 需要二次确认,避免误触导致本地密钥被清空
+
+---
+
+## A10. Access Lost(Member:被踢 / 失效 / 不一致)
**Title:`Team`**
@@ -242,15 +374,21 @@
┌──────────────────────────────────────────────┐
│ < Back Team 🔋 │
├──────────────────────────────────────────────┤
-│ You are no longer in this team │
+│ Access lost │
│ │
-│ Access to team data revoked │
+│ Reason: [ Revoked | Out-of-sync | Unknown ] │
+│ │
+│ • Revoked: removed by leader │
+│ • Out-of-sync: team updated, sync required │
│ │
├──────────────────────────────────────────────┤
-│ [ Join Another Team ] [ OK ] │
+│ [ Try Sync ] [ Join Another Team ] [ OK ]│
└──────────────────────────────────────────────┘
```
+> 关键:区分“被踢” vs “密钥/epoch 不一致”,减少误判
+> `Try Sync` 只在 Out-of-sync 时启用
+
---
# B. 页面流转说明(UI 状态机)
@@ -277,6 +415,12 @@
|
v
[ Join Team ]
+ |
+ +--> [ Join via NFC ] -> (read ok) -> [ Enter Code ] -> [ Join Pending ]
+ |
+ +--> [ Enter Invite Code ] ---------> [ Join Pending ]
+ |
+ +--> [ Nearby Teams -> Join ] ------> [ Join Pending ]
|
v
[ Join Pending ]
@@ -294,18 +438,17 @@
[ Team Home ]
|
v
- [ Invite ]
+ [ Invite ] <--> [ Invite via NFC ]
|
- +-- member joins --> [ Join Request popup ]
+ +-- member joins --> [ Requests (Inbox) ]
|
- +-- accept --> epoch rotate
- | |
- | v
- | [ Team Status ]
+ +-- accept --> epoch rotate --> [ Team Status ]
|
+-- reject
```
+> Join Request 不依赖 popup;popup 只是“提示”,Inbox 才是可靠入口。
+
---
## B4. 踢人流程(Leader)
@@ -331,22 +474,38 @@
## C1. 协议消息类型(最小集)
-| 类型 | 用途 |
-| -------------------- | ------- |
-| `TEAM_INVITE` | 广播邀请码 |
-| `TEAM_JOIN_REQ` | 申请加入 |
-| `TEAM_JOIN_DECISION` | 同意 / 拒绝 |
-| `TEAM_KICK` | 踢人事件 |
-| `TEAM_EPOCH_ROTATE` | 宣告轮次更新 |
-| `TEAM_KEY_DIST` | 新密钥分发 |
-| `TEAM_PRESENCE` | 在线状态 |
-| `TEAM_POS` | 位置 |
-| `TEAM_SYNC_REQ` | 补齐请求 |
-| `TEAM_SYNC_RSP` | 补齐响应 |
+| 类型 | 用途 |
+| -------------------- | --------------------------------- |
+| `TEAM_INVITE` | 广播邀请码(Plain) |
+| `TEAM_JOIN_REQ` | 申请加入(Plain) |
+| `TEAM_JOIN_DECISION` | 同意/拒绝(Plain) |
+| `TEAM_KEY_DIST` | 新密钥分发(对新成员 Plain 定向 / 对旧成员可加密或定向) |
+| `TEAM_KICK` | 踢人事件(加密,已在 team 内的成员可读) |
+| `TEAM_EPOCH_ROTATE` | 宣告轮次更新(通常作为 KeyEvent 记录并可广播提示) |
+| `TEAM_PRESENCE` | 在线状态(加密) |
+| `TEAM_POS` | 位置(加密) |
+| `TEAM_SYNC_REQ` | 补齐请求(加密) |
+| `TEAM_SYNC_RSP` | 补齐响应(加密) |
+
+> v0.1 约束:**新成员在拿到 key 前必须能完成 Join Handshake**
+> 所以 `INVITE/JOIN_REQ/JOIN_DECISION/KEY_DIST(for newcomer)` 必须是 Plain 可解。
---
-## C2. TeamEnvelope(所有 Team 包统一外壳)
+## C2. 字段命名定稿:epoch / event_seq / msg_id
+
+为了避免实现踩雷,明确:
+
+* `epoch`:**密钥轮次**(加解密 key 选择)
+* `event_seq`:**关键事件序号**(仅 Key Events 与 Sync 使用,单调递增)
+* `msg_id`:**普通包去重标识**(可选,v0.1 可不做)
+
+---
+
+## C3. TeamEnvelope(所有 Team 包统一外壳,v0.1)
+
+> `event_seq` 只在关键事件或 sync 承载事件时出现;
+> Presence/Pos 不要求连续 seq。
```
TeamEnvelope {
@@ -354,72 +513,146 @@ TeamEnvelope {
epoch
type
sender_id
- seq
timestamp
- auth
+ msg_id? // optional, v0.1 may omit
+ auth // AEAD tag or MAC (depends on encrypt/plain)
payload
}
```
---
+## C4. Key Events(写入 events.log 的“结构真相”)
+
+v0.1 必须记录的关键事件:
+
+* `TeamCreated(event_seq=1)`
+* `MemberAccepted(event_seq++)`
+* `MemberKicked(event_seq++)`
+* `LeaderTransferred(event_seq++)`
+* `EpochRotated(event_seq++)`
+
+> Key Events 是 Sync 的依据;Presence/Pos/Chat 不属于 Key Events。
+
+---
+
# D. 协议流转(和 UI 的对应关系)
## D1. Create Team
* 本地生成 `team_id`
* `epoch = 1`
-* 自己 = leader
-* 生成 `team_key(1)`
+* self = leader
+* 生成 `team_key(epoch=1)`
+* 追加 key event:`TeamCreated(event_seq=1)`
+* 写 snapshot + events.log
---
-## D2. Invite / Join
+## D2. Invite / Join(Radio)
-**Invite**
+**Invite(Radio, Plain)**
-* Leader 周期发 `TEAM_INVITE`
+* Leader 周期发 `TEAM_INVITE(team_id_short, invite_code, expires_at)`
-**Join**
+**Join(Radio, Plain → KeyDist)**
-1. Member → `TEAM_JOIN_REQ`
-2. Leader → `TEAM_JOIN_DECISION(ACCEPT)`
-3. Leader → `epoch++`
-4. Leader → `TEAM_KEY_DIST(epoch)`
-5. 双方进入新 epoch
+1. Member → `TEAM_JOIN_REQ(team_id_short, invite_code, member_pub/cap...)`(Plain)
+2. Leader → `TEAM_JOIN_DECISION(ACCEPT/REJECT, team_id, leader_id)`(Plain)
+3. Leader:写入 `MemberAccepted`(event_seq++)
+4. Leader:`epoch++` 并写入 `EpochRotated`(event_seq++)
+5. Leader → `TEAM_KEY_DIST(epoch, key)` **定向给新成员(Plain)**
+6. Leader → `TEAM_KEY_DIST(epoch, key)` 分发给旧成员(可定向/可加密,策略实现)
+7. 全员进入新 epoch,开始加密 Presence/Pos/WP
+
+---
+
+## D2b. Invite / Join(NFC)
+
+**NFC 目标**:让新成员无需空口接收 KeyDist 也能拿到 key(更可靠/更快),同时避免明文泄露。
+
+### NFC Payload(建议以 NDEF 自定义记录承载)
+
+* `magic/version`
+* `team_id`
+* `epoch`(当前或即将生效的 epoch)
+* `team_id_short`(A7K3)
+* `expires_at`
+* `salt + nonce`
+* `ciphertext(team_key)`(用 Invite Code 派生密钥加密)
+* `tag`
+
+### 加密建议(v0.1)
+
+* KDF:PBKDF2-HMAC-SHA256(迭代 10k)
+* AEAD:AES-GCM
+
+### 流程
+
+1. Leader 在 `Invite via NFC` 页开启卡模拟(仅页面内开启)
+2. Member 在 `Join via NFC` 页轮询读取 payload
+3. Member 自动进入 `Enter Code`,输入 Invite Code 解密得到 `team_key`
+4. Member 可直接进入 `Join Pending` 发送 `TEAM_JOIN_REQ`(Plain)
+5. Leader Accept 后仍需 **记录 Key Events + epoch rotate**
+6. Leader 对旧成员分发新 epoch key(KeyDist)
+7. 对新成员:可以选择
+
+ * A) 不发 KeyDist(因为 NFC 已提供),只发 Decision
+ * B) 仍发定向 KeyDist(作为冗余保障)
+
+> v0.1 推荐:A 为主,B 可作为可选兼容/容错开关
---
## D3. Kick
-1. Leader → `TEAM_KICK(target)`
-2. Leader → `epoch++`
-3. Leader → `TEAM_KEY_DIST(epoch)`
-4. 被踢成员解密失败 → UI 切到 A10
+1. Leader:写入 `MemberKicked(event_seq++)`
+2. Leader:`epoch++`,写入 `EpochRotated(event_seq++)`
+3. Leader → `TEAM_KICK(target)`(加密,成员可读)
+4. Leader → `TEAM_KEY_DIST(new_epoch)` 分发给剩余成员
+5. 被踢成员:后续解密失败 / 收到明确 Kick → UI 切到 A10,Reason=Revoked
---
-## D4. Presence & Health
+## D4. Presence & Health(Status 页字段来源)
-* 所有成员周期发 `TEAM_PRESENCE`
-* Status 页字段来自:
+* 全员周期发 `TEAM_PRESENCE(team_id, epoch, last_event_seq, battery, caps...)`(加密)
+* Status 页来自:
- * last_seen_ts
- * leader presence
- * epoch 一致性
+ * `last_seen_ts`(presence 更新)
+ * `leader presence`
+ * `epoch 一致性`
+ * `last_event_seq 一致性`
+ * `sync 状态`(是否落后/是否补齐)
---
-## D5. Sync(补齐)
+## D5. Sync(补齐 Key Events)
-1. 发现 `event_seq` 落后
-2. → `TEAM_SYNC_REQ(from_seq)`
-3. → `TEAM_SYNC_RSP(events...)`
-4. 重放事件,UI 刷新
+1. 发现对方 `last_event_seq > my_last_event_seq`
+2. → `TEAM_SYNC_REQ(from_event_seq = my_last_event_seq + 1)`
+3. → `TEAM_SYNC_RSP(events...)`(携带一组 Key Events,每条含 event_seq)
+4. 本地重放事件、append events.log、刷新 snapshot
+5. 安全态从 WARN → OK
---
-下面我把前面那套 **Pager Team Core v0.1(入队/踢人/角色 + 状态页流转 + 协议流转)**,直接“落”到你工程里:**模块清单(按你 domain/usecase/ports/infra/ui 的风格)+ 状态机伪代码**。我会尽量贴近你 Trail Mate 的架构习惯(纯 domain、usecase 编排、ports 抽象、infra 适配 Meshtastic、UI 独立状态)。
+# E. v0.1 额外约束(写进文档的“实现护栏”)
+
+## E1. 明文/密文边界(避免新人永远解不开)
+
+* Plain 必须包含:`INVITE / JOIN_REQ / JOIN_DECISION / KEY_DIST(for newcomer)`
+* 加密从“新 epoch 生效后”开始:`PRESENCE / POS / WP / SYNC` 等
+
+## E2. 并发 Join 处理策略(Leader)
+
+* Leader 同时只处理 **1 个 pending join**
+* 其余排队显示在 `Requests` 页面,成员端可 Retry
+
+## E3. Invite Code 绑定 team 上下文
+
+* Invite Code 校验必须结合 `team_id_short` 或 `team_id`
+* NFC payload 也带 `team_id_short`,用于防“撞码误入队”
---
@@ -862,6 +1095,9 @@ void TeamService::onJoinReq(const JoinReq& req, uint32_t from) {
if (clock_.now() > invite_.expire_ts) return;
if (req.code != invite_.invite_code) return; // v0.1:简单匹配
+ // 先请求 NodeInfo(获取公钥,便于定向发送)
+ transport_.requestNodeInfo(from, /*want_response*/ true);
+
// 让 UI 弹窗:Accept / Reject
ui_->promptJoinRequest(from, /*name*/ req.name);
// Accept 的动作走 uiAcceptJoin(from)
@@ -909,7 +1145,53 @@ void TeamService::distributeEpochKeyToMembers() {
---
-## 4.2.8 Member:收到 ACCEPT + KEY_DIST
+## 4.2.8 NFC Key Exchange(Invite Code 加密 PSK)
+
+**目标**:NFC payload 不含明文 `team_psk`,读卡后需输入 Invite Code 解密,降低明文泄露风险。
+支持 **Radio / NFC 双模式**:
+
+* Radio:现有 Invite Code 广播 + Join/Accept + KeyDist(对新人)
+* NFC:NFC 传递加密 PSK + Join/Accept,**不再给新人发送 KeyDist**
+ * 仍需给**已有成员**发送 KeyDist(因为会 rotate epoch)
+
+**Payload(NDEF / TLV)**
+
+* `magic/version`
+* `team_id`
+* `key_id`
+* `expires_at`
+* `salt`(随机)
+* `nonce`
+* `ciphertext(team_psk)`
+* `tag`
+
+**加密规则**
+
+* KDF:`PBKDF2-HMAC-SHA256`,迭代 `10k`
+* 对称算法:`AES-GCM`
+* Invite Code:6 位连续字符(无分隔符)
+
+**Member 流程(复用 Enter Code UI)**
+
+1. 读取 NFC payload → 检查 `expires_at`(默认 9 分钟有效期)
+2. 弹出 Enter Code(复用现有输入)
+3. KDF → AES-GCM 解密 → 得到 `team_psk`
+4. `setKeysFromPsk(team_id, key_id, team_psk)` → 继续走 Join/Accept
+
+**Leader 侧关键点(方案 A)**
+
+* 仍执行 **Epoch Rotate**(成员变化必须记录)
+* **不再给新人发送 KeyDist**(新人已通过 NFC 获得 key)
+* **继续给已有成员发送 KeyDist**(确保他们更新到新 key)
+
+**NFC 扫描窗口**
+
+* 进入 “Join via NFC” / “Invite via NFC” 页面时才开启 NFC
+* 退出页面立即关闭 NFC(省电 & ST25R3916 无电容自检)
+
+---
+
+## 4.2.9 Member:收到 ACCEPT + KEY_DIST
```cpp
void TeamService::onJoinDecision(const JoinDecision& d) {
@@ -940,7 +1222,7 @@ void TeamService::onKeyDist(const KeyDist& kd) {
---
-## 4.2.9 Kick Flow(Leader → 全队)
+## 4.2.10 Kick Flow(Leader → 全队)
```cpp
void TeamService::uiKickMember(MemberId target) {
@@ -983,7 +1265,7 @@ void TeamService::onKick(const Kick& k) {
---
-## 4.2.10 Presence & Health(状态页数据来源)
+## 4.2.11 Presence & Health(状态页数据来源)
```cpp
void TeamService::broadcastPresenceIfDue() {
@@ -1017,7 +1299,7 @@ void TeamService::onPresence(const Presence& p) {
---
-## 4.2.11 Sync(补齐关键事件)
+## 4.2.12 Sync(补齐关键事件)
```cpp
void TeamService::requestSyncFrom(MemberId peer, uint32_t from_seq) {
@@ -1078,4 +1360,4 @@ void TeamService::onSyncRsp(const SyncRsp& rsp) {
3. `ports/i_team_transport.h`(send / sendTo / onPacket)
4. `usecase/team_service.cpp`(先只做 create + invite 广播 + presence)
5. `ui/screens/team/team_state.h` + `team_pages.cpp`(StatusNotInTeam / StatusInTeam 两页先跑起来)
-6. 再加 join/kick/sync
\ No newline at end of file
+6. 再加 join/kick/sync
diff --git a/src/app/app_context.cpp b/src/app/app_context.cpp
index 769dcba5..3142195a 100644
--- a/src/app/app_context.cpp
+++ b/src/app/app_context.cpp
@@ -7,8 +7,13 @@
#include "../chat/infra/protocol_factory.h"
#include "../gps/usecase/gps_service.h"
#include "../sys/event_bus.h"
+#include "../ui/ui_team.h"
#include "../ui/widgets/system_notification.h"
#include "../ui/ui_common.h"
+#include "../team/protocol/team_chat.h"
+#ifdef USING_ST25R3916
+#include "../team/infra/nfc/team_nfc.h"
+#endif
#include "app_tasks.h"
#include
@@ -137,6 +142,13 @@ void AppContext::update()
ui_controller_->update();
}
+#ifdef USING_ST25R3916
+ if (team::nfc::is_share_active())
+ {
+ team::nfc::poll_share();
+ }
+#endif
+
// Process events
sys::Event* event = nullptr;
while (sys::EventBus::subscribe(&event, 0))
@@ -170,6 +182,72 @@ void AppContext::update()
ui::SystemNotification::show(msg_event->text, 3000);
break;
}
+ case sys::EventType::TeamChat:
+ {
+ sys::TeamChatEvent* team_event = (sys::TeamChatEvent*)event;
+ if (board_)
+ {
+ board_->vibrator();
+ }
+ std::string notice = "Team: ";
+ const auto& msg = team_event->data.msg;
+ if (msg.header.type == team::proto::TeamChatType::Text)
+ {
+ std::string text(msg.payload.begin(), msg.payload.end());
+ if (text.size() > 48)
+ {
+ text = text.substr(0, 45) + "...";
+ }
+ notice += text;
+ }
+ else if (msg.header.type == team::proto::TeamChatType::Location)
+ {
+ team::proto::TeamChatLocation loc;
+ if (team::proto::decodeTeamChatLocation(msg.payload.data(), msg.payload.size(), &loc) &&
+ !loc.label.empty())
+ {
+ notice += "Location: " + loc.label;
+ }
+ else
+ {
+ notice += "Location";
+ }
+ }
+ else if (msg.header.type == team::proto::TeamChatType::Command)
+ {
+ team::proto::TeamChatCommand cmd;
+ if (team::proto::decodeTeamChatCommand(msg.payload.data(), msg.payload.size(), &cmd))
+ {
+ const char* name = "Command";
+ switch (cmd.cmd_type)
+ {
+ case team::proto::TeamCommandType::RallyTo:
+ name = "RallyTo";
+ break;
+ case team::proto::TeamCommandType::MoveTo:
+ name = "MoveTo";
+ break;
+ case team::proto::TeamCommandType::Hold:
+ name = "Hold";
+ break;
+ default:
+ break;
+ }
+ notice += "Command: ";
+ notice += name;
+ }
+ else
+ {
+ notice += "Command";
+ }
+ }
+ else
+ {
+ notice += "Message";
+ }
+ ui::SystemNotification::show(notice.c_str(), 3000);
+ break;
+ }
case sys::EventType::ChatSendResult:
{
sys::ChatSendResultEvent* result_event = (sys::ChatSendResultEvent*)event;
@@ -274,6 +352,26 @@ void AppContext::update()
}
// Forward event to UI controller if it exists
+ if (event->type == sys::EventType::TeamAdvertise ||
+ event->type == sys::EventType::TeamJoinRequest ||
+ event->type == sys::EventType::TeamJoinAccept ||
+ event->type == sys::EventType::TeamJoinConfirm ||
+ event->type == sys::EventType::TeamJoinDecision ||
+ event->type == sys::EventType::TeamKick ||
+ event->type == sys::EventType::TeamTransferLeader ||
+ event->type == sys::EventType::TeamKeyDist ||
+ event->type == sys::EventType::TeamStatus ||
+ event->type == sys::EventType::TeamPosition ||
+ event->type == sys::EventType::TeamWaypoint ||
+ event->type == sys::EventType::TeamChat ||
+ event->type == sys::EventType::TeamError ||
+ event->type == sys::EventType::SystemTick)
+ {
+ ui_team_handle_event(event);
+ delete event;
+ continue;
+ }
+
if (ui_controller_)
{
ui_controller_->onChatEvent(event);
diff --git a/src/app/app_context.h b/src/app/app_context.h
index 51efd17f..6e60c80a 100644
--- a/src/app/app_context.h
+++ b/src/app/app_context.h
@@ -100,6 +100,11 @@ class AppContext
return config_;
}
+ chat::NodeId getSelfNodeId() const
+ {
+ return mesh_adapter_ ? mesh_adapter_->getNodeId() : 0;
+ }
+
void saveConfig()
{
config_.save(preferences_);
diff --git a/src/board/LilyGoKeyboard.cpp b/src/board/LilyGoKeyboard.cpp
index e082709a..f58d03d1 100644
--- a/src/board/LilyGoKeyboard.cpp
+++ b/src/board/LilyGoKeyboard.cpp
@@ -433,10 +433,9 @@ char LilyGoKeyboard::handleSpaceAndNullChar(char keyVal, char& lastKeyVal, bool&
}
}
// 无符号键的配置:上一个键为空字符则当前转换为空格
- else if (lastKeyVal == '\0')
+ else if (lastKeyVal == '\0' && keyVal == '\0' && pressed)
{
keyVal = ' ';
- pressed = true;
}
}
#endif
diff --git a/src/board/TLoRaPagerBoard.cpp b/src/board/TLoRaPagerBoard.cpp
index 63308f84..c2464510 100644
--- a/src/board/TLoRaPagerBoard.cpp
+++ b/src/board/TLoRaPagerBoard.cpp
@@ -9,6 +9,7 @@
#include
#include
+#include
#include "display/drivers/ST7796.h"
#include "pins_arduino.h"
@@ -321,7 +322,7 @@ uint32_t TLoRaPagerBoard::begin(uint32_t disable_hw_init)
log_d("SPI bus initialized (SCK=%d, MISO=%d, MOSI=%d)", LORA_SCK, LORA_MISO, LORA_MOSI);
// Configure NFC interrupt pin
- pinMode(NFC_INT, INPUT);
+ pinMode(NFC_INT, INPUT_PULLUP);
// Initialize RTC (PCF85063) - optional
if (!(disable_hw_init & NO_HW_RTC))
@@ -452,8 +453,12 @@ uint32_t TLoRaPagerBoard::begin(uint32_t disable_hw_init)
log_d("Board initialization complete. Hardware online: 0x%08X", devices_probe);
Serial.printf("[TLoRaPagerBoard::begin] ===== HARDWARE INITIALIZATION COMPLETE =====\n");
Serial.printf("[TLoRaPagerBoard::begin] devices_probe=0x%08X\n", devices_probe);
- Serial.printf("[TLoRaPagerBoard::begin] GPS online: %s (HW_GPS_ONLINE=0x%08X)\n",
- (devices_probe & HW_GPS_ONLINE) ? "YES" : "NO", HW_GPS_ONLINE);
+ const char* gps_state =
+ (devices_probe & HW_GPS_ONLINE) ? "YES" :
+ ((disable_hw_init & NO_HW_GPS) ? "SKIPPED" : "DEFERRED");
+ Serial.printf("[TLoRaPagerBoard::begin] GPS online: %s\n", gps_state);
+ Serial.printf("[TLoRaPagerBoard::begin] NFC online: %s (HW_NFC_ONLINE=0x%08X)\n",
+ (devices_probe & HW_NFC_ONLINE) ? "YES" : "NO", HW_NFC_ONLINE);
return devices_probe;
}
@@ -573,6 +578,7 @@ bool TLoRaPagerBoard::initNFC()
{
#ifdef USING_ST25R3916
bool res = false;
+ ReturnCode rc = ERR_NONE;
log_d("Init NFC");
// Enable NFC power before initialization
@@ -580,16 +586,20 @@ bool TLoRaPagerBoard::initNFC()
delay(10); // Wait for power to stabilize
// Initialize NFC reader
- res = NFCReader.rfalNfcInitialize() == ST_ERR_NONE;
+ rc = NFCReader.rfalNfcInitialize();
+ res = (rc == ERR_NONE);
if (!res)
{
- log_e("Failed to find NFC Reader");
+ log_e("Failed to find NFC Reader (rc=%d)", rc);
+ Serial.printf("[TLoRaPagerBoard::initNFC] NFC init failed rc=%d\n", rc);
powerControl(POWER_NFC, false);
}
else
{
log_d("Initializing NFC Reader succeeded");
+ Serial.printf("[TLoRaPagerBoard::initNFC] NFC init ok\n");
devices_probe |= HW_NFC_ONLINE;
+ detachInterrupt(NFC_INT);
// Turn off NFC power after initialization (will be enabled when needed)
powerControl(POWER_NFC, false);
}
@@ -780,7 +790,8 @@ void TLoRaPagerBoard::vibrator()
log_d("[vibrator] Enabling power and starting vibration (effect=%d)...", _haptic_effects);
powerControl(POWER_HAPTIC_DRIVER, true);
drv.setWaveform(0, _haptic_effects);
- drv.setWaveform(1, 0);
+ drv.setWaveform(1, _haptic_effects);
+ drv.setWaveform(2, 0);
drv.run();
log_d("[vibrator] Vibration started, setting up stop timer...");
@@ -876,7 +887,7 @@ int TLoRaPagerBoard::getKeyChar(char* c)
}
#ifdef USING_ST25R3916
-bool TLoRaPagerBoard::startNFCDiscovery(uint8_t techs2Find, uint16_t totalDuration)
+bool TLoRaPagerBoard::startNFCDiscovery(uint16_t techs2Find, uint16_t totalDuration)
{
if (!(devices_probe & HW_NFC_ONLINE))
{
@@ -889,26 +900,49 @@ bool TLoRaPagerBoard::startNFCDiscovery(uint8_t techs2Find, uint16_t totalDurati
delay(10); // Wait for power to stabilize
// Reinitialize NFC reader
- if (NFCReader.rfalNfcInitialize() != ST_ERR_NONE)
+ ReturnCode rc = NFCReader.rfalNfcInitialize();
+ if (rc != ERR_NONE)
{
log_e("Failed to reinitialize NFC");
+ Serial.printf("[TLoRaPagerBoard::startNFCDiscovery] rfalNfcInitialize rc=%d\n", rc);
powerControl(POWER_NFC, false);
return false;
}
+ detachInterrupt(NFC_INT);
// Setup discovery parameters
rfalNfcDiscoverParam discover_params;
+ rfalNfcDefaultDiscParams(&discover_params);
discover_params.devLimit = 1;
discover_params.techs2Find = techs2Find;
- discover_params.GBLen = RFAL_NFCDEP_GB_MAX_LEN;
discover_params.notifyCb = nullptr; // Can be set by user if needed
discover_params.totalDuration = totalDuration;
- discover_params.wakeupEnabled = false;
+
+ if (techs2Find & RFAL_NFC_LISTEN_TECH_A)
+ {
+ static bool nfcid_init = false;
+ static uint8_t nfcid[RFAL_NFCID1_TRIPLE_LEN] = { 0 };
+ if (!nfcid_init)
+ {
+ nfcid[0] = static_cast(random(1, 255));
+ nfcid[1] = static_cast(random(0, 256));
+ nfcid[2] = static_cast(random(0, 256));
+ nfcid[3] = static_cast(random(0, 256));
+ nfcid_init = true;
+ }
+ discover_params.lmConfigPA.nfcidLen = RFAL_LM_NFCID_LEN_04;
+ memcpy(discover_params.lmConfigPA.nfcid, nfcid, sizeof(nfcid));
+ discover_params.lmConfigPA.SENS_RES[0] = 0x04;
+ discover_params.lmConfigPA.SENS_RES[1] = 0x00;
+ discover_params.lmConfigPA.SEL_RES = RFAL_NFCA_SEL_RES_CONF_T4T;
+ }
// Start discovery
- if (NFCReader.rfalNfcDiscover(&discover_params) != ST_ERR_NONE)
+ rc = NFCReader.rfalNfcDiscover(&discover_params);
+ if (rc != ERR_NONE)
{
log_e("Failed to start NFC discovery");
+ Serial.printf("[TLoRaPagerBoard::startNFCDiscovery] rfalNfcDiscover rc=%d\n", rc);
powerControl(POWER_NFC, false);
return false;
}
@@ -925,13 +959,27 @@ void TLoRaPagerBoard::stopNFCDiscovery()
}
// Deactivate NFC
- NFCReader.rfalNfcDeactivate(true);
+ NFCReader.rfalNfcDeactivate(RFAL_NFC_DEACTIVATE_IDLE);
// Turn off NFC power
powerControl(POWER_NFC, false);
log_d("NFC discovery stopped");
}
+
+void TLoRaPagerBoard::pollNfcIrq()
+{
+ if (!nfc)
+ {
+ return;
+ }
+ RfalRfClass* rf = nfc->getRfalRf();
+ if (!rf)
+ {
+ return;
+ }
+ static_cast(rf)->st25r3916CheckForReceivedInterrupts();
+}
#endif
bool TLoRaPagerBoard::initGPS()
@@ -998,6 +1046,88 @@ void TLoRaPagerBoard::pushColors(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t
LilyGoDispArduinoSPI::pushColors(x1, y1, x2, y2, color);
}
+int TLoRaPagerBoard::transmitRadio(const uint8_t* data, size_t len)
+{
+ if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50)))
+ {
+ int rc = radio_.transmit(data, len);
+ LilyGoDispArduinoSPI::unlock();
+ return rc;
+ }
+ return RADIOLIB_ERR_SPI_WRITE_FAILED;
+}
+
+int TLoRaPagerBoard::startRadioReceive()
+{
+ if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50)))
+ {
+ int rc = radio_.startReceive();
+ LilyGoDispArduinoSPI::unlock();
+ return rc;
+ }
+ return RADIOLIB_ERR_SPI_WRITE_FAILED;
+}
+
+uint32_t TLoRaPagerBoard::getRadioIrqFlags()
+{
+ if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20)))
+ {
+ uint32_t flags = radio_.getIrqFlags();
+ LilyGoDispArduinoSPI::unlock();
+ return flags;
+ }
+ return 0;
+}
+
+int TLoRaPagerBoard::getRadioPacketLength(bool update)
+{
+ if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20)))
+ {
+ int len = static_cast(radio_.getPacketLength(update));
+ LilyGoDispArduinoSPI::unlock();
+ return len;
+ }
+ return 0;
+}
+
+int TLoRaPagerBoard::readRadioData(uint8_t* buf, size_t len)
+{
+ if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50)))
+ {
+ int rc = radio_.readData(buf, len);
+ LilyGoDispArduinoSPI::unlock();
+ return rc;
+ }
+ return RADIOLIB_ERR_SPI_WRITE_FAILED;
+}
+
+void TLoRaPagerBoard::clearRadioIrqFlags(uint32_t flags)
+{
+ if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20)))
+ {
+ radio_.clearIrqFlags(flags);
+ LilyGoDispArduinoSPI::unlock();
+ }
+}
+
+void TLoRaPagerBoard::configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom,
+ int8_t tx_power, uint16_t preamble_len, uint8_t sync_word,
+ uint8_t crc_len)
+{
+ if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(100)))
+ {
+ radio_.setFrequency(freq_mhz);
+ radio_.setBandwidth(bw_khz);
+ radio_.setSpreadingFactor(sf);
+ radio_.setCodingRate(cr_denom);
+ radio_.setOutputPower(tx_power);
+ radio_.setPreambleLength(preamble_len);
+ radio_.setSyncWord(sync_word);
+ radio_.setCRC(crc_len);
+ LilyGoDispArduinoSPI::unlock();
+ }
+}
+
bool TLoRaPagerBoard::hasEncoder()
{
return true;
@@ -1068,7 +1198,7 @@ bool TLoRaPagerBoard::syncTimeFromGPS(uint32_t gps_task_interval_ms)
day < 1 || day > 31 ||
hour >= 24 || minute >= 60 || second >= 60)
{
- Serial.printf("[TLoRaPagerBoard::syncTimeFromGPS] Invalid GPS time values: %04d-%02d-%02d %02d:%02d:%02d\n",
+ GPS_BOARD_LOG("[TLoRaPagerBoard::syncTimeFromGPS] Invalid GPS time values: %04d-%02d-%02d %02d:%02d:%02d\n",
year, month, day, hour, minute, second);
return false;
}
diff --git a/src/board/TLoRaPagerBoard.h b/src/board/TLoRaPagerBoard.h
index 350d8383..bf8ed894 100644
--- a/src/board/TLoRaPagerBoard.h
+++ b/src/board/TLoRaPagerBoard.h
@@ -232,13 +232,18 @@ class TLoRaPagerBoard : public BoardBase,
* @param totalDuration Total discovery duration in ms
* @return true if successful, false otherwise
*/
- bool startNFCDiscovery(uint8_t techs2Find = RFAL_NFC_POLL_TECH_A, uint16_t totalDuration = 1000);
+ bool startNFCDiscovery(uint16_t techs2Find = RFAL_NFC_POLL_TECH_A, uint16_t totalDuration = 1000);
/**
* @brief Stop NFC discovery mode
*/
void stopNFCDiscovery();
+ /**
+ * @brief Poll NFC interrupt registers (IRQ line free)
+ */
+ void pollNfcIrq();
+
/**
* @brief Check if NFC is ready
* @return true if NFC is initialized and online
@@ -269,26 +274,15 @@ class TLoRaPagerBoard : public BoardBase,
* @brief Check if LoRa is initialized and online
*/
bool isRadioOnline() const override { return isHardwareOnline(HW_RADIO_ONLINE); }
- int transmitRadio(const uint8_t* data, size_t len) override { return radio_.transmit(data, len); }
- int startRadioReceive() override { return radio_.startReceive(); }
- uint32_t getRadioIrqFlags() override { return radio_.getIrqFlags(); }
- int getRadioPacketLength(bool update) override { return static_cast(radio_.getPacketLength(update)); }
- int readRadioData(uint8_t* buf, size_t len) override { return radio_.readData(buf, len); }
- void clearRadioIrqFlags(uint32_t flags) override { radio_.clearIrqFlags(flags); }
+ int transmitRadio(const uint8_t* data, size_t len) override;
+ int startRadioReceive() override;
+ uint32_t getRadioIrqFlags() override;
+ int getRadioPacketLength(bool update) override;
+ int readRadioData(uint8_t* buf, size_t len) override;
+ void clearRadioIrqFlags(uint32_t flags) override;
void configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom,
int8_t tx_power, uint16_t preamble_len, uint8_t sync_word,
- uint8_t crc_len) override
- {
- auto& radio = radio_;
- radio.setFrequency(freq_mhz);
- radio.setBandwidth(bw_khz);
- radio.setSpreadingFactor(sf);
- radio.setCodingRate(cr_denom);
- radio.setOutputPower(tx_power);
- radio.setPreambleLength(preamble_len);
- radio.setSyncWord(sync_word);
- radio.setCRC(crc_len);
- }
+ uint8_t crc_len) override;
// GpsBoard
void setGPSOnline(bool online) override { setGPSOnlineInternal(online); }
@@ -435,7 +429,7 @@ class TLoRaPagerBoard : public BoardBase,
bool isUsbPresent_bestEffort();
uint32_t devices_probe = 0; ///< Hardware detection status bitmask
- uint8_t _haptic_effects = 100; ///< Default haptic effect (very strong buzz for message notification)
+ uint8_t _haptic_effects = 15; ///< Default haptic effect (strong buzz for message notification)
};
extern TLoRaPagerBoard& instance;
diff --git a/src/board/nfc_include.h b/src/board/nfc_include.h
index 3f7e787e..7987e5f2 100644
--- a/src/board/nfc_include.h
+++ b/src/board/nfc_include.h
@@ -10,6 +10,9 @@
#pragma once
#ifdef USING_ST25R3916
+#ifdef BR
+#undef BR
+#endif
#include
#include
#include
@@ -17,9 +20,8 @@
#include
#include
#include
-#include
-#include
#include
+#include
#include
#include
#include
@@ -27,7 +29,9 @@
#include
#include
#include
-#include
+#ifdef rfalRunBlocking
+#undef rfalRunBlocking
+#endif
#include
#include
#include
diff --git a/src/chat/infra/meshcore/meshcore_adapter.h b/src/chat/infra/meshcore/meshcore_adapter.h
index a59cf887..846580c8 100644
--- a/src/chat/infra/meshcore/meshcore_adapter.h
+++ b/src/chat/infra/meshcore/meshcore_adapter.h
@@ -50,6 +50,7 @@ class MeshCoreAdapter : public IMeshAdapter
void applyConfig(const MeshConfig& config) override;
bool isReady() const override;
+ NodeId getNodeId() const override { return 0; }
/**
* @brief Poll for incoming raw packet data
diff --git a/src/chat/infra/meshtastic/mt_adapter.cpp b/src/chat/infra/meshtastic/mt_adapter.cpp
index 1fcadaba..e93d5240 100644
--- a/src/chat/infra/meshtastic/mt_adapter.cpp
+++ b/src/chat/infra/meshtastic/mt_adapter.cpp
@@ -98,6 +98,8 @@ static const char* portName(uint32_t portnum)
return "TEAM_POS";
case team::proto::TEAM_WAYPOINT_APP:
return "TEAM_WP";
+ case team::proto::TEAM_CHAT_APP:
+ return "TEAM_CHAT";
default:
return "UNKNOWN";
}
@@ -809,6 +811,26 @@ bool MtAdapter::pollIncomingData(MeshIncomingData* out)
return true;
}
+bool MtAdapter::requestNodeInfo(NodeId dest, bool want_response)
+{
+ if (!ready_)
+ {
+ return false;
+ }
+ uint32_t target = (dest == 0) ? 0xFFFFFFFF : dest;
+ return sendNodeInfoTo(target, want_response);
+}
+
+bool MtAdapter::isPkiReady() const
+{
+ return pki_ready_;
+}
+
+bool MtAdapter::hasPkiKey(NodeId dest) const
+{
+ return node_public_keys_.find(dest) != node_public_keys_.end();
+}
+
void MtAdapter::applyConfig(const MeshConfig& config)
{
config_ = config;
@@ -2767,4 +2789,4 @@ bool MtAdapter::sendRoutingError(uint32_t dest, uint32_t request_id, uint8_t cha
}
} // namespace meshtastic
-} // namespace chat
\ No newline at end of file
+} // namespace chat
diff --git a/src/chat/infra/meshtastic/mt_adapter.h b/src/chat/infra/meshtastic/mt_adapter.h
index 67ff71be..4f45d302 100644
--- a/src/chat/infra/meshtastic/mt_adapter.h
+++ b/src/chat/infra/meshtastic/mt_adapter.h
@@ -40,8 +40,14 @@ class MtAdapter : public chat::IMeshAdapter
const uint8_t* payload, size_t len,
NodeId dest = 0, bool want_ack = false) override;
bool pollIncomingData(MeshIncomingData* out) override;
+ bool requestNodeInfo(NodeId dest, bool want_response) override;
+ bool startKeyVerification(NodeId node_id) override;
+ bool submitKeyVerificationNumber(NodeId node_id, uint64_t nonce, uint32_t number) override;
+ bool isPkiReady() const override;
+ bool hasPkiKey(NodeId dest) const override;
void applyConfig(const MeshConfig& config) override;
bool isReady() const override;
+ NodeId getNodeId() const override { return node_id_; }
/**
* @brief Poll for incoming raw packet data
@@ -69,22 +75,6 @@ class MtAdapter : public chat::IMeshAdapter
*/
void processSendQueue() override;
- /**
- * @brief Submit key verification number for ongoing PKI verification
- * @param node_id Remote node id
- * @param nonce Verification nonce
- * @param number Security number
- * @return true if processed
- */
- bool submitKeyVerificationNumber(NodeId node_id, uint64_t nonce, uint32_t number);
-
- /**
- * @brief Start PKI key verification with a remote node
- * @param node_id Remote node id
- * @return true if sent
- */
- bool startKeyVerification(NodeId node_id);
-
private:
LoraBoard& board_;
MeshConfig config_;
@@ -203,4 +193,4 @@ class MtAdapter : public chat::IMeshAdapter
};
} // namespace meshtastic
-} // namespace chat
\ No newline at end of file
+} // namespace chat
diff --git a/src/chat/ports/i_mesh_adapter.h b/src/chat/ports/i_mesh_adapter.h
index 82c4e297..f44571d6 100644
--- a/src/chat/ports/i_mesh_adapter.h
+++ b/src/chat/ports/i_mesh_adapter.h
@@ -57,6 +57,70 @@ class IMeshAdapter
*/
virtual bool pollIncomingData(MeshIncomingData* out) = 0;
+ /**
+ * @brief Request NodeInfo from a specific node (if supported)
+ * @param dest Destination node (0 for broadcast)
+ * @param want_response Request response if supported
+ * @return true if request queued
+ */
+ virtual bool requestNodeInfo(NodeId dest, bool want_response)
+ {
+ (void)dest;
+ (void)want_response;
+ return false;
+ }
+
+ /**
+ * @brief Start PKI key verification with a remote node (if supported)
+ * @param dest Destination node
+ * @return true if started
+ */
+ virtual bool startKeyVerification(NodeId dest)
+ {
+ (void)dest;
+ return false;
+ }
+
+ /**
+ * @brief Submit PKI verification number (if supported)
+ * @param dest Destination node
+ * @param nonce Verification nonce
+ * @param number Security number
+ * @return true if accepted
+ */
+ virtual bool submitKeyVerificationNumber(NodeId dest, uint64_t nonce, uint32_t number)
+ {
+ (void)dest;
+ (void)nonce;
+ (void)number;
+ return false;
+ }
+
+ /**
+ * @brief Get local node ID (if supported)
+ */
+ virtual NodeId getNodeId() const
+ {
+ return 0;
+ }
+
+ /**
+ * @brief Check if PKI is ready (if supported)
+ */
+ virtual bool isPkiReady() const
+ {
+ return false;
+ }
+
+ /**
+ * @brief Check if PKI public key for node is known (if supported)
+ */
+ virtual bool hasPkiKey(NodeId dest) const
+ {
+ (void)dest;
+ return false;
+ }
+
/**
* @brief Apply mesh configuration
* @param config Configuration to apply
diff --git a/src/chat/usecase/chat_service.cpp b/src/chat/usecase/chat_service.cpp
index 2dab2ed5..692449ae 100644
--- a/src/chat/usecase/chat_service.cpp
+++ b/src/chat/usecase/chat_service.cpp
@@ -120,7 +120,8 @@ void ChatService::processIncoming()
msg.peer = incoming.from;
}
msg.msg_id = incoming.msg_id;
- msg.timestamp = incoming.timestamp ? incoming.timestamp : now_message_timestamp();
+ // Use local receive time to avoid sender clock skew.
+ msg.timestamp = now_message_timestamp();
msg.text = incoming.text;
msg.status = MessageStatus::Incoming;
diff --git a/src/gps/domain/gps_state.h b/src/gps/domain/gps_state.h
index 42619f1e..83c12ac2 100644
--- a/src/gps/domain/gps_state.h
+++ b/src/gps/domain/gps_state.h
@@ -9,8 +9,14 @@ struct GpsState
{
double lat = 0.0;
double lng = 0.0;
+ double alt_m = 0.0;
+ double speed_mps = 0.0;
+ double course_deg = 0.0;
uint8_t satellites = 0;
bool valid = false;
+ bool has_alt = false;
+ bool has_speed = false;
+ bool has_course = false;
uint32_t age = 0;
};
diff --git a/src/gps/infra/hal_gps_adapter.cpp b/src/gps/infra/hal_gps_adapter.cpp
index d4d3accc..2c31286e 100644
--- a/src/gps/infra/hal_gps_adapter.cpp
+++ b/src/gps/infra/hal_gps_adapter.cpp
@@ -49,6 +49,36 @@ double HalGpsAdapter::longitude() const
return hal_gps_.longitude();
}
+bool HalGpsAdapter::hasAltitude() const
+{
+ return hal_gps_.hasAltitude();
+}
+
+double HalGpsAdapter::altitude() const
+{
+ return hal_gps_.altitude();
+}
+
+bool HalGpsAdapter::hasSpeed() const
+{
+ return hal_gps_.hasSpeed();
+}
+
+double HalGpsAdapter::speed() const
+{
+ return hal_gps_.speed();
+}
+
+bool HalGpsAdapter::hasCourse() const
+{
+ return hal_gps_.hasCourse();
+}
+
+double HalGpsAdapter::course() const
+{
+ return hal_gps_.course();
+}
+
uint8_t HalGpsAdapter::satellites() const
{
return hal_gps_.satellites();
diff --git a/src/gps/infra/hal_gps_adapter.h b/src/gps/infra/hal_gps_adapter.h
index e8a5e275..600e12a5 100644
--- a/src/gps/infra/hal_gps_adapter.h
+++ b/src/gps/infra/hal_gps_adapter.h
@@ -20,6 +20,12 @@ class HalGpsAdapter : public IGpsHardware
bool hasFix() const override;
double latitude() const override;
double longitude() const override;
+ bool hasAltitude() const override;
+ double altitude() const override;
+ bool hasSpeed() const override;
+ double speed() const override;
+ bool hasCourse() const override;
+ double course() const override;
uint8_t satellites() const override;
bool syncTime(uint32_t gps_task_interval_ms) override;
diff --git a/src/gps/ports/i_gps_hw.h b/src/gps/ports/i_gps_hw.h
index ddba6880..a89acdf2 100644
--- a/src/gps/ports/i_gps_hw.h
+++ b/src/gps/ports/i_gps_hw.h
@@ -17,6 +17,12 @@ class IGpsHardware
virtual bool hasFix() const = 0;
virtual double latitude() const = 0;
virtual double longitude() const = 0;
+ virtual bool hasAltitude() const = 0;
+ virtual double altitude() const = 0;
+ virtual bool hasSpeed() const = 0;
+ virtual double speed() const = 0;
+ virtual bool hasCourse() const = 0;
+ virtual double course() const = 0;
virtual uint8_t satellites() const = 0;
virtual bool syncTime(uint32_t gps_task_interval_ms) = 0;
};
diff --git a/src/gps/usecase/gps_service.cpp b/src/gps/usecase/gps_service.cpp
index e5797785..353edbf8 100644
--- a/src/gps/usecase/gps_service.cpp
+++ b/src/gps/usecase/gps_service.cpp
@@ -78,6 +78,8 @@ void GpsService::begin(GpsBoard& gps_board, MotionBoard& motion_board,
}
motion_control_enabled_ = motion_policy_.begin(motion_adapter_, motion_config_);
+ // Force GPS always-on: do not suspend or gate by motion policy.
+ motion_control_enabled_ = false;
if (motion_control_enabled_ && gps_task_handle_ != nullptr)
{
@@ -303,6 +305,18 @@ void GpsService::gpsTask(void* pvParameters)
{
service->gps_state_.lat = service->gps_adapter_.latitude();
service->gps_state_.lng = service->gps_adapter_.longitude();
+ service->gps_state_.has_alt = service->gps_adapter_.hasAltitude();
+ service->gps_state_.alt_m = service->gps_state_.has_alt
+ ? service->gps_adapter_.altitude()
+ : 0.0;
+ service->gps_state_.has_speed = service->gps_adapter_.hasSpeed();
+ service->gps_state_.speed_mps = service->gps_state_.has_speed
+ ? service->gps_adapter_.speed()
+ : 0.0;
+ service->gps_state_.has_course = service->gps_adapter_.hasCourse();
+ service->gps_state_.course_deg = service->gps_state_.has_course
+ ? service->gps_adapter_.course()
+ : 0.0;
service->gps_state_.satellites = sat_count;
service->gps_state_.valid = true;
service->gps_last_update_time_ = millis();
@@ -323,6 +337,12 @@ void GpsService::gpsTask(void* pvParameters)
else
{
service->gps_state_.valid = false;
+ service->gps_state_.has_alt = false;
+ service->gps_state_.has_speed = false;
+ service->gps_state_.has_course = false;
+ service->gps_state_.alt_m = 0.0;
+ service->gps_state_.speed_mps = 0.0;
+ service->gps_state_.course_deg = 0.0;
if (was_valid)
{
GPS_TASK_LOG("[GPS Task] *** FIX LOST *** (loop %lu)\n", loop_count);
@@ -427,7 +447,8 @@ void GpsService::setGPSPowerState(bool enable)
}
gps_adapter_.powerOn();
gps_powered_ = true;
- gps_adapter_.init();
+ bool init_ok = gps_adapter_.init();
+ Serial.printf("[GPS] init: %s\n", init_ok ? "OK" : "FAIL");
setCollectionInterval(kGpsSampleIntervalMs);
if (gps_task_handle_ != nullptr)
{
diff --git a/src/hal/hal_gps.cpp b/src/hal/hal_gps.cpp
index 2f2ad66b..f9b3104a 100644
--- a/src/hal/hal_gps.cpp
+++ b/src/hal/hal_gps.cpp
@@ -72,6 +72,36 @@ double HalGps::longitude() const
return board_ != nullptr ? board_->getGPS().location.lng() : 0.0;
}
+bool HalGps::hasAltitude() const
+{
+ return board_ != nullptr && board_->getGPS().altitude.isValid();
+}
+
+double HalGps::altitude() const
+{
+ return board_ != nullptr ? board_->getGPS().altitude.meters() : 0.0;
+}
+
+bool HalGps::hasSpeed() const
+{
+ return board_ != nullptr && board_->getGPS().speed.isValid();
+}
+
+double HalGps::speed() const
+{
+ return board_ != nullptr ? board_->getGPS().speed.mps() : 0.0;
+}
+
+bool HalGps::hasCourse() const
+{
+ return board_ != nullptr && board_->getGPS().course.isValid();
+}
+
+double HalGps::course() const
+{
+ return board_ != nullptr ? board_->getGPS().course.deg() : 0.0;
+}
+
uint8_t HalGps::satellites() const
{
return board_ != nullptr ? board_->getGPS().satellites.value() : 0;
diff --git a/src/hal/hal_gps.h b/src/hal/hal_gps.h
index 52ca78da..503d271a 100644
--- a/src/hal/hal_gps.h
+++ b/src/hal/hal_gps.h
@@ -18,6 +18,12 @@ class HalGps
bool hasFix() const;
double latitude() const;
double longitude() const;
+ bool hasAltitude() const;
+ double altitude() const;
+ bool hasSpeed() const;
+ double speed() const;
+ bool hasCourse() const;
+ double course() const;
uint8_t satellites() const;
bool syncTime(uint32_t gps_task_interval_ms);
diff --git a/src/main.cpp b/src/main.cpp
index 69cdcbf1..2a820b46 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -15,6 +15,7 @@
#include "app/app_context.h"
#include "display/DisplayConfig.h"
#include "ui/assets/images.h"
+#include "ui/app_screen.h"
#include "ui/ui_common.h"
#include "ui/widgets/system_notification.h"
@@ -78,6 +79,7 @@ bool isScreenSleepDisabled();
// Factory-style menu structure (global for ui_*.cpp access)
lv_obj_t* main_screen = nullptr;
lv_obj_t* menu_panel = nullptr;
+lv_obj_t* app_panel = nullptr;
lv_group_t* menu_g = nullptr;
lv_group_t* app_g = nullptr;
lv_obj_t* desc_label = nullptr;
@@ -108,86 +110,63 @@ bool format_menu_time(char* out, size_t out_len)
}
// App function types (like factory example)
-typedef void (*app_func_t)(lv_obj_t* parent);
+class FunctionAppScreen : public AppScreen {
+public:
+ FunctionAppScreen(const char* name,
+ const lv_image_dsc_t* icon,
+ void (*enter)(lv_obj_t*),
+ void (*exit)(lv_obj_t*))
+ : name_(name), icon_(icon), enter_(enter), exit_(exit) {}
-typedef struct
-{
- app_func_t setup_func_cb;
- app_func_t exit_func_cb;
- void* user_data;
-} app_t;
+ const char* name() const override { return name_; }
+ const lv_image_dsc_t* icon() const override { return icon_; }
-static app_t* s_active_app = nullptr;
+ void enter(lv_obj_t* parent) override
+ {
+ if (enter_) {
+ enter_(parent);
+ }
+ }
-// App entry functions (implemented in ui_*.cpp files, global scope)
+ void exit(lv_obj_t* parent) override
+ {
+ if (exit_) {
+ exit_(parent);
+ }
+ }
-app_t ui_gps_main = {
- .setup_func_cb = ui_gps_enter,
- .exit_func_cb = ui_gps_exit,
- .user_data = nullptr,
-};
-
-app_t ui_chat_main = {
- .setup_func_cb = ui_chat_enter,
- .exit_func_cb = ui_chat_exit,
- .user_data = nullptr,
-};
-
-app_t ui_contacts_main = {
- .setup_func_cb = ui_contacts_enter,
- .exit_func_cb = ui_contacts_exit,
- .user_data = nullptr,
-};
-
-app_t ui_team_main = {
- .setup_func_cb = ui_team_enter,
- .exit_func_cb = ui_team_exit,
- .user_data = nullptr,
-};
-
-app_t ui_tracker_main = {
- .setup_func_cb = ui_tracker_enter,
- .exit_func_cb = ui_tracker_exit,
- .user_data = nullptr,
-};
-
-app_t ui_setting_main = {
- .setup_func_cb = ui_setting_enter,
- .exit_func_cb = ui_setting_exit,
- .user_data = nullptr,
+private:
+ const char* name_;
+ const lv_image_dsc_t* icon_;
+ void (*enter_)(lv_obj_t*);
+ void (*exit_)(lv_obj_t*);
};
// Shutdown app - directly triggers system shutdown
static void ui_shutdown_enter(lv_obj_t* parent)
{
+ (void)parent;
// Directly trigger software shutdown without confirmation dialog
// The main menu access already implies user intent
board.softwareShutdown();
}
-app_t ui_shutdown_main = {
- .setup_func_cb = ui_shutdown_enter,
- .exit_func_cb = nullptr,
- .user_data = nullptr,
-};
+static FunctionAppScreen s_gps_app("GPS", &gps_icon, ui_gps_enter, ui_gps_exit);
+static FunctionAppScreen s_tracker_app("Tracker", &tracker_icon, ui_tracker_enter, ui_tracker_exit);
+static FunctionAppScreen s_chat_app("Chat", &Chat, ui_chat_enter, ui_chat_exit);
+static FunctionAppScreen s_contacts_app("Contacts", &contact, ui_contacts_enter, ui_contacts_exit);
+static FunctionAppScreen s_team_app("Team", &team_icon, ui_team_enter, ui_team_exit);
+static FunctionAppScreen s_setting_app("Setting", &Setting, ui_setting_enter, ui_setting_exit);
+static FunctionAppScreen s_shutdown_app("Shutdown", &shutdown, ui_shutdown_enter, nullptr);
#ifdef ARDUINO_USB_MODE
-app_t ui_usb_main = {
- .setup_func_cb = ui_usb_enter,
- .exit_func_cb = ui_usb_exit,
- .user_data = nullptr,
-};
-#endif
-
-#ifdef ARDUINO_USB_MODE
-const char* kAppNames[8] = {"GPS", "Tracker", "Chat", "Contacts", "Team", "USB Mass Storage", "Setting", "Shutdown"};
-const lv_image_dsc_t* kAppImages[8] = {&gps_icon, &tracker_icon, &Chat, &contact, &team_icon, &img_usb, &Setting, &shutdown};
-app_t* kAppFuncs[8] = {&ui_gps_main, &ui_tracker_main, &ui_chat_main, &ui_contacts_main, &ui_team_main, &ui_usb_main, &ui_setting_main, &ui_shutdown_main};
+static FunctionAppScreen s_usb_app("USB Mass Storage", &img_usb, ui_usb_enter, ui_usb_exit);
+static AppScreen* kAppScreens[] = {&s_gps_app, &s_tracker_app, &s_chat_app, &s_contacts_app,
+ &s_team_app, &s_usb_app, &s_setting_app, &s_shutdown_app};
#define NUM_APPS 8
#else
-const char* kAppNames[7] = {"GPS", "Tracker", "Chat", "Contacts", "Team", "Setting", "Shutdown"};
-const lv_image_dsc_t* kAppImages[7] = {&gps_icon, &tracker_icon, &Chat, &contact, &team_icon, &Setting, &shutdown};
-app_t* kAppFuncs[7] = {&ui_gps_main, &ui_tracker_main, &ui_chat_main, &ui_contacts_main, &ui_team_main, &ui_setting_main, &ui_shutdown_main};
+static AppScreen* kAppScreens[] = {&s_gps_app, &s_tracker_app, &s_chat_app, &s_contacts_app,
+ &s_team_app, &s_setting_app, &s_shutdown_app};
#define NUM_APPS 7
#endif
@@ -216,8 +195,11 @@ static void btn_event_cb(lv_event_t* e)
}
}
-static void create_app(lv_obj_t* parent, const char* name, const lv_image_dsc_t* img, app_t* app_fun)
+static void create_app(lv_obj_t* parent, AppScreen* app)
{
+ const char* name = app ? app->name() : "";
+ const lv_image_dsc_t* img = app ? app->icon() : nullptr;
+
lv_obj_t* btn = lv_btn_create(parent);
lv_coord_t w = 150;
lv_coord_t h = LV_PCT(100);
@@ -248,23 +230,17 @@ static void create_app(lv_obj_t* parent, const char* name, const lv_image_dsc_t*
btn, [](lv_event_t* e)
{
lv_event_code_t c = lv_event_get_code(e);
- app_t *func_cb = (app_t *)lv_event_get_user_data(e);
+ auto* target_app = static_cast(lv_event_get_user_data(e));
lv_obj_t *parent = lv_obj_get_child(main_screen, 1);
if (lv_obj_has_flag(main_screen, LV_OBJ_FLAG_HIDDEN)) {
return;
}
if (c == LV_EVENT_CLICKED) {
set_default_group(app_g);
- if (s_active_app && s_active_app != func_cb && s_active_app->exit_func_cb) {
- (*s_active_app->exit_func_cb)(parent);
- }
- if (func_cb->setup_func_cb) {
- (*func_cb->setup_func_cb)(parent);
- }
- s_active_app = func_cb;
+ ui_switch_to_app(target_app, parent);
menu_hidden();
} },
- LV_EVENT_CLICKED, app_fun);
+ LV_EVENT_CLICKED, app);
}
void menu_name_label_event_cb(lv_event_t* e)
@@ -283,11 +259,6 @@ void menu_name_label_event_cb(lv_event_t* e)
// App entry functions are implemented in ui_gps.cpp, ui_chat.cpp, ui_setting.cpp
} // namespace
-void ui_clear_active_app()
-{
- s_active_app = nullptr;
-}
-
// GPS data access - now provided by TLoRaPagerBoard
// GPS data collection task is now in TLoRaPagerBoard class
@@ -635,7 +606,12 @@ void setup()
/* Create two views for switching menus and app UI */
menu_panel = lv_tileview_add_tile(main_screen, 0, 0, LV_DIR_HOR);
- lv_tileview_add_tile(main_screen, 0, 1, LV_DIR_HOR);
+ app_panel = lv_tileview_add_tile(main_screen, 0, 1, LV_DIR_HOR);
+ if (app_panel)
+ {
+ lv_obj_set_style_bg_color(app_panel, lv_color_white(), 0);
+ lv_obj_set_style_bg_opa(app_panel, LV_OPA_COVER, 0);
+ }
lv_obj_set_scrollbar_mode(main_screen, LV_SCROLLBAR_MODE_OFF);
lv_obj_remove_flag(main_screen, LV_OBJ_FLAG_SCROLLABLE);
@@ -702,7 +678,7 @@ void setup()
/* Add applications */
for (int i = 0; i < NUM_APPS; ++i)
{
- create_app(panel, kAppNames[i], kAppImages[i], kAppFuncs[i]);
+ create_app(panel, kAppScreens[i]);
lv_group_add_obj(menu_g, lv_obj_get_child(panel, i));
}
@@ -910,13 +886,21 @@ extern bool ui_usb_is_active();
void loop()
{
+ static uint32_t last_lvgl_ms = 0;
+ constexpr uint32_t kLvglIntervalMs = 20;
+ uint32_t now_ms = millis();
+
#ifdef ARDUINO_USB_MODE
// If USB mode is active, run USB loop (like Launcher's loop() function)
// This ensures USB tasks get CPU time and prevents other tasks from interfering
if (ui_usb_is_active())
{
// Process LVGL for USB mode
- lv_timer_handler();
+ if (now_ms - last_lvgl_ms >= kLvglIntervalMs)
+ {
+ last_lvgl_ms = now_ms;
+ lv_timer_handler();
+ }
// Yield to allow USB and other critical tasks to run
// This is critical for USB stability (like Launcher's yield())
@@ -939,7 +923,6 @@ void loop()
#if MAIN_TIMING_DEBUG
static uint32_t last_loop_ms = 0;
static uint32_t loop_count = 0;
- uint32_t now_ms = millis();
// Record loop() call interval
if (last_loop_ms > 0)
@@ -952,21 +935,33 @@ void loop()
}
last_loop_ms = now_ms;
loop_count++;
-
- uint32_t t_before = millis();
#endif
// Normal processing only when NOT in low power mode
- lv_timer_handler();
+ bool run_lvgl = (now_ms - last_lvgl_ms >= kLvglIntervalMs);
+#if MAIN_TIMING_DEBUG
+ uint32_t t_before = 0;
+#endif
+ if (run_lvgl)
+ {
+ last_lvgl_ms = now_ms;
+#if MAIN_TIMING_DEBUG
+ t_before = millis();
+#endif
+ lv_timer_handler();
+ }
#if MAIN_TIMING_DEBUG
- uint32_t t_after = millis();
- uint32_t handler_duration = t_after - t_before;
-
- // Log lv_timer_handler() execution time
- if (handler_duration > 10)
+ if (run_lvgl)
{
- Serial.printf("[MAIN] lv_timer_handler() took %lu ms\n", handler_duration);
+ uint32_t t_after = millis();
+ uint32_t handler_duration = t_after - t_before;
+
+ // Log lv_timer_handler() execution time
+ if (handler_duration > 10)
+ {
+ Serial.printf("[MAIN] lv_timer_handler() took %lu ms\n", handler_duration);
+ }
}
#endif
diff --git a/src/sys/event_bus.h b/src/sys/event_bus.h
index d03ffaa4..b9310b64 100644
--- a/src/sys/event_bus.h
+++ b/src/sys/event_bus.h
@@ -35,9 +35,14 @@ enum class EventType
TeamJoinRequest, // Team join request received
TeamJoinAccept, // Team join accept received
TeamJoinConfirm, // Team join confirm received
+ TeamJoinDecision, // Team join decision received
+ TeamKick, // Team kick received
+ TeamTransferLeader, // Team transfer leader received
+ TeamKeyDist, // Team key distribution received
TeamStatus, // Team status received
TeamPosition, // Team position received
TeamWaypoint, // Team waypoint received
+ TeamChat, // Team chat received
TeamError, // Team protocol error
InputEvent, // Input event (keyboard/rotary)
SystemTick // System tick (for periodic tasks)
@@ -246,6 +251,50 @@ struct TeamJoinConfirmEvent : public Event
: Event(EventType::TeamJoinConfirm), data(evt) {}
};
+/**
+ * @brief Team join decision event
+ */
+struct TeamJoinDecisionEvent : public Event
+{
+ team::TeamJoinDecisionEvent data;
+
+ explicit TeamJoinDecisionEvent(const team::TeamJoinDecisionEvent& evt)
+ : Event(EventType::TeamJoinDecision), data(evt) {}
+};
+
+/**
+ * @brief Team kick event
+ */
+struct TeamKickEvent : public Event
+{
+ team::TeamKickEvent data;
+
+ explicit TeamKickEvent(const team::TeamKickEvent& evt)
+ : Event(EventType::TeamKick), data(evt) {}
+};
+
+/**
+ * @brief Team transfer leader event
+ */
+struct TeamTransferLeaderEvent : public Event
+{
+ team::TeamTransferLeaderEvent data;
+
+ explicit TeamTransferLeaderEvent(const team::TeamTransferLeaderEvent& evt)
+ : Event(EventType::TeamTransferLeader), data(evt) {}
+};
+
+/**
+ * @brief Team key distribution event
+ */
+struct TeamKeyDistEvent : public Event
+{
+ team::TeamKeyDistEvent data;
+
+ explicit TeamKeyDistEvent(const team::TeamKeyDistEvent& evt)
+ : Event(EventType::TeamKeyDist), data(evt) {}
+};
+
/**
* @brief Team status event
*/
@@ -279,6 +328,17 @@ struct TeamWaypointEvent : public Event
: Event(EventType::TeamWaypoint), data(evt) {}
};
+/**
+ * @brief Team chat event
+ */
+struct TeamChatEvent : public Event
+{
+ team::TeamChatEvent data;
+
+ explicit TeamChatEvent(const team::TeamChatEvent& evt)
+ : Event(EventType::TeamChat), data(evt) {}
+};
+
/**
* @brief Team error event
*/
diff --git a/src/team/domain/team_events.h b/src/team/domain/team_events.h
index 05cf7c28..6e19bbb5 100644
--- a/src/team/domain/team_events.h
+++ b/src/team/domain/team_events.h
@@ -1,6 +1,7 @@
#pragma once
#include "team_types.h"
+#include "../protocol/team_chat.h"
#include "../protocol/team_mgmt.h"
#include
#include
@@ -40,6 +41,30 @@ struct TeamJoinConfirmEvent
team::proto::TeamJoinConfirm msg;
};
+struct TeamJoinDecisionEvent
+{
+ TeamEventContext ctx;
+ team::proto::TeamJoinDecision msg;
+};
+
+struct TeamKickEvent
+{
+ TeamEventContext ctx;
+ team::proto::TeamKick msg;
+};
+
+struct TeamTransferLeaderEvent
+{
+ TeamEventContext ctx;
+ team::proto::TeamTransferLeader msg;
+};
+
+struct TeamKeyDistEvent
+{
+ TeamEventContext ctx;
+ team::proto::TeamKeyDist msg;
+};
+
struct TeamStatusEvent
{
TeamEventContext ctx;
@@ -58,6 +83,12 @@ struct TeamWaypointEvent
std::vector payload;
};
+struct TeamChatEvent
+{
+ TeamEventContext ctx;
+ team::proto::TeamChatMessage msg;
+};
+
enum class TeamProtocolError
{
DecryptFail,
diff --git a/src/team/domain/team_types.h b/src/team/domain/team_types.h
index c6ed7454..1e4c5090 100644
--- a/src/team/domain/team_types.h
+++ b/src/team/domain/team_types.h
@@ -18,6 +18,7 @@ struct TeamKeys
std::array mgmt_key{};
std::array pos_key{};
std::array wp_key{};
+ std::array chat_key{};
bool valid = false;
};
diff --git a/src/team/infra/event/team_event_bus_sink.cpp b/src/team/infra/event/team_event_bus_sink.cpp
index 55fec37c..d779bb37 100644
--- a/src/team/infra/event/team_event_bus_sink.cpp
+++ b/src/team/infra/event/team_event_bus_sink.cpp
@@ -25,6 +25,26 @@ void TeamEventBusSink::onTeamJoinConfirm(const TeamJoinConfirmEvent& event)
sys::EventBus::publish(new sys::TeamJoinConfirmEvent(event), 0);
}
+void TeamEventBusSink::onTeamJoinDecision(const TeamJoinDecisionEvent& event)
+{
+ sys::EventBus::publish(new sys::TeamJoinDecisionEvent(event), 0);
+}
+
+void TeamEventBusSink::onTeamKick(const TeamKickEvent& event)
+{
+ sys::EventBus::publish(new sys::TeamKickEvent(event), 0);
+}
+
+void TeamEventBusSink::onTeamTransferLeader(const TeamTransferLeaderEvent& event)
+{
+ sys::EventBus::publish(new sys::TeamTransferLeaderEvent(event), 0);
+}
+
+void TeamEventBusSink::onTeamKeyDist(const TeamKeyDistEvent& event)
+{
+ sys::EventBus::publish(new sys::TeamKeyDistEvent(event), 0);
+}
+
void TeamEventBusSink::onTeamStatus(const TeamStatusEvent& event)
{
sys::EventBus::publish(new sys::TeamStatusEvent(event), 0);
@@ -40,6 +60,11 @@ void TeamEventBusSink::onTeamWaypoint(const TeamWaypointEvent& event)
sys::EventBus::publish(new sys::TeamWaypointEvent(event), 0);
}
+void TeamEventBusSink::onTeamChat(const TeamChatEvent& event)
+{
+ sys::EventBus::publish(new sys::TeamChatEvent(event), 0);
+}
+
void TeamEventBusSink::onTeamError(const TeamErrorEvent& event)
{
sys::EventBus::publish(new sys::TeamErrorEvent(event), 0);
diff --git a/src/team/infra/event/team_event_bus_sink.h b/src/team/infra/event/team_event_bus_sink.h
index d8bebc02..a0f000e8 100644
--- a/src/team/infra/event/team_event_bus_sink.h
+++ b/src/team/infra/event/team_event_bus_sink.h
@@ -12,9 +12,14 @@ class TeamEventBusSink : public team::ITeamEventSink
void onTeamJoinRequest(const TeamJoinRequestEvent& event) override;
void onTeamJoinAccept(const TeamJoinAcceptEvent& event) override;
void onTeamJoinConfirm(const TeamJoinConfirmEvent& event) override;
+ void onTeamJoinDecision(const TeamJoinDecisionEvent& event) override;
+ void onTeamKick(const TeamKickEvent& event) override;
+ void onTeamTransferLeader(const TeamTransferLeaderEvent& event) override;
+ void onTeamKeyDist(const TeamKeyDistEvent& event) override;
void onTeamStatus(const TeamStatusEvent& event) override;
void onTeamPosition(const TeamPositionEvent& event) override;
void onTeamWaypoint(const TeamWaypointEvent& event) override;
+ void onTeamChat(const TeamChatEvent& event) override;
void onTeamError(const TeamErrorEvent& event) override;
};
diff --git a/src/team/infra/nfc/team_nfc.cpp b/src/team/infra/nfc/team_nfc.cpp
new file mode 100644
index 00000000..b6caebc0
--- /dev/null
+++ b/src/team/infra/nfc/team_nfc.cpp
@@ -0,0 +1,1078 @@
+/**
+ * @file team_nfc.cpp
+ * @brief NFC payload + key exchange helpers (Invite Code protected)
+ */
+
+#include "team_nfc.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#ifdef USING_ST25R3916
+#include "board/TLoRaPagerBoard.h"
+#include "board/nfc_include.h"
+#endif
+
+namespace team::nfc
+{
+namespace
+{
+constexpr uint8_t kMagic[4] = { 'T', 'N', 'F', '1' };
+constexpr size_t kDerivedKeyLen = 16;
+constexpr uint32_t kPbkdf2Iterations = 10000;
+constexpr size_t kHeaderSize = 4 + 1 + team::proto::kTeamIdSize + 4 + 4 + kNfcSaltSize + kNfcNonceSize;
+constexpr char kMimeType[] = "application/vnd.trailmate.teamkey";
+constexpr uint8_t kT4tCcFileId[2] = { 0xE1, 0x03 };
+constexpr uint8_t kT4tNdefFileId[2] = { 0xE1, 0x04 };
+constexpr size_t kT4tCcFileLen = 15;
+
+#ifndef TEAM_NFC_LOG_ENABLE
+#define TEAM_NFC_LOG_ENABLE 1
+#endif
+
+#ifndef TEAM_NFC_LOG_SENSITIVE
+#define TEAM_NFC_LOG_SENSITIVE 1
+#endif
+
+#if TEAM_NFC_LOG_ENABLE
+#define TEAM_NFC_LOG(...) Serial.printf(__VA_ARGS__)
+#else
+#define TEAM_NFC_LOG(...)
+#endif
+
+void log_hex(const char* label, const uint8_t* data, size_t len)
+{
+#if TEAM_NFC_LOG_ENABLE
+ if (!label)
+ {
+ label = "";
+ }
+ TEAM_NFC_LOG("[NFC] %s (%u): ", label, static_cast(len));
+ for (size_t i = 0; i < len; ++i)
+ {
+ TEAM_NFC_LOG("%02X", data ? data[i] : 0);
+ }
+ TEAM_NFC_LOG("\n");
+#else
+ (void)label;
+ (void)data;
+ (void)len;
+#endif
+}
+
+void write_u32_le(std::vector& out, uint32_t v)
+{
+ out.push_back(static_cast(v & 0xFF));
+ out.push_back(static_cast((v >> 8) & 0xFF));
+ out.push_back(static_cast((v >> 16) & 0xFF));
+ out.push_back(static_cast((v >> 24) & 0xFF));
+}
+
+bool read_u32_le(const uint8_t* data, size_t len, size_t& offset, uint32_t& out)
+{
+ if (!data || offset + 4 > len)
+ {
+ return false;
+ }
+ out = static_cast(data[offset]) |
+ (static_cast(data[offset + 1]) << 8) |
+ (static_cast(data[offset + 2]) << 16) |
+ (static_cast(data[offset + 3]) << 24);
+ offset += 4;
+ return true;
+}
+
+void fill_random(uint8_t* out, size_t len)
+{
+ for (size_t i = 0; i < len; ++i)
+ {
+ out[i] = static_cast(random(0, 256));
+ }
+}
+
+void hmac_sha256(const uint8_t* key, size_t key_len,
+ const uint8_t* data, size_t data_len,
+ uint8_t out[32])
+{
+ uint8_t key_block[64];
+ memset(key_block, 0, sizeof(key_block));
+ if (key_len > sizeof(key_block))
+ {
+ SHA256 hash;
+ hash.reset();
+ hash.update(key, key_len);
+ hash.finalize(key_block, sizeof(key_block));
+ }
+ else
+ {
+ memcpy(key_block, key, key_len);
+ }
+
+ uint8_t o_key_pad[64];
+ uint8_t i_key_pad[64];
+ for (size_t i = 0; i < sizeof(key_block); ++i)
+ {
+ o_key_pad[i] = static_cast(key_block[i] ^ 0x5c);
+ i_key_pad[i] = static_cast(key_block[i] ^ 0x36);
+ }
+
+ uint8_t inner[32];
+ SHA256 hash;
+ hash.reset();
+ hash.update(i_key_pad, sizeof(i_key_pad));
+ hash.update(data, data_len);
+ hash.finalize(inner, sizeof(inner));
+
+ hash.reset();
+ hash.update(o_key_pad, sizeof(o_key_pad));
+ hash.update(inner, sizeof(inner));
+ hash.finalize(out, 32);
+}
+
+bool pbkdf2_hmac_sha256(const uint8_t* password, size_t password_len,
+ const uint8_t* salt, size_t salt_len,
+ uint32_t iterations,
+ uint8_t* out, size_t out_len)
+{
+ if (!password || !salt || !out || out_len == 0 || out_len > 32 || iterations == 0)
+ {
+ return false;
+ }
+
+ uint8_t block[32];
+ uint8_t u[32];
+ uint8_t salt_block[64];
+ if (salt_len + 4 > sizeof(salt_block))
+ {
+ return false;
+ }
+
+ memcpy(salt_block, salt, salt_len);
+ salt_block[salt_len + 0] = 0;
+ salt_block[salt_len + 1] = 0;
+ salt_block[salt_len + 2] = 0;
+ salt_block[salt_len + 3] = 1;
+
+ hmac_sha256(password, password_len, salt_block, salt_len + 4, u);
+ memcpy(block, u, sizeof(block));
+
+ for (uint32_t i = 1; i < iterations; ++i)
+ {
+ hmac_sha256(password, password_len, u, sizeof(u), u);
+ for (size_t j = 0; j < sizeof(block); ++j)
+ {
+ block[j] ^= u[j];
+ }
+ }
+
+ memcpy(out, block, out_len);
+ return true;
+}
+
+bool aes_gcm_encrypt(const uint8_t* key, size_t key_len,
+ const uint8_t* nonce, size_t nonce_len,
+ const uint8_t* aad, size_t aad_len,
+ const uint8_t* plain, size_t plain_len,
+ uint8_t* out_cipher,
+ uint8_t* out_tag, size_t tag_len)
+{
+ if (!key || !nonce || !plain || !out_cipher || !out_tag)
+ {
+ return false;
+ }
+
+ GCM gcm;
+ if (!gcm.setKey(key, key_len))
+ {
+ return false;
+ }
+ if (!gcm.setIV(nonce, nonce_len))
+ {
+ return false;
+ }
+ if (aad && aad_len > 0)
+ {
+ gcm.addAuthData(aad, aad_len);
+ }
+ if (plain_len > 0)
+ {
+ gcm.encrypt(out_cipher, plain, plain_len);
+ }
+ gcm.computeTag(out_tag, tag_len);
+ return true;
+}
+
+bool aes_gcm_decrypt(const uint8_t* key, size_t key_len,
+ const uint8_t* nonce, size_t nonce_len,
+ const uint8_t* aad, size_t aad_len,
+ const uint8_t* cipher, size_t cipher_len,
+ const uint8_t* tag, size_t tag_len,
+ uint8_t* out_plain)
+{
+ if (!key || !nonce || !cipher || !tag || !out_plain)
+ {
+ return false;
+ }
+
+ GCM gcm;
+ if (!gcm.setKey(key, key_len))
+ {
+ return false;
+ }
+ if (!gcm.setIV(nonce, nonce_len))
+ {
+ return false;
+ }
+ if (aad && aad_len > 0)
+ {
+ gcm.addAuthData(aad, aad_len);
+ }
+ if (cipher_len > 0)
+ {
+ gcm.decrypt(out_plain, cipher, cipher_len);
+ }
+ return gcm.checkTag(tag, tag_len);
+}
+
+void build_aad(const Payload& payload, std::vector& out)
+{
+ out.clear();
+ out.reserve(kHeaderSize);
+ out.insert(out.end(), kMagic, kMagic + sizeof(kMagic));
+ out.push_back(kNfcPayloadVersion);
+ out.insert(out.end(), payload.team_id.begin(), payload.team_id.end());
+ write_u32_le(out, payload.key_id);
+ write_u32_le(out, payload.expires_at);
+ out.insert(out.end(), payload.salt.begin(), payload.salt.end());
+ out.insert(out.end(), payload.nonce.begin(), payload.nonce.end());
+}
+
+bool nfc_available()
+{
+#ifdef USING_ST25R3916
+ TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance();
+ return board && board->isNFCReady() && board->nfc;
+#else
+ return false;
+#endif
+}
+
+bool write_ndef_message(const std::vector& payload)
+{
+#ifdef USING_ST25R3916
+ TEAM_NFC_LOG("[NFC] write_ndef_message payload_len=%u\n", static_cast(payload.size()));
+ if (!nfc_available())
+ {
+ TEAM_NFC_LOG("[NFC] write_ndef_message nfc_not_available\n");
+ return false;
+ }
+
+ TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance();
+ rfalNfcDevice* dev = nullptr;
+ if (board->nfc->rfalNfcGetActiveDevice(&dev) != ERR_NONE || !dev)
+ {
+ TEAM_NFC_LOG("[NFC] write_ndef_message no_active_device\n");
+ return false;
+ }
+
+ ndefConstBuffer8 type_buf{ reinterpret_cast(kMimeType),
+ static_cast(sizeof(kMimeType) - 1) };
+ ndefConstBuffer payload_buf{ payload.data(), static_cast(payload.size()) };
+
+ NdefClass ndef(board->nfc);
+ if (ndef.ndefPollerContextInitializationWrapper(dev) != ERR_NONE)
+ {
+ TEAM_NFC_LOG("[NFC] write_ndef_message ctx_init_failed\n");
+ return false;
+ }
+ if (ndef.ndefPollerNdefDetectWrapper(nullptr) != ERR_NONE)
+ {
+ TEAM_NFC_LOG("[NFC] write_ndef_message ndef_detect_failed\n");
+ return false;
+ }
+ ndefMessage message{};
+ ndefMessageInit(&message);
+
+ ndefRecord record{};
+ if (ndefRecordInit(&record, NDEF_TNF_MEDIA_TYPE, &type_buf, nullptr, &payload_buf) != ERR_NONE)
+ {
+ TEAM_NFC_LOG("[NFC] write_ndef_message record_init_failed\n");
+ return false;
+ }
+ if (ndefMessageAppend(&message, &record) != ERR_NONE)
+ {
+ TEAM_NFC_LOG("[NFC] write_ndef_message message_append_failed\n");
+ return false;
+ }
+
+ uint8_t raw_buf[256];
+ ndefBuffer raw{ raw_buf, sizeof(raw_buf) };
+ if (ndefMessageEncode(&message, &raw) != ERR_NONE)
+ {
+ TEAM_NFC_LOG("[NFC] write_ndef_message encode_failed\n");
+ return false;
+ }
+
+ // NOTE: This writes to a physical tag (poller mode). Card emulation still needs
+ // a listen-mode responder; see TODO in start_share().
+ bool ok = (ndef.ndefPollerWriteRawMessageWrapper(raw.buffer, raw.length) == ERR_NONE);
+ TEAM_NFC_LOG("[NFC] write_ndef_message write_raw %s\n", ok ? "ok" : "fail");
+ return ok;
+#else
+ (void)payload;
+ return false;
+#endif
+}
+
+bool read_ndef_message(std::vector& out_payload)
+{
+#ifdef USING_ST25R3916
+ TEAM_NFC_LOG("[NFC] read_ndef_message start\n");
+ if (!nfc_available())
+ {
+ TEAM_NFC_LOG("[NFC] read_ndef_message nfc_not_available\n");
+ return false;
+ }
+
+ TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance();
+ rfalNfcDevice* dev = nullptr;
+ if (board->nfc->rfalNfcGetActiveDevice(&dev) != ERR_NONE || !dev)
+ {
+ TEAM_NFC_LOG("[NFC] read_ndef_message no_active_device\n");
+ return false;
+ }
+
+ NdefClass ndef(board->nfc);
+ if (ndef.ndefPollerContextInitializationWrapper(dev) != ERR_NONE)
+ {
+ TEAM_NFC_LOG("[NFC] read_ndef_message ctx_init_failed\n");
+ return false;
+ }
+ ndefInfo info{};
+ if (ndef.ndefPollerNdefDetectWrapper(&info) != ERR_NONE)
+ {
+ TEAM_NFC_LOG("[NFC] read_ndef_message ndef_detect_failed\n");
+ return false;
+ }
+
+ uint8_t raw_buf[256];
+ uint32_t rcvd_len = 0;
+ if (ndef.ndefPollerReadRawMessageWrapper(raw_buf, sizeof(raw_buf), &rcvd_len, false) != ERR_NONE || rcvd_len == 0)
+ {
+ TEAM_NFC_LOG("[NFC] read_ndef_message read_raw_failed\n");
+ return false;
+ }
+ TEAM_NFC_LOG("[NFC] read_ndef_message raw_len=%u\n", static_cast(rcvd_len));
+
+ ndefMessage message{};
+ ndefMessageInit(&message);
+ ndefConstBuffer msg_buf{ raw_buf, rcvd_len };
+ if (ndefMessageDecode(&msg_buf, &message) != ERR_NONE)
+ {
+ TEAM_NFC_LOG("[NFC] read_ndef_message decode_failed\n");
+ return false;
+ }
+
+ static const uint8_t kMimeType[] = "application/vnd.trailmate.teamkey";
+ ndefConstBuffer8 type_buf{ kMimeType, static_cast(sizeof(kMimeType) - 1) };
+
+ for (ndefRecord* rec = ndefMessageGetFirstRecord(&message); rec; rec = ndefMessageGetNextRecord(rec))
+ {
+ if (!ndefRecordTypeMatch(rec, NDEF_TNF_MEDIA_TYPE, &type_buf))
+ {
+ continue;
+ }
+ ndefConstBuffer payload_buf{};
+ if (ndefRecordGetPayload(rec, &payload_buf) != ERR_NONE)
+ {
+ TEAM_NFC_LOG("[NFC] read_ndef_message payload_parse_failed\n");
+ continue;
+ }
+ if (payload_buf.buffer && payload_buf.length > 0)
+ {
+ out_payload.assign(payload_buf.buffer, payload_buf.buffer + payload_buf.length);
+ TEAM_NFC_LOG("[NFC] read_ndef_message payload_len=%u\n", static_cast(payload_buf.length));
+ return true;
+ }
+ }
+ TEAM_NFC_LOG("[NFC] read_ndef_message no_payload\n");
+ return false;
+#else
+ (void)out_payload;
+ return false;
+#endif
+}
+
+bool s_scan_active = false;
+bool s_share_active = false;
+std::vector s_share_payload;
+uint32_t s_scan_deadline_ms = 0;
+
+enum class T4tFile : uint8_t
+{
+ None,
+ Cc,
+ Ndef
+};
+
+enum class ShareState : uint8_t
+{
+ Idle,
+ WaitingForCmd,
+ SendingResp
+};
+
+ShareState s_share_state = ShareState::Idle;
+T4tFile s_selected_file = T4tFile::None;
+std::vector s_ndef_file;
+std::array s_cc_file{};
+std::vector s_share_response;
+uint8_t* s_share_rx = nullptr;
+uint16_t* s_share_rx_len = nullptr;
+rfalNfcState s_last_nfc_state = RFAL_NFC_STATE_NOTINIT;
+
+void reset_share_exchange()
+{
+ s_share_state = ShareState::Idle;
+ s_selected_file = T4tFile::None;
+ s_share_response.clear();
+ s_share_rx = nullptr;
+ s_share_rx_len = nullptr;
+ s_last_nfc_state = RFAL_NFC_STATE_NOTINIT;
+}
+
+bool build_t4t_files(const std::vector& payload)
+{
+ if (payload.empty())
+ {
+ TEAM_NFC_LOG("[NFC] build_t4t_files empty_payload\n");
+ return false;
+ }
+ const size_t type_len = strlen(kMimeType);
+ if (type_len > 255 || payload.size() > 255)
+ {
+ TEAM_NFC_LOG("[NFC] build_t4t_files oversized type_len=%u payload_len=%u\n",
+ static_cast(type_len), static_cast(payload.size()));
+ return false;
+ }
+
+ const size_t msg_len = 1 + 1 + 1 + type_len + payload.size();
+ if (msg_len > 0xFFFF)
+ {
+ TEAM_NFC_LOG("[NFC] build_t4t_files msg_len_overflow=%u\n", static_cast(msg_len));
+ return false;
+ }
+
+ s_ndef_file.clear();
+ s_ndef_file.reserve(2 + msg_len);
+ s_ndef_file.push_back(static_cast((msg_len >> 8) & 0xFF));
+ s_ndef_file.push_back(static_cast(msg_len & 0xFF));
+ s_ndef_file.push_back(0xD2); // MB=1, ME=1, SR=1, TNF=0x02 (MIME)
+ s_ndef_file.push_back(static_cast(type_len));
+ s_ndef_file.push_back(static_cast(payload.size()));
+ s_ndef_file.insert(s_ndef_file.end(), kMimeType, kMimeType + type_len);
+ s_ndef_file.insert(s_ndef_file.end(), payload.begin(), payload.end());
+
+ const uint16_t ndef_file_size = static_cast(s_ndef_file.size());
+ s_cc_file = { 0x00, 0x0F, 0x20, 0x00, 0xFF, 0x00, 0xFF,
+ 0x04, 0x06, kT4tNdefFileId[0], kT4tNdefFileId[1],
+ static_cast((ndef_file_size >> 8) & 0xFF),
+ static_cast(ndef_file_size & 0xFF),
+ 0x00, 0xFF };
+ TEAM_NFC_LOG("[NFC] build_t4t_files ok ndef_file_size=%u\n",
+ static_cast(ndef_file_size));
+ log_hex("cc_file", s_cc_file.data(), s_cc_file.size());
+ return true;
+}
+
+void append_status(std::vector& out, uint16_t status)
+{
+ out.push_back(static_cast((status >> 8) & 0xFF));
+ out.push_back(static_cast(status & 0xFF));
+}
+
+void set_status(std::vector& out, uint16_t status)
+{
+ out.clear();
+ append_status(out, status);
+}
+
+bool select_file_by_id(const uint8_t* data, size_t len)
+{
+ if (!data || len != 2)
+ {
+ return false;
+ }
+ if (data[0] == kT4tCcFileId[0] && data[1] == kT4tCcFileId[1])
+ {
+ s_selected_file = T4tFile::Cc;
+ return true;
+ }
+ if (data[0] == kT4tNdefFileId[0] && data[1] == kT4tNdefFileId[1])
+ {
+ s_selected_file = T4tFile::Ndef;
+ return true;
+ }
+ return false;
+}
+
+void handle_apdu(const uint8_t* apdu, size_t len, std::vector& response)
+{
+ response.clear();
+ if (!apdu || len < 4)
+ {
+ TEAM_NFC_LOG("[NFC] apdu invalid len=%u\n", static_cast(len));
+ set_status(response, 0x6700);
+ return;
+ }
+
+ const uint8_t ins = apdu[1];
+ const uint8_t p1 = apdu[2];
+ const uint8_t p2 = apdu[3];
+ TEAM_NFC_LOG("[NFC] apdu ins=0x%02X p1=0x%02X p2=0x%02X len=%u\n",
+ ins, p1, p2, static_cast(len));
+
+ if (ins == 0xA4)
+ {
+ if (len < 5)
+ {
+ set_status(response, 0x6700);
+ return;
+ }
+ const uint8_t lc = apdu[4];
+ if (len < static_cast(5 + lc))
+ {
+ set_status(response, 0x6700);
+ return;
+ }
+ const uint8_t* data = apdu + 5;
+ if (p1 == 0x04)
+ {
+ static const uint8_t kAidV2[] = { 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01 };
+ static const uint8_t kAidV1[] = { 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x00 };
+ bool match_v2 = (lc == sizeof(kAidV2)) && (memcmp(data, kAidV2, sizeof(kAidV2)) == 0);
+ bool match_v1 = (lc == sizeof(kAidV1)) && (memcmp(data, kAidV1, sizeof(kAidV1)) == 0);
+ if (!match_v2 && !match_v1)
+ {
+ TEAM_NFC_LOG("[NFC] apdu select AID not_found lc=%u\n", static_cast(lc));
+ set_status(response, 0x6A82);
+ return;
+ }
+ s_selected_file = T4tFile::None;
+ if (p2 == 0x00)
+ {
+ const uint8_t* aid = match_v2 ? kAidV2 : kAidV1;
+ response.push_back(0x6F);
+ response.push_back(static_cast(2 + sizeof(kAidV2)));
+ response.push_back(0x84);
+ response.push_back(static_cast(sizeof(kAidV2)));
+ response.insert(response.end(), aid, aid + sizeof(kAidV2));
+ }
+ append_status(response, 0x9000);
+ TEAM_NFC_LOG("[NFC] apdu select AID ok\n");
+ return;
+ }
+ if (p1 == 0x00)
+ {
+ if (lc != 2)
+ {
+ TEAM_NFC_LOG("[NFC] apdu select file bad_lc=%u\n", static_cast(lc));
+ set_status(response, 0x6700);
+ return;
+ }
+ if (!select_file_by_id(data, lc))
+ {
+ TEAM_NFC_LOG("[NFC] apdu select file not_found\n");
+ set_status(response, 0x6A82);
+ return;
+ }
+ append_status(response, 0x9000);
+ TEAM_NFC_LOG("[NFC] apdu select file ok\n");
+ return;
+ }
+
+ set_status(response, 0x6A86);
+ return;
+ }
+
+ if (ins == 0xB0)
+ {
+ if (len < 5)
+ {
+ set_status(response, 0x6700);
+ return;
+ }
+ const uint16_t offset = (static_cast(p1) << 8) | p2;
+ uint16_t le = apdu[4];
+ if (le == 0)
+ {
+ le = 256;
+ }
+
+ const uint8_t* file_data = nullptr;
+ size_t file_len = 0;
+ if (s_selected_file == T4tFile::Cc)
+ {
+ file_data = s_cc_file.data();
+ file_len = s_cc_file.size();
+ }
+ else if (s_selected_file == T4tFile::Ndef)
+ {
+ file_data = s_ndef_file.data();
+ file_len = s_ndef_file.size();
+ }
+ else
+ {
+ TEAM_NFC_LOG("[NFC] apdu read no_file_selected\n");
+ set_status(response, 0x6985);
+ return;
+ }
+
+ if (offset >= file_len)
+ {
+ TEAM_NFC_LOG("[NFC] apdu read offset_oob offset=%u file_len=%u\n",
+ static_cast(offset), static_cast(file_len));
+ set_status(response, 0x6B00);
+ return;
+ }
+ const size_t remaining = file_len - offset;
+ const size_t to_copy = (static_cast(le) < remaining) ? static_cast(le) : remaining;
+ response.insert(response.end(), file_data + offset, file_data + offset + to_copy);
+ append_status(response, 0x9000);
+ TEAM_NFC_LOG("[NFC] apdu read offset=%u le=%u copied=%u\n",
+ static_cast(offset), static_cast(le), static_cast(to_copy));
+ return;
+ }
+
+ if (ins == 0xD6)
+ {
+ TEAM_NFC_LOG("[NFC] apdu update rejected (read-only)\n");
+ set_status(response, 0x6982);
+ return;
+ }
+
+ TEAM_NFC_LOG("[NFC] apdu unsupported ins=0x%02X\n", ins);
+ set_status(response, 0x6D00);
+}
+} // namespace
+
+bool encode_payload(const Payload& payload, std::vector& out)
+{
+ TEAM_NFC_LOG("[NFC] encode_payload team_id_len=%u key_id=%u expires_at=%u\n",
+ static_cast(payload.team_id.size()),
+ payload.key_id,
+ payload.expires_at);
+ log_hex("team_id", payload.team_id.data(), payload.team_id.size());
+ log_hex("salt", payload.salt.data(), payload.salt.size());
+ log_hex("nonce", payload.nonce.data(), payload.nonce.size());
+ log_hex("cipher", payload.cipher.data(), payload.cipher.size());
+ log_hex("tag", payload.tag.data(), payload.tag.size());
+
+ out.clear();
+ out.reserve(kHeaderSize + team::proto::kTeamChannelPskSize + kNfcTagSize);
+ out.insert(out.end(), kMagic, kMagic + sizeof(kMagic));
+ out.push_back(kNfcPayloadVersion);
+ out.insert(out.end(), payload.team_id.begin(), payload.team_id.end());
+ write_u32_le(out, payload.key_id);
+ write_u32_le(out, payload.expires_at);
+ out.insert(out.end(), payload.salt.begin(), payload.salt.end());
+ out.insert(out.end(), payload.nonce.begin(), payload.nonce.end());
+ out.insert(out.end(), payload.cipher.begin(), payload.cipher.end());
+ out.insert(out.end(), payload.tag.begin(), payload.tag.end());
+ return true;
+}
+
+bool decode_payload(const uint8_t* data, size_t len, Payload* out)
+{
+ TEAM_NFC_LOG("[NFC] decode_payload len=%u\n", static_cast(len));
+ if (!data || !out)
+ {
+ TEAM_NFC_LOG("[NFC] decode_payload invalid_args\n");
+ return false;
+ }
+ const size_t expected = kHeaderSize + team::proto::kTeamChannelPskSize + kNfcTagSize;
+ if (len < expected)
+ {
+ TEAM_NFC_LOG("[NFC] decode_payload too_short expected=%u\n", static_cast(expected));
+ return false;
+ }
+ if (memcmp(data, kMagic, sizeof(kMagic)) != 0)
+ {
+ TEAM_NFC_LOG("[NFC] decode_payload bad_magic\n");
+ return false;
+ }
+ size_t offset = sizeof(kMagic);
+ uint8_t version = data[offset++];
+ if (version != kNfcPayloadVersion)
+ {
+ TEAM_NFC_LOG("[NFC] decode_payload bad_version=%u\n", static_cast(version));
+ return false;
+ }
+ if (offset + team::proto::kTeamIdSize > len)
+ {
+ return false;
+ }
+ memcpy(out->team_id.data(), data + offset, team::proto::kTeamIdSize);
+ offset += team::proto::kTeamIdSize;
+ if (!read_u32_le(data, len, offset, out->key_id))
+ {
+ TEAM_NFC_LOG("[NFC] decode_payload key_id_failed\n");
+ return false;
+ }
+ if (!read_u32_le(data, len, offset, out->expires_at))
+ {
+ TEAM_NFC_LOG("[NFC] decode_payload expires_failed\n");
+ return false;
+ }
+ if (offset + kNfcSaltSize + kNfcNonceSize + team::proto::kTeamChannelPskSize + kNfcTagSize > len)
+ {
+ return false;
+ }
+ memcpy(out->salt.data(), data + offset, kNfcSaltSize);
+ offset += kNfcSaltSize;
+ memcpy(out->nonce.data(), data + offset, kNfcNonceSize);
+ offset += kNfcNonceSize;
+ memcpy(out->cipher.data(), data + offset, team::proto::kTeamChannelPskSize);
+ offset += team::proto::kTeamChannelPskSize;
+ memcpy(out->tag.data(), data + offset, kNfcTagSize);
+ TEAM_NFC_LOG("[NFC] decode_payload ok key_id=%u expires_at=%u\n", out->key_id, out->expires_at);
+ log_hex("team_id", out->team_id.data(), out->team_id.size());
+ log_hex("salt", out->salt.data(), out->salt.size());
+ log_hex("nonce", out->nonce.data(), out->nonce.size());
+ log_hex("cipher", out->cipher.data(), out->cipher.size());
+ log_hex("tag", out->tag.data(), out->tag.size());
+ return true;
+}
+
+bool build_payload(const TeamId& team_id,
+ uint32_t key_id,
+ uint32_t expires_at,
+ const uint8_t* psk,
+ size_t psk_len,
+ const std::string& invite_code,
+ std::vector& out)
+{
+ TEAM_NFC_LOG("[NFC] build_payload key_id=%u expires_at=%u psk_len=%u\n",
+ key_id, expires_at, static_cast(psk_len));
+#if TEAM_NFC_LOG_SENSITIVE
+ TEAM_NFC_LOG("[NFC] build_payload invite_code=%s\n", invite_code.c_str());
+ log_hex("psk", psk, psk_len);
+#endif
+ if (!psk || psk_len != team::proto::kTeamChannelPskSize || invite_code.empty())
+ {
+ TEAM_NFC_LOG("[NFC] build_payload invalid_args\n");
+ return false;
+ }
+
+ Payload payload;
+ payload.team_id = team_id;
+ payload.key_id = key_id;
+ payload.expires_at = expires_at;
+ fill_random(payload.salt.data(), payload.salt.size());
+ fill_random(payload.nonce.data(), payload.nonce.size());
+
+ uint8_t key[kDerivedKeyLen];
+ if (!pbkdf2_hmac_sha256(reinterpret_cast(invite_code.data()),
+ invite_code.size(),
+ payload.salt.data(),
+ payload.salt.size(),
+ kPbkdf2Iterations,
+ key, sizeof(key)))
+ {
+ TEAM_NFC_LOG("[NFC] build_payload kdf_failed\n");
+ return false;
+ }
+#if TEAM_NFC_LOG_SENSITIVE
+ log_hex("kdf_key", key, sizeof(key));
+#endif
+
+ std::vector aad;
+ build_aad(payload, aad);
+ if (!aes_gcm_encrypt(key, sizeof(key),
+ payload.nonce.data(), payload.nonce.size(),
+ aad.data(), aad.size(),
+ psk, psk_len,
+ payload.cipher.data(),
+ payload.tag.data(), payload.tag.size()))
+ {
+ TEAM_NFC_LOG("[NFC] build_payload gcm_encrypt_failed\n");
+ return false;
+ }
+
+ return encode_payload(payload, out);
+}
+
+bool decrypt_payload(const Payload& payload,
+ const std::string& invite_code,
+ std::array& out_psk)
+{
+ TEAM_NFC_LOG("[NFC] decrypt_payload key_id=%u expires_at=%u\n", payload.key_id, payload.expires_at);
+#if TEAM_NFC_LOG_SENSITIVE
+ TEAM_NFC_LOG("[NFC] decrypt_payload invite_code=%s\n", invite_code.c_str());
+#endif
+ if (invite_code.empty())
+ {
+ TEAM_NFC_LOG("[NFC] decrypt_payload empty_invite_code\n");
+ return false;
+ }
+ uint8_t key[kDerivedKeyLen];
+ if (!pbkdf2_hmac_sha256(reinterpret_cast(invite_code.data()),
+ invite_code.size(),
+ payload.salt.data(),
+ payload.salt.size(),
+ kPbkdf2Iterations,
+ key, sizeof(key)))
+ {
+ TEAM_NFC_LOG("[NFC] decrypt_payload kdf_failed\n");
+ return false;
+ }
+#if TEAM_NFC_LOG_SENSITIVE
+ log_hex("kdf_key", key, sizeof(key));
+#endif
+ std::vector aad;
+ build_aad(payload, aad);
+ bool ok = aes_gcm_decrypt(key, sizeof(key),
+ payload.nonce.data(), payload.nonce.size(),
+ aad.data(), aad.size(),
+ payload.cipher.data(), payload.cipher.size(),
+ payload.tag.data(), payload.tag.size(),
+ out_psk.data());
+#if TEAM_NFC_LOG_SENSITIVE
+ if (ok)
+ {
+ log_hex("psk", out_psk.data(), out_psk.size());
+ }
+#endif
+ TEAM_NFC_LOG("[NFC] decrypt_payload %s\n", ok ? "ok" : "fail");
+ return ok;
+}
+
+bool start_share(const std::vector& payload)
+{
+ TEAM_NFC_LOG("[NFC] start_share payload_len=%u\n", static_cast(payload.size()));
+ s_share_payload = payload;
+ s_share_active = false;
+ reset_share_exchange();
+ if (!build_t4t_files(payload))
+ {
+ TEAM_NFC_LOG("[NFC] start_share build_t4t_files_failed\n");
+ return false;
+ }
+
+#ifdef USING_ST25R3916
+ if (!nfc_available())
+ {
+ TEAM_NFC_LOG("[NFC] start_share nfc_not_available\n");
+ return false;
+ }
+
+ TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance();
+ // Start listen mode for NFC-A (card emulation). APDU responses are
+ // handled in poll_share().
+ if (!board->startNFCDiscovery(RFAL_NFC_LISTEN_TECH_A, 60000))
+ {
+ TEAM_NFC_LOG("[NFC] start_share listen_start_failed\n");
+ return false;
+ }
+ s_share_active = true;
+ TEAM_NFC_LOG("[NFC] start_share ok\n");
+ return true;
+#else
+ (void)payload;
+ return false;
+#endif
+}
+
+void stop_share()
+{
+ TEAM_NFC_LOG("[NFC] stop_share\n");
+ s_share_active = false;
+ s_share_payload.clear();
+ reset_share_exchange();
+ s_ndef_file.clear();
+#ifdef USING_ST25R3916
+ if (nfc_available())
+ {
+ TLoRaPagerBoard::getInstance()->stopNFCDiscovery();
+ }
+#endif
+}
+
+void poll_share()
+{
+#ifdef USING_ST25R3916
+ if (!s_share_active || !nfc_available())
+ {
+ return;
+ }
+
+ TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance();
+ if (!board || !board->nfc)
+ {
+ return;
+ }
+
+ if (board->LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(2)))
+ {
+ board->pollNfcIrq();
+ board->nfc->rfalNfcWorker();
+ board->LilyGoDispArduinoSPI::unlock();
+ }
+
+ rfalNfcState state = board->nfc->rfalNfcGetState();
+ if (state != s_last_nfc_state)
+ {
+ TEAM_NFC_LOG("[NFC] poll_share state=%d\n", static_cast(state));
+ s_last_nfc_state = state;
+ }
+ if (state < RFAL_NFC_STATE_ACTIVATED)
+ {
+ return;
+ }
+
+ if (s_share_state == ShareState::Idle)
+ {
+ ReturnCode err = board->nfc->rfalNfcDataExchangeStart(nullptr, 0, &s_share_rx, &s_share_rx_len, RFAL_FWT_NONE);
+ if (err == ERR_NONE)
+ {
+ s_share_state = ShareState::WaitingForCmd;
+ TEAM_NFC_LOG("[NFC] poll_share wait_for_cmd\n");
+ }
+ else
+ {
+ TEAM_NFC_LOG("[NFC] poll_share start_wait_failed err=%d\n", err);
+ }
+ return;
+ }
+
+ ReturnCode err = board->nfc->rfalNfcDataExchangeGetStatus();
+ if (err == ERR_BUSY)
+ {
+ return;
+ }
+ if (err == ERR_SLEEP_REQ || err == ERR_LINK_LOSS)
+ {
+ TEAM_NFC_LOG("[NFC] poll_share link_sleep err=%d\n", err);
+ reset_share_exchange();
+ return;
+ }
+ if (err != ERR_NONE)
+ {
+ TEAM_NFC_LOG("[NFC] poll_share exchange_err=%d\n", err);
+ reset_share_exchange();
+ return;
+ }
+
+ if (s_share_state == ShareState::WaitingForCmd)
+ {
+ size_t cmd_len = s_share_rx_len ? static_cast(*s_share_rx_len) : 0;
+ TEAM_NFC_LOG("[NFC] poll_share cmd_len=%u\n", static_cast(cmd_len));
+ if (cmd_len > 0)
+ {
+ log_hex("apdu", s_share_rx, cmd_len);
+ }
+ handle_apdu(s_share_rx, cmd_len, s_share_response);
+ err = board->nfc->rfalNfcDataExchangeStart(s_share_response.data(),
+ static_cast(s_share_response.size()),
+ &s_share_rx, &s_share_rx_len,
+ RFAL_FWT_NONE);
+ if (err == ERR_NONE)
+ {
+ s_share_state = ShareState::SendingResp;
+ TEAM_NFC_LOG("[NFC] poll_share resp_len=%u\n", static_cast(s_share_response.size()));
+ log_hex("rapdu", s_share_response.data(), s_share_response.size());
+ }
+ else
+ {
+ TEAM_NFC_LOG("[NFC] poll_share send_resp_failed err=%d\n", err);
+ reset_share_exchange();
+ }
+ return;
+ }
+
+ if (s_share_state == ShareState::SendingResp)
+ {
+ TEAM_NFC_LOG("[NFC] poll_share resp_sent\n");
+ s_share_state = ShareState::Idle;
+ }
+#else
+ (void)0;
+#endif
+}
+
+bool start_scan(uint16_t duration_ms)
+{
+ s_scan_active = false;
+ s_scan_deadline_ms = 0;
+
+#ifdef USING_ST25R3916
+ TEAM_NFC_LOG("[NFC] start_scan duration_ms=%u\n", static_cast(duration_ms));
+ if (!nfc_available())
+ {
+ TEAM_NFC_LOG("[NFC] start_scan nfc_not_available\n");
+ return false;
+ }
+ TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance();
+ if (!board->startNFCDiscovery(RFAL_NFC_POLL_TECH_A, duration_ms))
+ {
+ TEAM_NFC_LOG("[NFC] start_scan discovery_failed\n");
+ return false;
+ }
+ s_scan_active = true;
+ s_scan_deadline_ms = millis() + duration_ms;
+ TEAM_NFC_LOG("[NFC] start_scan ok deadline_ms=%u\n", static_cast(s_scan_deadline_ms));
+ return true;
+#else
+ (void)duration_ms;
+ return false;
+#endif
+}
+
+void stop_scan()
+{
+ TEAM_NFC_LOG("[NFC] stop_scan\n");
+ s_scan_active = false;
+ s_scan_deadline_ms = 0;
+#ifdef USING_ST25R3916
+ if (nfc_available())
+ {
+ TLoRaPagerBoard::getInstance()->stopNFCDiscovery();
+ }
+#endif
+}
+
+bool poll_scan(std::vector& out_payload)
+{
+ if (!s_scan_active)
+ {
+ return false;
+ }
+ if (s_scan_deadline_ms != 0 && millis() > s_scan_deadline_ms)
+ {
+ TEAM_NFC_LOG("[NFC] poll_scan deadline_reached\n");
+ stop_scan();
+ return false;
+ }
+ if (read_ndef_message(out_payload))
+ {
+ TEAM_NFC_LOG("[NFC] poll_scan payload_len=%u\n", static_cast(out_payload.size()));
+ stop_scan();
+ return true;
+ }
+ return false;
+}
+
+bool is_scan_active()
+{
+ return s_scan_active;
+}
+
+bool is_share_active()
+{
+ return s_share_active;
+}
+
+} // namespace team::nfc
diff --git a/src/team/infra/nfc/team_nfc.h b/src/team/infra/nfc/team_nfc.h
new file mode 100644
index 00000000..c28bb504
--- /dev/null
+++ b/src/team/infra/nfc/team_nfc.h
@@ -0,0 +1,59 @@
+/**
+ * @file team_nfc.h
+ * @brief NFC payload + key exchange helpers (Invite Code protected)
+ */
+
+#pragma once
+
+#include "../../domain/team_types.h"
+#include "../../protocol/team_mgmt.h"
+#include
+#include
+#include
+#include
+#include
+
+namespace team::nfc
+{
+
+constexpr uint8_t kNfcPayloadVersion = 1;
+constexpr size_t kNfcSaltSize = 8;
+constexpr size_t kNfcNonceSize = 12;
+constexpr size_t kNfcTagSize = 16;
+
+struct Payload
+{
+ TeamId team_id{};
+ uint32_t key_id = 0;
+ uint32_t expires_at = 0;
+ std::array salt{};
+ std::array nonce{};
+ std::array cipher{};
+ std::array tag{};
+};
+
+bool encode_payload(const Payload& payload, std::vector& out);
+bool decode_payload(const uint8_t* data, size_t len, Payload* out);
+
+bool build_payload(const TeamId& team_id,
+ uint32_t key_id,
+ uint32_t expires_at,
+ const uint8_t* psk,
+ size_t psk_len,
+ const std::string& invite_code,
+ std::vector& out);
+
+bool decrypt_payload(const Payload& payload,
+ const std::string& invite_code,
+ std::array& out_psk);
+
+bool start_share(const std::vector& payload);
+void stop_share();
+void poll_share();
+bool start_scan(uint16_t duration_ms);
+void stop_scan();
+bool poll_scan(std::vector& out_payload);
+bool is_scan_active();
+bool is_share_active();
+
+} // namespace team::nfc
diff --git a/src/team/ports/i_team_event_sink.h b/src/team/ports/i_team_event_sink.h
index 4edf5881..4d94f37d 100644
--- a/src/team/ports/i_team_event_sink.h
+++ b/src/team/ports/i_team_event_sink.h
@@ -14,9 +14,14 @@ class ITeamEventSink
virtual void onTeamJoinRequest(const TeamJoinRequestEvent& event) = 0;
virtual void onTeamJoinAccept(const TeamJoinAcceptEvent& event) = 0;
virtual void onTeamJoinConfirm(const TeamJoinConfirmEvent& event) = 0;
+ virtual void onTeamJoinDecision(const TeamJoinDecisionEvent& event) = 0;
+ virtual void onTeamKick(const TeamKickEvent& event) = 0;
+ virtual void onTeamTransferLeader(const TeamTransferLeaderEvent& event) = 0;
+ virtual void onTeamKeyDist(const TeamKeyDistEvent& event) = 0;
virtual void onTeamStatus(const TeamStatusEvent& event) = 0;
virtual void onTeamPosition(const TeamPositionEvent& event) = 0;
virtual void onTeamWaypoint(const TeamWaypointEvent& event) = 0;
+ virtual void onTeamChat(const TeamChatEvent& event) = 0;
virtual void onTeamError(const TeamErrorEvent& event) = 0;
};
diff --git a/src/team/protocol/team_chat.cpp b/src/team/protocol/team_chat.cpp
new file mode 100644
index 00000000..1d48dcc0
--- /dev/null
+++ b/src/team/protocol/team_chat.cpp
@@ -0,0 +1,250 @@
+/**
+ * @file team_chat.cpp
+ * @brief Team chat protocol encoding/decoding
+ */
+
+#include "team_chat.h"
+
+namespace team::proto
+{
+namespace
+{
+void write_u16_le(std::vector& out, uint16_t v)
+{
+ out.push_back(static_cast(v & 0xFF));
+ out.push_back(static_cast((v >> 8) & 0xFF));
+}
+
+void write_u32_le(std::vector& out, uint32_t v)
+{
+ out.push_back(static_cast(v & 0xFF));
+ out.push_back(static_cast((v >> 8) & 0xFF));
+ out.push_back(static_cast((v >> 16) & 0xFF));
+ out.push_back(static_cast((v >> 24) & 0xFF));
+}
+
+void write_i32_le(std::vector& out, int32_t v)
+{
+ write_u32_le(out, static_cast(v));
+}
+
+void write_i16_le(std::vector& out, int16_t v)
+{
+ write_u16_le(out, static_cast(v));
+}
+
+bool read_u16_le(const uint8_t* data, size_t len, size_t& off, uint16_t& out)
+{
+ if (off + 2 > len)
+ {
+ return false;
+ }
+ out = static_cast(data[off]) |
+ (static_cast(data[off + 1]) << 8);
+ off += 2;
+ return true;
+}
+
+bool read_u32_le(const uint8_t* data, size_t len, size_t& off, uint32_t& out)
+{
+ if (off + 4 > len)
+ {
+ return false;
+ }
+ out = static_cast(data[off]) |
+ (static_cast(data[off + 1]) << 8) |
+ (static_cast(data[off + 2]) << 16) |
+ (static_cast(data[off + 3]) << 24);
+ off += 4;
+ return true;
+}
+
+bool read_i32_le(const uint8_t* data, size_t len, size_t& off, int32_t& out)
+{
+ uint32_t tmp = 0;
+ if (!read_u32_le(data, len, off, tmp))
+ {
+ return false;
+ }
+ out = static_cast(tmp);
+ return true;
+}
+
+bool read_i16_le(const uint8_t* data, size_t len, size_t& off, int16_t& out)
+{
+ uint16_t tmp = 0;
+ if (!read_u16_le(data, len, off, tmp))
+ {
+ return false;
+ }
+ out = static_cast(tmp);
+ return true;
+}
+} // namespace
+
+bool encodeTeamChatMessage(const TeamChatMessage& msg, std::vector& out)
+{
+ out.clear();
+ out.reserve(1 + 1 + 2 + 4 + 4 + 4 + msg.payload.size());
+ out.push_back(msg.header.version);
+ out.push_back(static_cast(msg.header.type));
+ write_u16_le(out, msg.header.flags);
+ write_u32_le(out, msg.header.msg_id);
+ write_u32_le(out, msg.header.ts);
+ write_u32_le(out, msg.header.from);
+ out.insert(out.end(), msg.payload.begin(), msg.payload.end());
+ return true;
+}
+
+bool decodeTeamChatMessage(const uint8_t* data, size_t len, TeamChatMessage* out)
+{
+ if (!data || !out || len < 1 + 1 + 2 + 4 + 4 + 4)
+ {
+ return false;
+ }
+ size_t off = 0;
+ TeamChatHeader header{};
+ header.version = data[off++];
+ header.type = static_cast(data[off++]);
+ if (!read_u16_le(data, len, off, header.flags))
+ {
+ return false;
+ }
+ if (!read_u32_le(data, len, off, header.msg_id))
+ {
+ return false;
+ }
+ if (!read_u32_le(data, len, off, header.ts))
+ {
+ return false;
+ }
+ if (!read_u32_le(data, len, off, header.from))
+ {
+ return false;
+ }
+ out->header = header;
+ out->payload.assign(data + off, data + len);
+ return true;
+}
+
+bool encodeTeamChatLocation(const TeamChatLocation& loc, std::vector& out)
+{
+ out.clear();
+ out.reserve(4 + 4 + 2 + 2 + 4 + 1 + 2 + loc.label.size());
+ write_i32_le(out, loc.lat_e7);
+ write_i32_le(out, loc.lon_e7);
+ write_i16_le(out, loc.alt_m);
+ write_u16_le(out, loc.acc_m);
+ write_u32_le(out, loc.ts);
+ out.push_back(loc.source);
+ uint16_t label_len = static_cast(loc.label.size());
+ write_u16_le(out, label_len);
+ if (label_len > 0)
+ {
+ out.insert(out.end(), loc.label.begin(), loc.label.end());
+ }
+ return true;
+}
+
+bool decodeTeamChatLocation(const uint8_t* data, size_t len, TeamChatLocation* out)
+{
+ if (!data || !out)
+ {
+ return false;
+ }
+ size_t off = 0;
+ if (!read_i32_le(data, len, off, out->lat_e7))
+ {
+ return false;
+ }
+ if (!read_i32_le(data, len, off, out->lon_e7))
+ {
+ return false;
+ }
+ if (!read_i16_le(data, len, off, out->alt_m))
+ {
+ return false;
+ }
+ if (!read_u16_le(data, len, off, out->acc_m))
+ {
+ return false;
+ }
+ if (!read_u32_le(data, len, off, out->ts))
+ {
+ return false;
+ }
+ if (off + 1 > len)
+ {
+ return false;
+ }
+ out->source = data[off++];
+ uint16_t label_len = 0;
+ if (!read_u16_le(data, len, off, label_len))
+ {
+ return false;
+ }
+ if (off + label_len > len)
+ {
+ return false;
+ }
+ out->label.assign(reinterpret_cast(data + off), label_len);
+ return true;
+}
+
+bool encodeTeamChatCommand(const TeamChatCommand& cmd, std::vector& out)
+{
+ out.clear();
+ out.reserve(1 + 4 + 4 + 2 + 1 + 2 + cmd.note.size());
+ out.push_back(static_cast(cmd.cmd_type));
+ write_i32_le(out, cmd.lat_e7);
+ write_i32_le(out, cmd.lon_e7);
+ write_u16_le(out, cmd.radius_m);
+ out.push_back(cmd.priority);
+ uint16_t note_len = static_cast(cmd.note.size());
+ write_u16_le(out, note_len);
+ if (note_len > 0)
+ {
+ out.insert(out.end(), cmd.note.begin(), cmd.note.end());
+ }
+ return true;
+}
+
+bool decodeTeamChatCommand(const uint8_t* data, size_t len, TeamChatCommand* out)
+{
+ if (!data || !out || len < 1)
+ {
+ return false;
+ }
+ size_t off = 0;
+ out->cmd_type = static_cast(data[off++]);
+ if (!read_i32_le(data, len, off, out->lat_e7))
+ {
+ return false;
+ }
+ if (!read_i32_le(data, len, off, out->lon_e7))
+ {
+ return false;
+ }
+ if (!read_u16_le(data, len, off, out->radius_m))
+ {
+ return false;
+ }
+ if (off + 1 > len)
+ {
+ return false;
+ }
+ out->priority = data[off++];
+ uint16_t note_len = 0;
+ if (!read_u16_le(data, len, off, note_len))
+ {
+ return false;
+ }
+ if (off + note_len > len)
+ {
+ return false;
+ }
+ out->note.assign(reinterpret_cast(data + off), note_len);
+ return true;
+}
+
+} // namespace team::proto
diff --git a/src/team/protocol/team_chat.h b/src/team/protocol/team_chat.h
new file mode 100644
index 00000000..d7609b8c
--- /dev/null
+++ b/src/team/protocol/team_chat.h
@@ -0,0 +1,77 @@
+/**
+ * @file team_chat.h
+ * @brief Team chat protocol payloads
+ */
+
+#pragma once
+
+#include
+#include
+#include
+
+namespace team::proto
+{
+
+constexpr uint8_t kTeamChatVersion = 1;
+
+enum class TeamChatType : uint8_t
+{
+ Text = 1,
+ Location = 2,
+ Command = 3
+};
+
+struct TeamChatHeader
+{
+ uint8_t version = kTeamChatVersion;
+ TeamChatType type = TeamChatType::Text;
+ uint16_t flags = 0;
+ uint32_t msg_id = 0;
+ uint32_t ts = 0;
+ uint32_t from = 0;
+};
+
+struct TeamChatMessage
+{
+ TeamChatHeader header{};
+ std::vector payload;
+};
+
+enum class TeamCommandType : uint8_t
+{
+ RallyTo = 1,
+ MoveTo = 2,
+ Hold = 3
+};
+
+struct TeamChatLocation
+{
+ int32_t lat_e7 = 0;
+ int32_t lon_e7 = 0;
+ int16_t alt_m = 0;
+ uint16_t acc_m = 0;
+ uint32_t ts = 0;
+ uint8_t source = 0;
+ std::string label;
+};
+
+struct TeamChatCommand
+{
+ TeamCommandType cmd_type = TeamCommandType::RallyTo;
+ int32_t lat_e7 = 0;
+ int32_t lon_e7 = 0;
+ uint16_t radius_m = 0;
+ uint8_t priority = 0;
+ std::string note;
+};
+
+bool encodeTeamChatMessage(const TeamChatMessage& msg, std::vector& out);
+bool decodeTeamChatMessage(const uint8_t* data, size_t len, TeamChatMessage* out);
+
+bool encodeTeamChatLocation(const TeamChatLocation& loc, std::vector& out);
+bool decodeTeamChatLocation(const uint8_t* data, size_t len, TeamChatLocation* out);
+
+bool encodeTeamChatCommand(const TeamChatCommand& cmd, std::vector& out);
+bool decodeTeamChatCommand(const uint8_t* data, size_t len, TeamChatCommand* out);
+
+} // namespace team::proto
diff --git a/src/team/protocol/team_mgmt.cpp b/src/team/protocol/team_mgmt.cpp
index b6f76458..fe070a03 100644
--- a/src/team/protocol/team_mgmt.cpp
+++ b/src/team/protocol/team_mgmt.cpp
@@ -282,8 +282,13 @@ bool encodeTeamJoinAccept(const TeamJoinAccept& input, std::vector& out
uint16_t flags = 0;
if (input.params.has_params) flags |= 0x01;
+ if (input.has_team_id) flags |= 0x02;
writer.putU16(flags);
if (!encodeTeamParams(input.params, writer)) return false;
+ if (input.has_team_id)
+ {
+ writer.putBytes(input.team_id.data(), input.team_id.size());
+ }
return true;
}
@@ -305,7 +310,9 @@ bool decodeTeamJoinAccept(const uint8_t* data, size_t len, TeamJoinAccept* out)
if (!reader.getU16(&flags)) return false;
out->params.has_params = (flags & 0x01) != 0;
+ out->has_team_id = (flags & 0x02) != 0;
if (out->params.has_params && !decodeTeamParams(reader, &out->params)) return false;
+ if (out->has_team_id && !reader.getBytes(out->team_id.data(), out->team_id.size())) return false;
return true;
}
@@ -346,6 +353,103 @@ bool decodeTeamJoinConfirm(const uint8_t* data, size_t len, TeamJoinConfirm* out
return true;
}
+bool encodeTeamJoinDecision(const TeamJoinDecision& input, std::vector& out)
+{
+ out.clear();
+ ByteWriter writer(out);
+ writer.putU8(input.accept ? 1 : 0);
+ uint16_t flags = 0;
+ if (input.has_reason) flags |= 0x01;
+ writer.putU16(flags);
+ if (input.has_reason) writer.putU32(input.reason);
+ return true;
+}
+
+bool decodeTeamJoinDecision(const uint8_t* data, size_t len, TeamJoinDecision* out)
+{
+ if (!data || !out)
+ {
+ return false;
+ }
+ ByteReader reader(data, len);
+ uint16_t flags = 0;
+ uint8_t accept = 0;
+ if (!reader.getU8(&accept)) return false;
+ out->accept = (accept != 0);
+ if (!reader.getU16(&flags)) return false;
+ out->has_reason = (flags & 0x01) != 0;
+ if (out->has_reason && !reader.getU32(&out->reason)) return false;
+ return true;
+}
+
+bool encodeTeamKick(const TeamKick& input, std::vector& out)
+{
+ out.clear();
+ ByteWriter writer(out);
+ writer.putU32(input.target);
+ return true;
+}
+
+bool decodeTeamKick(const uint8_t* data, size_t len, TeamKick* out)
+{
+ if (!data || !out)
+ {
+ return false;
+ }
+ ByteReader reader(data, len);
+ if (!reader.getU32(&out->target)) return false;
+ return true;
+}
+
+bool encodeTeamTransferLeader(const TeamTransferLeader& input, std::vector& out)
+{
+ out.clear();
+ ByteWriter writer(out);
+ writer.putU32(input.target);
+ return true;
+}
+
+bool decodeTeamTransferLeader(const uint8_t* data, size_t len, TeamTransferLeader* out)
+{
+ if (!data || !out)
+ {
+ return false;
+ }
+ ByteReader reader(data, len);
+ if (!reader.getU32(&out->target)) return false;
+ return true;
+}
+
+bool encodeTeamKeyDist(const TeamKeyDist& input, std::vector& out)
+{
+ if (input.channel_psk_len > input.channel_psk.size())
+ {
+ return false;
+ }
+ out.clear();
+ ByteWriter writer(out);
+ writer.putBytes(input.team_id.data(), input.team_id.size());
+ writer.putU32(input.key_id);
+ writer.putU8(input.channel_psk_len);
+ writer.putBytes(input.channel_psk.data(), input.channel_psk_len);
+ return true;
+}
+
+bool decodeTeamKeyDist(const uint8_t* data, size_t len, TeamKeyDist* out)
+{
+ if (!data || !out)
+ {
+ return false;
+ }
+ ByteReader reader(data, len);
+ if (!reader.getBytes(out->team_id.data(), out->team_id.size())) return false;
+ if (!reader.getU32(&out->key_id)) return false;
+ if (!reader.getU8(&out->channel_psk_len)) return false;
+ if (out->channel_psk_len > out->channel_psk.size()) return false;
+ if (!reader.getBytes(out->channel_psk.data(), out->channel_psk_len)) return false;
+ return true;
+}
+
bool encodeTeamStatus(const TeamStatus& input, std::vector& out)
{
out.clear();
diff --git a/src/team/protocol/team_mgmt.h b/src/team/protocol/team_mgmt.h
index 7f9508a1..860aec48 100644
--- a/src/team/protocol/team_mgmt.h
+++ b/src/team/protocol/team_mgmt.h
@@ -22,7 +22,11 @@ enum class TeamMgmtType : uint8_t
Status = 5,
Rotate = 6,
Leave = 7,
- Disband = 8
+ Disband = 8,
+ JoinDecision = 9,
+ Kick = 10,
+ TransferLeader = 11,
+ KeyDist = 12
};
struct TeamParams
@@ -58,11 +62,13 @@ struct TeamJoinRequest
struct TeamJoinAccept
{
+ std::array team_id{};
uint8_t channel_index = 0;
std::array channel_psk{};
uint8_t channel_psk_len = 0;
uint32_t key_id = 0;
TeamParams params;
+ bool has_team_id = false;
};
struct TeamJoinConfirm
@@ -74,6 +80,31 @@ struct TeamJoinConfirm
bool has_battery = false;
};
+struct TeamJoinDecision
+{
+ bool accept = false;
+ uint32_t reason = 0;
+ bool has_reason = false;
+};
+
+struct TeamKick
+{
+ uint32_t target = 0;
+};
+
+struct TeamTransferLeader
+{
+ uint32_t target = 0;
+};
+
+struct TeamKeyDist
+{
+ std::array team_id{};
+ uint32_t key_id = 0;
+ std::array channel_psk{};
+ uint8_t channel_psk_len = 0;
+};
+
struct TeamStatus
{
std::array member_list_hash{};
@@ -101,6 +132,18 @@ bool decodeTeamJoinAccept(const uint8_t* data, size_t len, TeamJoinAccept* out);
bool encodeTeamJoinConfirm(const TeamJoinConfirm& input, std::vector& out);
bool decodeTeamJoinConfirm(const uint8_t* data, size_t len, TeamJoinConfirm* out);
+bool encodeTeamJoinDecision(const TeamJoinDecision& input, std::vector& out);
+bool decodeTeamJoinDecision(const uint8_t* data, size_t len, TeamJoinDecision* out);
+
+bool encodeTeamKick(const TeamKick& input, std::vector& out);
+bool decodeTeamKick(const uint8_t* data, size_t len, TeamKick* out);
+
+bool encodeTeamTransferLeader(const TeamTransferLeader& input, std::vector& out);
+bool decodeTeamTransferLeader(const uint8_t* data, size_t len, TeamTransferLeader* out);
+
+bool encodeTeamKeyDist(const TeamKeyDist& input, std::vector& out);
+bool decodeTeamKeyDist(const uint8_t* data, size_t len, TeamKeyDist* out);
+
bool encodeTeamStatus(const TeamStatus& input, std::vector& out);
bool decodeTeamStatus(const uint8_t* data, size_t len, TeamStatus* out);
diff --git a/src/team/protocol/team_portnum.h b/src/team/protocol/team_portnum.h
index 9c3bd073..3a1fe762 100644
--- a/src/team/protocol/team_portnum.h
+++ b/src/team/protocol/team_portnum.h
@@ -8,5 +8,6 @@ namespace team::proto
constexpr uint32_t TEAM_MGMT_APP = 300;
constexpr uint32_t TEAM_POSITION_APP = 301;
constexpr uint32_t TEAM_WAYPOINT_APP = 302;
+constexpr uint32_t TEAM_CHAT_APP = 303;
} // namespace team::proto
diff --git a/src/team/usecase/team_controller.cpp b/src/team/usecase/team_controller.cpp
index 76670910..5e0bfd5c 100644
--- a/src/team/usecase/team_controller.cpp
+++ b/src/team/usecase/team_controller.cpp
@@ -19,6 +19,12 @@ void TeamController::clearKeys()
state_ = TeamUiState::Idle;
}
+bool TeamController::setKeysFromPsk(const TeamId& team_id, uint32_t key_id,
+ const uint8_t* psk, size_t psk_len)
+{
+ return service_.setKeysFromPsk(team_id, key_id, psk, psk_len);
+}
+
bool TeamController::onCreateTeam(const team::proto::TeamAdvertise& advertise,
chat::ChannelId channel)
{
@@ -30,6 +36,13 @@ bool TeamController::onCreateTeam(const team::proto::TeamAdvertise& advertise,
return ok;
}
+bool TeamController::onAdvertise(const team::proto::TeamAdvertise& advertise,
+ chat::ChannelId channel, chat::NodeId dest)
+{
+ (void)dest;
+ return service_.sendAdvertise(advertise, channel);
+}
+
bool TeamController::onJoinTeam(const team::proto::TeamJoinRequest& join_request,
chat::ChannelId channel, chat::NodeId dest)
{
@@ -63,6 +76,75 @@ bool TeamController::onConfirmJoin(const team::proto::TeamJoinConfirm& confirm,
return ok;
}
+bool TeamController::onJoinDecision(const team::proto::TeamJoinDecision& decision,
+ chat::ChannelId channel, chat::NodeId dest)
+{
+ return service_.sendJoinDecision(decision, channel, dest);
+}
+
+bool TeamController::onKick(const team::proto::TeamKick& kick,
+ chat::ChannelId channel, chat::NodeId dest)
+{
+ return service_.sendKick(kick, channel, dest);
+}
+
+bool TeamController::onTransferLeader(const team::proto::TeamTransferLeader& transfer,
+ chat::ChannelId channel, chat::NodeId dest)
+{
+ return service_.sendTransferLeader(transfer, channel, dest);
+}
+
+bool TeamController::onKeyDist(const team::proto::TeamKeyDist& msg,
+ chat::ChannelId channel, chat::NodeId dest)
+{
+ return service_.sendKeyDist(msg, channel, dest);
+}
+
+bool TeamController::onKeyDistPlain(const team::proto::TeamKeyDist& msg,
+ chat::ChannelId channel, chat::NodeId dest)
+{
+ return service_.sendKeyDistPlain(msg, channel, dest);
+}
+
+bool TeamController::onStatus(const team::proto::TeamStatus& status,
+ chat::ChannelId channel, chat::NodeId dest)
+{
+ return service_.sendStatus(status, channel, dest);
+}
+
+bool TeamController::onStatusPlain(const team::proto::TeamStatus& status,
+ chat::ChannelId channel, chat::NodeId dest)
+{
+ return service_.sendStatusPlain(status, channel, dest);
+}
+
+bool TeamController::onPosition(const std::vector& payload,
+ chat::ChannelId channel)
+{
+ return service_.sendPosition(payload, channel);
+}
+
+bool TeamController::onChat(const team::proto::TeamChatMessage& msg,
+ chat::ChannelId channel)
+{
+ return service_.sendChat(msg, channel);
+}
+
+bool TeamController::requestNodeInfo(chat::NodeId dest, bool want_response)
+{
+ return service_.requestNodeInfo(dest, want_response);
+}
+
+bool TeamController::startPkiVerification(chat::NodeId dest)
+{
+ return service_.startPkiVerification(dest);
+}
+
+bool TeamController::submitPkiNumber(chat::NodeId dest, uint64_t nonce, uint32_t number)
+{
+ return service_.submitPkiNumber(dest, nonce, number);
+}
+
void TeamController::resetUiState()
{
state_ = TeamUiState::Idle;
diff --git a/src/team/usecase/team_controller.h b/src/team/usecase/team_controller.h
index 6e45fc20..2a828422 100644
--- a/src/team/usecase/team_controller.h
+++ b/src/team/usecase/team_controller.h
@@ -22,15 +22,41 @@ class TeamController
void setKeys(const TeamKeys& keys);
void clearKeys();
+ bool setKeysFromPsk(const TeamId& team_id, uint32_t key_id,
+ const uint8_t* psk, size_t psk_len);
bool onCreateTeam(const team::proto::TeamAdvertise& advertise,
chat::ChannelId channel);
+ bool onAdvertise(const team::proto::TeamAdvertise& advertise,
+ chat::ChannelId channel, chat::NodeId dest = 0);
bool onJoinTeam(const team::proto::TeamJoinRequest& join_request,
chat::ChannelId channel, chat::NodeId dest = 0);
bool onAcceptJoin(const team::proto::TeamJoinAccept& accept,
chat::ChannelId channel, chat::NodeId dest);
bool onConfirmJoin(const team::proto::TeamJoinConfirm& confirm,
chat::ChannelId channel, chat::NodeId dest = 0);
+ bool onJoinDecision(const team::proto::TeamJoinDecision& decision,
+ chat::ChannelId channel, chat::NodeId dest);
+ bool onKick(const team::proto::TeamKick& kick,
+ chat::ChannelId channel, chat::NodeId dest = 0);
+ bool onTransferLeader(const team::proto::TeamTransferLeader& transfer,
+ chat::ChannelId channel, chat::NodeId dest = 0);
+ bool onKeyDist(const team::proto::TeamKeyDist& msg,
+ chat::ChannelId channel, chat::NodeId dest);
+ bool onKeyDistPlain(const team::proto::TeamKeyDist& msg,
+ chat::ChannelId channel, chat::NodeId dest);
+ bool onStatus(const team::proto::TeamStatus& status,
+ chat::ChannelId channel, chat::NodeId dest = 0);
+ bool onStatusPlain(const team::proto::TeamStatus& status,
+ chat::ChannelId channel, chat::NodeId dest = 0);
+ bool onPosition(const std::vector& payload,
+ chat::ChannelId channel);
+ bool onChat(const team::proto::TeamChatMessage& msg,
+ chat::ChannelId channel);
+ bool requestNodeInfo(chat::NodeId dest, bool want_response);
+ bool startPkiVerification(chat::NodeId dest);
+ bool submitPkiNumber(chat::NodeId dest, uint64_t nonce, uint32_t number);
+ TeamService::SendError getLastSendError() const { return service_.getLastSendError(); }
TeamUiState getState() const { return state_; }
void resetUiState();
diff --git a/src/team/usecase/team_service.cpp b/src/team/usecase/team_service.cpp
index 1feb36d1..fd6018a1 100644
--- a/src/team/usecase/team_service.cpp
+++ b/src/team/usecase/team_service.cpp
@@ -5,12 +5,257 @@
#include "../protocol/team_portnum.h"
#include "../protocol/team_wire.h"
#include
+#include
namespace team
{
namespace
{
+#define TEAM_LOG_ENABLE 1
+#if TEAM_LOG_ENABLE
+#define TEAM_LOG(...) Serial.printf(__VA_ARGS__)
+#else
+#define TEAM_LOG(...)
+#endif
+
+std::string toHex(const uint8_t* data, size_t len, size_t max_len = 64)
+{
+ if (!data || len == 0)
+ {
+ return {};
+ }
+ size_t capped = (len > max_len) ? max_len : len;
+ static const char* kHex = "0123456789ABCDEF";
+ std::string out;
+ out.reserve(capped * 2);
+ for (size_t i = 0; i < capped; ++i)
+ {
+ uint8_t b = data[i];
+ out.push_back(kHex[b >> 4]);
+ out.push_back(kHex[b & 0x0F]);
+ }
+ if (capped < len)
+ {
+ out.append("..");
+ }
+ return out;
+}
+
+template
+std::string hexFromArray(const std::array& data)
+{
+ return toHex(data.data(), data.size(), data.size());
+}
+
+const char* mgmtTypeName(team::proto::TeamMgmtType type)
+{
+ switch (type)
+ {
+ case team::proto::TeamMgmtType::Advertise:
+ return "Advertise";
+ case team::proto::TeamMgmtType::JoinRequest:
+ return "JoinRequest";
+ case team::proto::TeamMgmtType::JoinAccept:
+ return "JoinAccept";
+ case team::proto::TeamMgmtType::JoinConfirm:
+ return "JoinConfirm";
+ case team::proto::TeamMgmtType::Status:
+ return "Status";
+ case team::proto::TeamMgmtType::Rotate:
+ return "Rotate";
+ case team::proto::TeamMgmtType::Leave:
+ return "Leave";
+ case team::proto::TeamMgmtType::Disband:
+ return "Disband";
+ case team::proto::TeamMgmtType::JoinDecision:
+ return "JoinDecision";
+ case team::proto::TeamMgmtType::Kick:
+ return "Kick";
+ case team::proto::TeamMgmtType::TransferLeader:
+ return "TransferLeader";
+ case team::proto::TeamMgmtType::KeyDist:
+ return "KeyDist";
+ default:
+ return "Unknown";
+ }
+}
+
+const char* teamPortName(uint32_t portnum)
+{
+ switch (portnum)
+ {
+ case team::proto::TEAM_MGMT_APP:
+ return "TEAM_MGMT";
+ case team::proto::TEAM_POSITION_APP:
+ return "TEAM_POS";
+ case team::proto::TEAM_WAYPOINT_APP:
+ return "TEAM_WP";
+ case team::proto::TEAM_CHAT_APP:
+ return "TEAM_CHAT";
+ default:
+ return "TEAM_OTHER";
+ }
+}
+
+const char* teamErrorName(team::TeamProtocolError err)
+{
+ switch (err)
+ {
+ case team::TeamProtocolError::DecryptFail:
+ return "DecryptFail";
+ case team::TeamProtocolError::DecodeFail:
+ return "DecodeFail";
+ case team::TeamProtocolError::KeyMismatch:
+ return "KeyMismatch";
+ case team::TeamProtocolError::UnknownVersion:
+ return "UnknownVersion";
+ default:
+ return "UnknownError";
+ }
+}
+
+void logTeamEncrypted(const char* dir,
+ const chat::MeshIncomingData& data,
+ const team::proto::TeamEncrypted& envelope,
+ const std::vector* plain,
+ const std::vector* wire,
+ const char* result)
+{
+ std::string team_id_hex = hexFromArray(envelope.team_id);
+ std::string nonce_hex = hexFromArray(envelope.nonce);
+ std::string cipher_hex = toHex(envelope.ciphertext.data(),
+ envelope.ciphertext.size(),
+ envelope.ciphertext.size());
+ TEAM_LOG("[TEAM] %s %s %s ver=%u flags=0x%02X key_id=%lu team_id=%s nonce=%s cipher_len=%u cipher_hex=%s\n",
+ dir,
+ teamPortName(data.portnum),
+ result ? result : "result",
+ envelope.version,
+ envelope.aad_flags,
+ static_cast(envelope.key_id),
+ team_id_hex.c_str(),
+ nonce_hex.c_str(),
+ static_cast(envelope.ciphertext.size()),
+ cipher_hex.c_str());
+ if (plain)
+ {
+ std::string plain_hex = toHex(plain->data(), plain->size(), plain->size());
+ TEAM_LOG("[TEAM] %s %s plain_len=%u plain_hex=%s\n",
+ dir,
+ teamPortName(data.portnum),
+ static_cast(plain->size()),
+ plain_hex.c_str());
+ }
+ if (wire)
+ {
+ std::string wire_hex = toHex(wire->data(), wire->size(), wire->size());
+ TEAM_LOG("[TEAM] %s %s wire_len=%u wire_hex=%s\n",
+ dir,
+ teamPortName(data.portnum),
+ static_cast(wire->size()),
+ wire_hex.c_str());
+ }
+}
+
+void logTeamAdvertise(const team::proto::TeamAdvertise& msg, const char* dir)
+{
+ TEAM_LOG("[TEAM] %s Advertise team_id=%s has_join_hint=%u join_hint=0x%08lX has_channel_index=%u channel_index=%u has_expires_at=%u expires_at=%llu nonce=%llu\n",
+ dir,
+ hexFromArray(msg.team_id).c_str(),
+ msg.has_join_hint ? 1 : 0,
+ static_cast(msg.join_hint),
+ msg.has_channel_index ? 1 : 0,
+ static_cast(msg.channel_index),
+ msg.has_expires_at ? 1 : 0,
+ static_cast(msg.expires_at),
+ static_cast(msg.nonce));
+}
+
+void logTeamJoinRequest(const team::proto::TeamJoinRequest& msg, const char* dir)
+{
+ std::string pub_hex = toHex(msg.member_pub.data(), msg.member_pub_len, msg.member_pub_len);
+ TEAM_LOG("[TEAM] %s JoinRequest team_id=%s has_pub=%u pub_len=%u pub_hex=%s has_cap=%u cap=0x%08lX nonce=%llu\n",
+ dir,
+ hexFromArray(msg.team_id).c_str(),
+ msg.has_member_pub ? 1 : 0,
+ static_cast(msg.member_pub_len),
+ pub_hex.c_str(),
+ msg.has_capabilities ? 1 : 0,
+ static_cast(msg.capabilities),
+ static_cast(msg.nonce));
+}
+
+void logTeamJoinAccept(const team::proto::TeamJoinAccept& msg, const char* dir)
+{
+ std::string psk_hex = toHex(msg.channel_psk.data(), msg.channel_psk_len, msg.channel_psk_len);
+ TEAM_LOG("[TEAM] %s JoinAccept has_team_id=%u team_id=%s channel_index=%u psk_len=%u psk_hex=%s key_id=%lu params_has=%u pos_ms=%lu precision=%u flags=0x%08lX\n",
+ dir,
+ msg.has_team_id ? 1 : 0,
+ hexFromArray(msg.team_id).c_str(),
+ static_cast(msg.channel_index),
+ static_cast(msg.channel_psk_len),
+ psk_hex.c_str(),
+ static_cast(msg.key_id),
+ msg.params.has_params ? 1 : 0,
+ static_cast(msg.params.position_interval_ms),
+ static_cast(msg.params.precision_level),
+ static_cast(msg.params.flags));
+}
+
+void logTeamJoinConfirm(const team::proto::TeamJoinConfirm& msg, const char* dir)
+{
+ TEAM_LOG("[TEAM] %s JoinConfirm ok=%u has_cap=%u cap=0x%08lX has_battery=%u battery=%u\n",
+ dir,
+ msg.ok ? 1 : 0,
+ msg.has_capabilities ? 1 : 0,
+ static_cast(msg.capabilities),
+ msg.has_battery ? 1 : 0,
+ static_cast(msg.battery));
+}
+
+void logTeamJoinDecision(const team::proto::TeamJoinDecision& msg, const char* dir)
+{
+ TEAM_LOG("[TEAM] %s JoinDecision accept=%u has_reason=%u reason=%lu\n",
+ dir,
+ msg.accept ? 1 : 0,
+ msg.has_reason ? 1 : 0,
+ static_cast(msg.reason));
+}
+
+void logTeamKick(const team::proto::TeamKick& msg, const char* dir)
+{
+ TEAM_LOG("[TEAM] %s Kick target=%08lX\n", dir, static_cast(msg.target));
+}
+
+void logTeamTransferLeader(const team::proto::TeamTransferLeader& msg, const char* dir)
+{
+ TEAM_LOG("[TEAM] %s TransferLeader target=%08lX\n", dir, static_cast(msg.target));
+}
+
+void logTeamKeyDist(const team::proto::TeamKeyDist& msg, const char* dir)
+{
+ std::string psk_hex = toHex(msg.channel_psk.data(), msg.channel_psk_len, msg.channel_psk_len);
+ TEAM_LOG("[TEAM] %s KeyDist team_id=%s key_id=%lu psk_len=%u psk_hex=%s\n",
+ dir,
+ hexFromArray(msg.team_id).c_str(),
+ static_cast(msg.key_id),
+ static_cast(msg.channel_psk_len),
+ psk_hex.c_str());
+}
+
+void logTeamStatus(const team::proto::TeamStatus& msg, const char* dir)
+{
+ TEAM_LOG("[TEAM] %s Status key_id=%lu member_hash=%s params_has=%u pos_ms=%lu precision=%u flags=0x%08lX\n",
+ dir,
+ static_cast(msg.key_id),
+ hexFromArray(msg.member_list_hash).c_str(),
+ msg.params.has_params ? 1 : 0,
+ static_cast(msg.params.position_interval_ms),
+ static_cast(msg.params.precision_level),
+ static_cast(msg.params.flags));
+}
+
std::vector buildAad(const team::proto::TeamEncrypted& envelope)
{
std::vector aad;
@@ -81,6 +326,43 @@ void TeamService::clearKeys()
keys_ = TeamKeys{};
}
+bool TeamService::setKeysFromPsk(const TeamId& team_id, uint32_t key_id,
+ const uint8_t* psk, size_t psk_len)
+{
+ if (!psk || psk_len == 0)
+ {
+ return false;
+ }
+
+ TeamKeys keys{};
+ keys.team_id = team_id;
+ keys.key_id = key_id;
+
+ if (!crypto_.deriveKey(psk, psk_len, "team_mgmt",
+ keys.mgmt_key.data(), keys.mgmt_key.size()))
+ {
+ return false;
+ }
+ if (!crypto_.deriveKey(psk, psk_len, "team_pos",
+ keys.pos_key.data(), keys.pos_key.size()))
+ {
+ return false;
+ }
+ if (!crypto_.deriveKey(psk, psk_len, "team_wp",
+ keys.wp_key.data(), keys.wp_key.size()))
+ {
+ return false;
+ }
+ if (!crypto_.deriveKey(psk, psk_len, "team_chat",
+ keys.chat_key.data(), keys.chat_key.size()))
+ {
+ return false;
+ }
+ keys.valid = true;
+ keys_ = keys;
+ return true;
+}
+
void TeamService::processIncoming()
{
chat::MeshIncomingData data;
@@ -88,6 +370,12 @@ void TeamService::processIncoming()
{
if (data.portnum == team::proto::TEAM_MGMT_APP)
{
+ std::string rx_raw_hex = toHex(data.payload.data(), data.payload.size(), data.payload.size());
+ TEAM_LOG("[TEAM] RX TEAM_MGMT raw from=%08lX len=%u hex=%s\n",
+ static_cast(data.from),
+ static_cast(data.payload.size()),
+ rx_raw_hex.c_str());
+
team::proto::TeamEncrypted envelope;
std::vector plain;
bool decoded_encrypted =
@@ -100,18 +388,34 @@ void TeamService::processIncoming()
if (decoded_encrypted)
{
+ logTeamEncrypted("RX", data, envelope, &plain, nullptr, "decrypt-ok");
if (!team::proto::decodeTeamMgmtMessage(
plain.data(), plain.size(),
&version, &type, payload))
{
+ std::string plain_hex = toHex(plain.data(), plain.size(), plain.size());
+ TEAM_LOG("[TEAM] RX TEAM_MGMT decode fail (encrypted) len=%u hex=%s\n",
+ static_cast(plain.size()),
+ plain_hex.c_str());
emitError(data, TeamProtocolError::DecodeFail, &envelope);
continue;
}
if (version != team::proto::kTeamMgmtVersion)
{
+ std::string plain_hex = toHex(plain.data(), plain.size(), plain.size());
+ TEAM_LOG("[TEAM] RX TEAM_MGMT bad version (encrypted) ver=%u len=%u hex=%s\n",
+ static_cast(version),
+ static_cast(plain.size()),
+ plain_hex.c_str());
emitError(data, TeamProtocolError::UnknownVersion, &envelope);
continue;
}
+ std::string payload_hex = toHex(payload.data(), payload.size(), payload.size());
+ TEAM_LOG("[TEAM] RX TEAM_MGMT encrypted ver=%u type=%s payload_len=%u payload_hex=%s\n",
+ static_cast(version),
+ mgmtTypeName(type),
+ static_cast(payload.size()),
+ payload_hex.c_str());
}
else
{
@@ -119,12 +423,25 @@ void TeamService::processIncoming()
data.payload.data(), data.payload.size(),
&version, &type, payload))
{
+ TEAM_LOG("[TEAM] RX TEAM_MGMT plain decode fail len=%u hex=%s\n",
+ static_cast(data.payload.size()),
+ rx_raw_hex.c_str());
continue;
}
if (version != team::proto::kTeamMgmtVersion)
{
+ TEAM_LOG("[TEAM] RX TEAM_MGMT plain bad version ver=%u len=%u hex=%s\n",
+ static_cast(version),
+ static_cast(data.payload.size()),
+ rx_raw_hex.c_str());
continue;
}
+ std::string payload_hex = toHex(payload.data(), payload.size(), payload.size());
+ TEAM_LOG("[TEAM] RX TEAM_MGMT plain ver=%u type=%s payload_len=%u payload_hex=%s\n",
+ static_cast(version),
+ mgmtTypeName(type),
+ static_cast(payload.size()),
+ payload_hex.c_str());
}
switch (type)
@@ -138,6 +455,7 @@ void TeamService::processIncoming()
decoded_encrypted ? &envelope : nullptr);
break;
}
+ logTeamAdvertise(msg, "RX");
TeamAdvertiseEvent event{makeContext(data, msg.team_id), msg};
sink_.onTeamAdvertise(event);
break;
@@ -151,16 +469,13 @@ void TeamService::processIncoming()
decoded_encrypted ? &envelope : nullptr);
break;
}
+ logTeamJoinRequest(msg, "RX");
TeamJoinRequestEvent event{makeContext(data, msg.team_id), msg};
sink_.onTeamJoinRequest(event);
break;
}
case team::proto::TeamMgmtType::JoinAccept:
{
- if (!decoded_encrypted)
- {
- break;
- }
team::proto::TeamJoinAccept msg;
if (!team::proto::decodeTeamJoinAccept(payload.data(), payload.size(), &msg))
{
@@ -168,8 +483,20 @@ void TeamService::processIncoming()
decoded_encrypted ? &envelope : nullptr);
break;
}
- TeamJoinAcceptEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg};
+ logTeamJoinAccept(msg, "RX");
+ team::TeamEventContext ctx = makeContext(data, decoded_encrypted ? &envelope : nullptr);
+ if (msg.has_team_id)
+ {
+ ctx.team_id = msg.team_id;
+ }
+ TeamJoinAcceptEvent event{ctx, msg};
sink_.onTeamJoinAccept(event);
+
+ if (msg.channel_psk_len > 0 && msg.has_team_id && msg.key_id != 0)
+ {
+ setKeysFromPsk(msg.team_id, msg.key_id,
+ msg.channel_psk.data(), msg.channel_psk_len);
+ }
break;
}
case team::proto::TeamMgmtType::JoinConfirm:
@@ -187,14 +514,81 @@ void TeamService::processIncoming()
}
TeamJoinConfirmEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg};
sink_.onTeamJoinConfirm(event);
+ logTeamJoinConfirm(msg, "RX");
break;
}
- case team::proto::TeamMgmtType::Status:
+ case team::proto::TeamMgmtType::JoinDecision:
+ {
+ team::proto::TeamJoinDecision msg;
+ if (!team::proto::decodeTeamJoinDecision(payload.data(), payload.size(), &msg))
+ {
+ emitError(data, TeamProtocolError::DecodeFail,
+ decoded_encrypted ? &envelope : nullptr);
+ break;
+ }
+ logTeamJoinDecision(msg, "RX");
+ TeamJoinDecisionEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg};
+ sink_.onTeamJoinDecision(event);
+ break;
+ }
+ case team::proto::TeamMgmtType::Kick:
{
if (!decoded_encrypted)
{
break;
}
+ team::proto::TeamKick msg;
+ if (!team::proto::decodeTeamKick(payload.data(), payload.size(), &msg))
+ {
+ emitError(data, TeamProtocolError::DecodeFail,
+ decoded_encrypted ? &envelope : nullptr);
+ break;
+ }
+ logTeamKick(msg, "RX");
+ TeamKickEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg};
+ sink_.onTeamKick(event);
+ break;
+ }
+ case team::proto::TeamMgmtType::TransferLeader:
+ {
+ if (!decoded_encrypted)
+ {
+ break;
+ }
+ team::proto::TeamTransferLeader msg;
+ if (!team::proto::decodeTeamTransferLeader(payload.data(), payload.size(), &msg))
+ {
+ emitError(data, TeamProtocolError::DecodeFail,
+ decoded_encrypted ? &envelope : nullptr);
+ break;
+ }
+ logTeamTransferLeader(msg, "RX");
+ TeamTransferLeaderEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg};
+ sink_.onTeamTransferLeader(event);
+ break;
+ }
+ case team::proto::TeamMgmtType::KeyDist:
+ {
+ team::proto::TeamKeyDist msg;
+ if (!team::proto::decodeTeamKeyDist(payload.data(), payload.size(), &msg))
+ {
+ emitError(data, TeamProtocolError::DecodeFail,
+ decoded_encrypted ? &envelope : nullptr);
+ break;
+ }
+ logTeamKeyDist(msg, "RX");
+ TeamKeyDistEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg};
+ sink_.onTeamKeyDist(event);
+
+ if (msg.channel_psk_len > 0 && msg.key_id != 0)
+ {
+ setKeysFromPsk(msg.team_id, msg.key_id,
+ msg.channel_psk.data(), msg.channel_psk_len);
+ }
+ break;
+ }
+ case team::proto::TeamMgmtType::Status:
+ {
team::proto::TeamStatus msg;
if (!team::proto::decodeTeamStatus(payload.data(), payload.size(), &msg))
{
@@ -202,6 +596,7 @@ void TeamService::processIncoming()
decoded_encrypted ? &envelope : nullptr);
break;
}
+ logTeamStatus(msg, "RX");
TeamStatusEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg};
sink_.onTeamStatus(event);
break;
@@ -212,6 +607,11 @@ void TeamService::processIncoming()
}
else if (data.portnum == team::proto::TEAM_POSITION_APP)
{
+ std::string rx_raw_hex = toHex(data.payload.data(), data.payload.size(), data.payload.size());
+ TEAM_LOG("[TEAM] RX TEAM_POS raw from=%08lX len=%u hex=%s\n",
+ static_cast(data.from),
+ static_cast(data.payload.size()),
+ rx_raw_hex.c_str());
team::proto::TeamEncrypted envelope;
std::vector plain;
if (!decodeEncryptedPayload(data, keys_.pos_key.data(), keys_.pos_key.size(),
@@ -220,11 +620,17 @@ void TeamService::processIncoming()
continue;
}
+ logTeamEncrypted("RX", data, envelope, &plain, nullptr, "decrypt-ok");
TeamPositionEvent event{makeContext(data, &envelope), plain};
sink_.onTeamPosition(event);
}
else if (data.portnum == team::proto::TEAM_WAYPOINT_APP)
{
+ std::string rx_raw_hex = toHex(data.payload.data(), data.payload.size(), data.payload.size());
+ TEAM_LOG("[TEAM] RX TEAM_WP raw from=%08lX len=%u hex=%s\n",
+ static_cast(data.from),
+ static_cast