diff --git a/CHANGELOG.md b/CHANGELOG.md index 58295b93..c784de28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,20 @@ versions. - Meshtastic encryption (AES/PSK) and broader protocol compatibility / Meshtastic 加密(AES/PSK)与更完整协议兼容 - Real LoRa field tests, performance, and power tuning / 实机 LoRa 测试、性能与功耗调优 +## [0.1.2-alpha] - 2026-01-21 +### Added / 新增 +- Meshtastic routing ACK decoding with reason mapping, ack timeouts, and richer RX/TX diagnostics / Meshtastic 路由 ACK 解码,增加原因映射、ACK 超时和更详细的收发日志 +- PKI peer key persistence and per-node PKI backoff after NO_CHANNEL/UNKNOWN_PUBKEY / PKI 公钥持久化,对 NO_CHANNEL/UNKNOWN_PUBKEY 加入单节点 PKI 退避 +- Time helpers for chat timestamps / 聊天时间戳辅助工具 + +### Changed / 变更 +- Chat app switching now calls exit callbacks and clears active app on menu return / Chat 应用切换支持 exit 回调,返回菜单清空 active app +- Contacts compose flow supports broadcast channels and avoids UI tree mismatch when chat container exists / Contacts 撰写支持广播频道,在 chat container 存在时避免 UI 树错位 +- UI timestamp display applies stored timezone offset / UI 显示时间应用已存储时区偏移 + +### Fixed / 修复 +- Chat screen cleanup safety on repeated enter/exit to prevent black-screen stacking / Chat 重复进出时更安全清理,避免黑屏堆叠 + ## [0.1.1-alpha] - 2026-01-20 ### Added / 新增 - Mesh protocol factory with explicit Meshtastic/MeshCore selection / Mesh 协议工厂,支持显式选择 Meshtastic/MeshCore diff --git a/README.md b/README.md index a63f8524..73937777 100644 Binary files a/README.md and b/README.md differ diff --git a/README_CN.md b/README_CN.md index a2f5f08f..9738c9da 100644 Binary files a/README_CN.md and b/README_CN.md differ diff --git a/docs/TEAM.md b/docs/TEAM.md new file mode 100644 index 00000000..9d0aa528 --- /dev/null +++ b/docs/TEAM.md @@ -0,0 +1,2845 @@ +# 你这个 TEAM 业务到底在做什么(我现在能总结到“可写 TeamEvent”的程度) + +## 1) Team 的定义(产品级一句话) + +**Team = 临时形成、目标驱动、随时解散的户外行动单元。** + +它不是“群聊”,也不是“普通位置共享”,而是让一小群人在无网络环境下持续回答三件事: + +1. **我们是否还在一起**(队伍是否分散/掉队/走偏) +2. **接下来要做什么**(集合、等待、求助等“行动共识”) +3. **有没有人出问题**(静止、失联、电量低、SOS) + +--- + +## 2) Team 的核心能力(你已经确定的) + +### A. 临时组队(Create / Join / Disband) + +* Leader 一键建队 +* 成员通过短码/QR 拿到 **TeamKey** 加入 +* Team 可以随时解散(解散即密钥销毁) + +**这一套你已经做成状态机:Create → Active → Rotate → Disband** + +--- + +### B. 强隐私位置共享(你的差异化关键) + +你明确在意: + +> **位置广播出去后,只有 Team 成员能解析成功;即便 Team channel 配置外传,也看不懂。** + +因此你采用两层保护: + +* L1:Meshtastic **Channel** 加密(CH-ENC) +* L2:应用层 **E2EE**(TeamKey 派生的 PosKey/WpKey,TeamEncrypted Envelope) + +并且你倾向新增自己的 PortNum: + +* `TEAM_POSITION_APP` +* `TEAM_WAYPOINT_APP` +* 管理类 `TEAM_MGMT_APP` + +--- + +### C. Team 内的“行动指令”(Preset / Command) + +你提出的差异化: + +* 向队友发 **预设指令**(集合、等待、求助…) +* 指令携带必要参数(例如集合地点 waypoint) +* 指令是“事件”,不靠打字聊天 + +> 注意:你前面没要求我把它做成完整协议,但你已经明确这是 Team 的重要业务能力。 + +--- + +### D. Team 生命周期复盘(你现在要做的) + +你希望**整个组队过程可复盘**,包括生命周期中所有事件落地。 + +所以我们引入: + +* SD 日志(append-only) +* LogRecord(AEAD + CRC) +* snapshot 加速(可选) +* LogKey(独立于 TeamKey,可配置是否保留) + +--- + +## 3) Team 生命周期里“会发生哪些事件”(这直接决定 TeamEvent) + +为了写 `TeamEvent` 的 protobuf,你需要先承认: +**TeamEvent 不等于“所有无线包”**,而是**业务层发生的事情**,能用于复盘与解释行为。 + +你当前业务可以归纳为 5 类事件: + +### 1) 生命周期事件(Lifecycle) + +* Team 被创建(Leader 本地) +* 广播可发现(ADVERTISE) +* 有人请求加入(JOIN_REQUEST) +* Leader 接受并下发配置(JOIN_ACCEPT) +* 成员确认入队(JOIN_CONFIRM) +* Team 状态同步(TEAM_STATUS) +* 密钥轮换(KEY_ROTATE) +* 解散/离开(DISBAND / LEAVE) + +这些事件用于回答: + +* “队伍什么时候开始/结束” +* “谁在队里、什么时候加入/离开” +* “什么时候换过密钥、为什么换” + +--- + +### 2) 位置与轨迹事件(Telemetry) + +* 收到某成员位置(POSITION_RX) +* 收到航点/集合点(WAYPOINT_RX) + +这些事件用于复盘: + +* 人在什么时候到过哪里 +* 队伍是否分散、何时重新聚拢 + +> 位置事件会有降采样策略,否则日志爆炸。 + +--- + +### 3) 指令事件(Command) + +* 集合指令(含地点 waypoint) +* 等待/暂停 +* 求助(SOS,含位置/严重级别) + +这些事件用于复盘: + +* “谁下达过行动共识” +* “队伍如何响应” + +--- + +### 4) 连接与可见性事件(Presence/Health) + +这类你在产品目标里明确需要,但我们之前没有把它细化成协议字段,现在总结出来: + +* 某成员“暂时不可见”(超时没收到位置) +* 恢复可见 +* 电量低(如果你决定上报) +* 静止过久(如果你决定做本机推断事件) + +这些事件用于复盘: + +* “为什么当时地图上看不到人” +* “掉队是不是因为设备没电/失联” + +> 这类事件可以是“本机推断”,不一定来自无线包。 + +--- + +### 5) 安全/异常事件(Security/Diagnostics) + +你要做强隐私承诺,就必须能复盘“失败原因”,否则用户只会觉得 bug。 + +* 解密失败(DECRYPT_FAIL) +* key_id 不匹配(KEY_MISMATCH) +* 重放丢弃(REPLAY_DROP) +* 包格式错误(DECODE_FAIL) + +这些事件用于复盘: + +* “当时收到了包但没显示”的原因 + +--- + +## 4) 我现在对你 TEAM 业务的总结边界(我确定 vs 我不会擅自假设) + +### 我已经可以确定的(来自你明确提出并反复确认的) + +* Team 是临时行动单元,有 Create/Join/Disband +* Team 内位置共享 + waypoint +* 强隐私:channel 隔离 + 应用层 E2EE +* 需要记录整个生命周期事件并可复盘 +* 你愿意新增自定义 PortNum(TEAM_*_APP) + +### 我不会擅自补的(你没冻结) + +* 指令种类到底有哪些、每种指令带哪些参数(我能提建议,但需要你确认) +* 成员健康字段是否要上报(电量、能力、角色) +* 是否要求“全队一致复盘”(你目前更像本地复盘/Leader复盘) + +--- + +# Team 建队 / 加入 报文交互流程(强隐私版本) + +## 参与方 + +``` +L = Leader / 建队者设备 +M = Member / 队员设备 +O = Others / 旁观设备(非 Team 成员) +``` + +## 信道 + +``` +CH0 = 默认公共频道(Primary Channel) +CHT = Team 私有频道(新建 / 分配的 channel index) +``` + +## 安全层 + +``` +CH-ENC = Meshtastic 按 Channel 的链路层加密 +E2EE = TeamKey 派生的应用层端到端加密 + (Envelope: TeamEncrypted) +``` + +--- + +## 总览:时序线图(ASCII) + +``` +时间 ↓ + +L (Leader) M (Member) O (Others) +──────────────────────────────────────────────────────────────────────── + +(0) 本地建队(无报文) +L: 生成 TeamKey +L: TeamId = Trunc(Hash(TeamKey)) +L: 选择 / 创建 CHT +L: ChannelPSK = KDF(TeamKey, "channel-psk") +L: MgmtKey = KDF(TeamKey, "mgmt") +L: PosKey = KDF(TeamKey, "pos") +L: WpKey = KDF(TeamKey, "wp") + +──────────────────────────────────────────────────────────────────────── + +(1) Team 可被“发现”(不泄密) +CH0 +L ─────── TEAM_ADVERTISE ───────▶ * + { team_id, join_hint?, channel_index?, nonce/ts } + + O: 只能知道“附近有一个 Team” + 无法获得任何密钥或位置 + +──────────────────────────────────────────────────────────────────────── + +(2) 队员请求加入(无密钥) +CH0 +M ───── TEAM_JOIN_REQUEST ─────▶ L + { team_id, member_pub?, nonce/ts } + +──────────────────────────────────────────────────────────────────────── + +(3) Leader 接受加入并下发 Team 配置 +CH0 +L ───── TEAM_JOIN_ACCEPT ─────▶ M + { + team_id, + payload = E2EE(MgmtKey, { + channel_index = CHT, + channel_psk = ChannelPSK, + key_id = current_key_id, + team_params? (freq / precision / timeout) + }), + nonce/ts + } + +注: +- 此时 M **必须已经通过 UI 获得 TeamKey** + (短码 / QR / 近距离方式) +- 否则无法解密 MgmtKey / payload + +──────────────────────────────────────────────────────────────────────── + +(4) 队员本地切换到 Team +M: 保存 TeamKey +M: 派生 MgmtKey / PosKey / WpKey +M: 保存 CHT + ChannelPSK +M: 切换到 CHT + +──────────────────────────────────────────────────────────────────────── + +(5) 入队确认(推荐) +CHT (CH-ENC) +M ───── TEAM_JOIN_CONFIRM ─────▶ L + { + team_id, + payload = E2EE(MgmtKey, { + ok, + capabilities?, + battery? + }), + nonce/seq + } + +──────────────────────────────────────────────────────────────────────── + +(6) Team 状态同步(可选) +CHT (CH-ENC) +L ─────── TEAM_STATUS ───────▶ Team + { + team_id, + payload = E2EE(MgmtKey, { + member_list_hash, + key_id, + team_params + }), + nonce/seq + } + +──────────────────────────────────────────────────────────────────────── + +(7) Team 内位置共享(核心) +CHT (CH-ENC) +Each Member ── TEAM_POSITION_APP ──▶ Team + payload = TeamEncrypted { + team_id, + key_id, + nonce, + ciphertext = AEAD( + PosKey, + protobuf(meshtastic_Position), + aad = header + ) + } + +Team 外成员: +- 即便拿到 CHT + ChannelPSK +- 只能解 CH-ENC +- 无 TeamKey / PosKey → 无法解密位置 +``` + +--- + +## 关键语义说明(非常重要) + +### 1️⃣ 为什么 **TEAM_ADVERTISE** 不包含密钥 + +这是**“发现”而不是“加入”**: + +* 让人知道「附近有一个 Team」 +* 但不授予任何能力 +* 防止被动监听就获得权限 + +--- + +### 2️⃣ 为什么 **TeamKey 不通过无线明文传播** + +这是你“强隐私承诺”的核心: + +* TeamKey **只通过 UI 侧渠道**传播(短码 / QR / 近距) +* 无线中只传播 **TeamKey 派生物** +* 即使频道配置外传,也无法逆推出 TeamKey + +--- + +### 3️⃣ 为什么要分 **ChannelPSK / MgmtKey / PosKey** + +这是“可演化设计”: + +* ChannelPSK:只管“这包是谁能收到” +* MgmtKey:管成员管理 / 参数 +* PosKey / WpKey:管位置 / 航点 + +👉 未来你可以做到: + +* 换位置密钥 ≠ 踢人 +* 换管理密钥 ≠ 影响历史数据 + +--- + +### 4️⃣ 为什么要 **TEAM_JOIN_CONFIRM** + +不是为了安全,是为了**产品可感知性**: + +* Leader 知道谁真的成功入队 +* UI 能显示“成员已就绪” +* 后续 Team 状态才可信 + +--- + +## MVP 与增强项划分(帮你控复杂度) + +### MVP 必须(第一版就该有) + +* (0) 本地建队 + TeamKey 派生 +* (1) TEAM_ADVERTISE +* UI 侧 TeamKey 输入 / 扫码 +* (3) TEAM_JOIN_ACCEPT +* (4) 切换到 CHT +* (7) TEAM_POSITION_APP(E2EE) + +### 可选增强(后续再加) + +* member_pub + 更强密钥交换 +* TEAM_JOIN_CONFIRM +* TEAM_STATUS +* key rotation / key_id 更新 +* 防重放窗口优化 + +--- + +# Team Management Protocol + +## Message Definitions (Protobuf-Level) + +--- + +## 公共约定(适用于所有 Team 报文) + +### Team Identity + +* **TeamKey** + + * 高熵随机生成 + * 仅通过 UI 侧(短码 / QR / 近距)分发 + * **MUST NOT** 通过无线明文传播 + +* **team_id** + + * 定义:`Trunc(Hash(TeamKey))` + * 用途:标识 Team,而不泄露 TeamKey + * **MUST** 在所有 Team 相关报文中出现 + +--- + +### 加密层级 + +* **CH-ENC**:Meshtastic Channel 加密 +* **E2EE**:应用层 Team 加密(AEAD) + +--- + +### TeamEncrypted Envelope(通用) + +> 用于所有 E2EE payload(位置 / 管理 / 状态) + +| 字段 | 类型 | 级别 | 说明 | +| ---------- | ------ | ---- | ------------ | +| version | uint32 | MUST | Envelope 版本 | +| team_id | bytes | MUST | Team 标识 | +| key_id | uint32 | MUST | 当前密钥版本 | +| nonce | bytes | MUST | 每包唯一,用于 AEAD | +| ciphertext | bytes | MUST | AEAD 加密后的数据 | +| aad_flags | uint32 | MAY | AAD 类型标识 | + +--- + +## 1. TEAM_ADVERTISE + +**用途**:让 Team 可被“发现”,不授予任何能力 + +* **信道**:CH0 +* **加密**:可明文或 CH0 加密 +* **PortNum**:`TEAM_MGMT_APP` + +### 字段表 + +| 字段 | 类型 | 级别 | 说明 | +| ------------- | ------ | ---- | ---------------- | +| team_id | bytes | MUST | Team 标识 | +| join_hint | uint32 | MAY | 加入提示(需确认 / 有效期等) | +| channel_index | uint32 | MAY | Team Channel 索引 | +| expires_at | uint64 | MAY | 广告过期时间 | +| nonce | bytes | MUST | 防重放 | + +### 语义规则 + +* **MUST NOT** 包含任何密钥材料 +* **MUST NOT** 泄露位置或成员信息 +* **MAY** 周期性广播 + +--- + +## 2. TEAM_JOIN_REQUEST + +**用途**:队员请求加入 Team(无密钥) + +* **信道**:CH0 +* **PortNum**:`TEAM_MGMT_APP` + +### 字段表 + +| 字段 | 类型 | 级别 | 说明 | +| ------------ | ------ | ---- | ----------- | +| team_id | bytes | MUST | 目标 Team | +| member_pub | bytes | MAY | 公钥(增强密钥交换用) | +| capabilities | uint32 | MAY | 能力标识 | +| nonce | bytes | MUST | 防重放 | + +### 语义规则 + +* **MUST NOT** 包含 TeamKey +* **MAY** 被 Leader 拒绝(无响应) + +--- + +## 3. TEAM_JOIN_ACCEPT + +**用途**:Leader 接受成员并下发 Team 配置 + +* **信道**:CH0 +* **PortNum**:`TEAM_MGMT_APP` +* **加密**:E2EE(MgmtKey) + +### Payload(解密后结构) + +| 字段 | 类型 | 级别 | 说明 | +| ------------- | ---------- | ---- | ------------ | +| channel_index | uint32 | MUST | Team Channel | +| channel_psk | bytes | MUST | Channel PSK | +| key_id | uint32 | MUST | 当前密钥版本 | +| team_params | TeamParams | MAY | 行为参数 | + +### 外层字段表 + +| 字段 | 类型 | 级别 | 说明 | +| ------- | ------------- | ---- | ------- | +| team_id | bytes | MUST | Team 标识 | +| payload | TeamEncrypted | MUST | E2EE 封装 | +| nonce | bytes | MUST | 防重放 | + +### 语义规则 + +* 接收方 **MUST** 已拥有 TeamKey +* 解密失败 **MUST** 丢弃 +* **MUST NOT** 在 TeamKey 未确认前应用配置 + +--- + +## 4. TEAM_JOIN_CONFIRM + +**用途**:成员确认已成功入队 + +* **信道**:CHT +* **PortNum**:`TEAM_MGMT_APP` +* **加密**:CH-ENC + E2EE(MgmtKey) + +### Payload(解密后) + +| 字段 | 类型 | 级别 | 说明 | +| ------------ | ------ | ---- | ----- | +| ok | bool | MUST | 加入成功 | +| capabilities | uint32 | MAY | 能力声明 | +| battery | uint32 | MAY | 电量百分比 | + +--- + +## 5. TEAM_STATUS + +**用途**:同步 Team 当前状态 + +* **信道**:CHT +* **PortNum**:`TEAM_MGMT_APP` +* **加密**:CH-ENC + E2EE(MgmtKey) + +### Payload(解密后) + +| 字段 | 类型 | 级别 | 说明 | +| ---------------- | ---------- | ---- | ------ | +| member_list_hash | bytes | MUST | 成员列表摘要 | +| key_id | uint32 | MUST | 当前密钥版本 | +| team_params | TeamParams | MAY | 当前参数 | + +--- + +## 6. TEAM_POSITION_APP + +**用途**:Team 内位置共享(强隐私) + +* **信道**:CHT +* **PortNum**:`TEAM_POSITION_APP` +* **加密**:CH-ENC + E2EE(PosKey) + +### Payload(E2EE 明文结构) + +| 字段 | 类型 | 级别 | 说明 | +| --------------- | ------------------- | ---- | ---- | +| position | meshtastic_Position | MUST | 位置数据 | +| precision_level | uint32 | MAY | 精度等级 | +| timestamp | uint64 | MUST | 位置时间 | + +### 外层(TeamEncrypted) + +| 字段 | 类型 | 级别 | 说明 | +| ---------- | ------ | ---- | ------- | +| team_id | bytes | MUST | Team 标识 | +| key_id | uint32 | MUST | 位置密钥版本 | +| nonce | bytes | MUST | 每包唯一 | +| ciphertext | bytes | MUST | AEAD 密文 | + +--- + +## 7. TEAM_WAYPOINT_APP + +**用途**:Team 内航点共享 + +* 与 `TEAM_POSITION_APP` 完全一致 +* 使用 `WpKey` 解密 +* 明文结构为 `meshtastic_Waypoint` + +--- + +## 8. MUST / SHOULD / MAY 总结(Conformance Checklist) + +### MUST + +* TeamKey **不得**通过无线明文传播 +* 所有 Team 数据 **必须**包含 team_id +* 所有 E2EE payload **必须**使用 AEAD +* nonce **必须** 唯一(防重放) +* 解散 Team 时 **必须** 丢弃所有派生密钥 + +### SHOULD + +* 使用独立的 PosKey / WpKey +* 支持 key_id 轮换 +* JOIN_CONFIRM / TEAM_STATUS 用于 UI 同步 + +### MAY + +* 使用 member_pub 做更强密钥交换 +* 支持权限分级 +* 统一 payload 长度以降低关联风险 + +--- + +## 协议一句话定义(可写在文件头) + +> **Team 协议定义了一种临时、目标驱动的加密协作单元,其安全边界以 TeamKey 为核心,而非无线信道。** + +--- + + +## 目标:你要对用户承诺什么 + +**隐私承诺(你可以写进 PRD / README):** + +1. **只有 Team 成员能解读 Team 位置与航点**(即便别人拿到频道配置/PSK,也无法解密) +2. **Team 解散后内容不可恢复**(密钥丢弃) +3. **外人无法从包内容推断成员位置**(密文 + 随机化 + 防重放) + +--- + +好,这一步我们把 **Team 的一生**画清楚。 +不是“功能流程图”,而是**状态机**——**什么时候处在什么状态、因为什么事件发生跃迁、跃迁时必须做什么**。 + +下面这张 **ASCII 状态机图**,你可以直接放进协议文档或 PRD。 + +--- + +# Team 生命周期状态机(State Machine) + +``` + ┌─────────────────────────┐ + │ (Idle) │ + │ No Active Team │ + └───────────┬─────────────┘ + │ + Create Team │ (UI action) + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CREATE │ +│ │ +│ Entry actions (local only): │ +│ - Generate TeamKey (high entropy) │ +│ - Derive team_id = Trunc(Hash(TeamKey)) │ +│ - Allocate / select Team Channel (CHT) │ +│ - Derive keys: │ +│ ChannelPSK = KDF(TeamKey, "channel-psk") │ +│ MgmtKey = KDF(TeamKey, "mgmt") │ +│ PosKey = KDF(TeamKey, "pos") │ +│ WpKey = KDF(TeamKey, "wp") │ +│ │ +│ Exit condition: │ +│ - Leader confirms creation │ +└───────────┬─────────────────────────────────────────────────┘ + │ + │ TEAM_ADVERTISE (CH0) + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ACTIVE │ +│ │ +│ Team exists and is operational │ +│ │ +│ Ongoing behaviors: │ +│ - Broadcast TEAM_ADVERTISE (CH0, optional) │ +│ - Accept TEAM_JOIN_REQUEST (CH0) │ +│ - Send TEAM_JOIN_ACCEPT (CH0, E2EE MgmtKey) │ +│ - Operate on Team Channel (CHT): │ +│ * TEAM_JOIN_CONFIRM │ +│ * TEAM_STATUS │ +│ * TEAM_POSITION_APP (E2EE PosKey) │ +│ * TEAM_WAYPOINT_APP (E2EE WpKey) │ +│ │ +│ Valid transitions: │ +│ - Rotate keys │ +│ - Disband team │ +└───────────┬───────────────────────┬─────────────────────────┘ + │ │ + │ Rotate Team Key │ Disband Team + │ (Leader action) │ (Leader or policy) + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ROTATE │ +│ │ +│ Purpose: │ +│ - Mitigate key leakage │ +│ - Exclude lost / untrusted members │ +│ │ +│ Entry actions (Leader): │ +│ - Increment key_id │ +│ - Generate new subkeys: │ +│ MgmtKey', PosKey', WpKey' │ +│ │ +│ Protocol actions: │ +│ - Broadcast key update via TEAM_STATUS (E2EE MgmtKey) │ +│ - Optionally re-issue TEAM_JOIN_ACCEPT to valid members │ +│ │ +│ Member behavior: │ +│ - Switch to new key_id │ +│ - Drop packets with old key_id │ +│ │ +│ Exit condition: │ +│ - All active members synced OR timeout │ +└───────────┬─────────────────────────────────────────────────┘ + │ + │ Rotation complete + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ACTIVE │ +│ (with new key_id) │ +└─────────────────────────────────────────────────────────────┘ + + + (from ACTIVE or ROTATE) + │ + │ Disband Team + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DISBAND │ +│ │ +│ Entry actions (Leader): │ +│ - Broadcast TEAM_END / final TEAM_STATUS (optional) │ +│ │ +│ Mandatory local actions (ALL members): │ +│ - Immediately discard: │ +│ * TeamKey │ +│ * ChannelPSK │ +│ * MgmtKey / PosKey / WpKey │ +│ - Stop all Team broadcasts │ +│ - Leave Team Channel (CHT) │ +│ │ +│ Exit condition: │ +│ - Return to Idle │ +└───────────┬─────────────────────────────────────────────────┘ + │ + │ + ▼ +┌─────────────────────────┐ +│ (Idle) │ +│ No Active Team │ +└─────────────────────────┘ +``` + +--- + +## 状态语义说明(这比图本身更重要) + +### 1️⃣ Idle + +* 设备**不持有任何 TeamKey** +* 不广播 Team 位置 +* UI 显示「未建队 / 未加入」 + +--- + +### 2️⃣ Create + +* **纯本地状态** +* 没有任何无线安全风险 +* 所有“信任根”在这一刻生成 + +> 这是 **Team 安全边界的诞生点** + +--- + +### 3️⃣ Active + +* Team 的 **主要生命周期** +* 所有 Team 功能(地图、位置、指令)都只在此状态合法 +* **TeamKey 是唯一信任来源** + +--- + +### 4️⃣ Rotate + +* **不是异常状态,是主动防御状态** +* 用来兑现你对用户的这句话: + + > “即使配置外传,也能立刻止损” + +关键点: + +* Rotate **不改变 team_id** +* 只改变 `key_id` + 派生密钥 +* 旧密钥立即失效 + +--- + +### 5️⃣ Disband + +* **安全终止态** +* 所有密钥必须被销毁 +* 不允许“恢复”“回看”“重连” + +> Team 结束 ≠ 暂停 +> Team 结束 = **密码学意义上的死亡** + +--- + +## 你可以直接写进协议的 MUST 规则(来自状态机) + +* **MUST** 仅在 `ACTIVE` / `ROTATE` 状态发送 Team 数据 +* **MUST** 在 `DISBAND` 进入时销毁全部 Team 密钥 +* **MUST** 丢弃 key_id 不匹配的数据包 +* **MUST NOT** 从 `DISBAND` 回到 `ACTIVE`(必须重新建队) + +--- + +## 为什么这张状态机“站得住” + +* **产品层**:每个状态都能映射到一个清晰 UI +* **协议层**:每个跃迁都有明确报文或本地动作 +* **安全层**:密钥生命周期与状态生命周期完全一致 + +--- + +非常好,这一步其实是**把“用户点了什么”和“协议里发生了什么”一一对齐**。 +下面我会**严格按 UI 行为 → 状态变化 → 报文触发**来写,不引入新概念、不模糊。 + +你可以把这份内容理解为: + +> **UI 是状态机的控制面板,协议报文是状态跃迁的副作用。** + +--- + +# UI 行为 ↔ Team 状态机 ↔ 报文触发 精确映射 + +--- + +## 一、UI 总览:Team 菜单的三种“用户可感知状态” + +从 UI 角度,Team 其实只有三种状态(和协议状态一一对应): + +| UI 显示状态 | 协议状态 | 用户能做什么 | +| -------- | ------- | ------------ | +| 未建队 | Idle | 建队 / 加入 | +| Team 进行中 | Active | 查看 / 分享 / 解散 | +| 正在解散 | Disband | 无(系统动作) | + +> Rotate 是 **Active 内的子动作**,通常是 Leader-only,不是普通用户主流程。 + +--- + +## 二、UI 行为 1:点击「建队(Create Team)」 + +### UI 行为 + +``` +菜单 → Team → [Create Team] +``` + +--- + +### 对应状态机跃迁 + +``` +Idle ──(Create Team)──▶ Create ──▶ Active +``` + +--- + +### 报文与动作映射(逐步) + +#### Step 1:用户点击「Create Team」(无报文) + +**UI 行为** + +* 用户确认“我要建一个 Team” + +**本地动作(MUST)** + +* 生成 `TeamKey` +* 计算 `team_id = Trunc(Hash(TeamKey))` +* 分配 / 选择 `CHT` +* 派生密钥: + + * `ChannelPSK` + * `MgmtKey` + * `PosKey` + * `WpKey` + +📌 **此时没有任何无线报文** +📌 **这是 Team 安全边界的起点** + +--- + +#### Step 2:UI 显示「Team 已创建 / 分享码」 + +**UI 行为** + +* 显示 Team Code / QR +* 显示「成员数:1」 + +**协议动作** + +* **进入 Active 状态** +* 开始周期性(或一次性)广播: + +``` +CH0 → TEAM_ADVERTISE +``` + +```text +TEAM_ADVERTISE { + team_id, + join_hint?, + channel_index?, + nonce +} +``` + +📌 这是 **UI 显示“Team 已存在”** 的唯一协议依据 + +--- + +#### Step 3:UI 返回 Team 主页面(Active) + +**UI 行为** + +* 显示: + + * Team Active + * Members: 1 + * [View Team Map] + * [Share Team] + * [Disband Team] + +**协议行为** + +* 允许并处理以下报文: + + * `TEAM_JOIN_REQUEST` + * `TEAM_JOIN_ACCEPT` + * `TEAM_POSITION_APP` + +--- + +## 三、UI 行为 2:他人加入 Team(Join Team) + +### UI 行为(队员) + +``` +Team → Join Team → 输入短码 / 扫码 → Confirm +``` + +--- + +### 报文与状态映射 + +#### Step 1:队员输入短码 / 扫码(无报文) + +**UI 行为** + +* 用户确认加入 + +**本地动作(MUST)** + +* 解析 / 获得 `TeamKey` +* 预计算 `team_id` +* 派生 `MgmtKey / PosKey / WpKey` + +📌 **没有 TeamKey,后续任何 JOIN 报文都没有意义** + +--- + +#### Step 2:队员请求加入 + +``` +CH0 → TEAM_JOIN_REQUEST +``` + +```text +TEAM_JOIN_REQUEST { + team_id, + nonce +} +``` + +--- + +#### Step 3:Leader UI 出现「加入请求」 + +**UI 行为(Leader)** + +* 显示“新成员请求加入” +* [Accept] / [Ignore] + +📌 UI 事件 **直接绑定** 下一条报文是否发送 + +--- + +#### Step 4:Leader 点击「Accept」 + +``` +CH0 → TEAM_JOIN_ACCEPT +``` + +```text +TEAM_JOIN_ACCEPT { + team_id, + payload = E2EE(MgmtKey, { + channel_index, + channel_psk, + key_id, + team_params? + }), + nonce +} +``` + +--- + +#### Step 5:队员 UI 切换为「Team Active」 + +**UI 行为** + +* 显示 Team 主页面 +* Members ≥ 2 + +**本地动作** + +* 保存 CHT + ChannelPSK +* 切换到 CHT + +**协议动作(推荐)** + +``` +CHT → TEAM_JOIN_CONFIRM +``` + +--- + +## 四、UI 行为 3:Team 进行中(无按钮,但有持续行为) + +### UI 行为 + +``` +Team Active → View Team Map +``` + +--- + +### 协议行为(持续) + +* 每个成员周期性发送: + +``` +CHT → TEAM_POSITION_APP +``` + +* UI 地图刷新 **只依赖成功解密后的 payload** +* 解密失败的数据包: + + * MUST 被丢弃 + * MUST NOT 更新 UI + +--- + +## 五、UI 行为 4:点击「解散 Team(Disband Team)」【Leader】 + +### UI 行为 + +``` +Team Active → [Disband Team] → Confirm +``` + +--- + +### 对应状态机跃迁 + +``` +Active ──(Disband)──▶ Disband ──▶ Idle +``` + +--- + +### 报文与动作映射 + +#### Step 1:Leader 点击「Disband」 + +**UI 行为** + +* 显示强确认弹窗 + +--- + +#### Step 2:Leader 确认解散 + +**协议动作(可选但推荐)** + +``` +CHT → TEAM_STATUS / TEAM_END +``` + +```text +TEAM_STATUS { + team_id, + payload = E2EE(MgmtKey, { + state = DISBAND + }) +} +``` + +📌 这是 **通知性质**,不是安全关键 +📌 即使收不到,也不影响安全性 + +--- + +#### Step 3:所有设备进入 Disband + +**本地动作(MUST,所有成员)** + +* 立即销毁: + + * `TeamKey` + * `ChannelPSK` + * `MgmtKey / PosKey / WpKey` +* 停止所有 Team 报文 +* 离开 CHT + +--- + +#### Step 4:UI 回到未建队状态 + +``` +Team → You are not in a Team +[Create Team] [Join Team] +``` + +📌 **UI 是密钥销毁完成的“可视确认”** + +--- + +## 六、UI 行为 5:普通成员「离开 Team(Leave)」【非解散】 + +> 这是 **UI 行为 ≠ 协议状态变化** 的典型例子 + +### UI 行为 + +``` +Team Active → [Leave Team] +``` + +--- + +### 本地动作(MUST) + +* 销毁所有 TeamKey / 派生密钥 +* 离开 CHT + +### 协议动作(MAY) + +``` +CHT → TEAM_LEAVE_NOTICE +``` + +> 仅用于 UI 同步,不影响 Team 存续 + +📌 **Team 状态仍为 Active(对其他人)** + +--- + +## 七、关键设计原则(可以写进 PRD) + +1. **UI 行为永远先于报文** + 报文只是“用户意图的副作用” + +2. **没有 TeamKey → UI 不允许进入 Active** + 安全失败必须表现为“操作不可达” + +3. **解散是密钥行为,不是通知行为** + 报文失败 ≠ 解散失败 + +--- + +## 一句话总结这套映射关系 + +> **UI 决定状态, +> 状态决定密钥, +> 密钥决定哪些报文“有意义”。** + +--- + +### 下一步(非常自然) + +如果你愿意,我可以下一步帮你直接做其中之一: + +1. **把这套映射写成“UI × Protocol 对照表”**(方便测试与 PR) +2. **帮你设计 Team 页面在不同状态下的 LVGL 组件树** +3. **把这些 UI 行为转成自动化测试用例描述** + +你现在这一步,已经是在做**系统级产品设计**了,而不是“功能拼装”。 + + +好,这一步我们**完全站在“嵌入式 UI 工程”的角度**来做。 +目标不是“好看”,而是 **状态清晰、组件稳定、不会演化失控**。 + +下面我给你的是 **LVGL 组件树(Component Tree)**,不是布局草图; +你可以直接据此拆 `.cpp / .h`,或者对照你现在的 UI 架构来重构。 + +--- + +# Team 页面 · LVGL 组件树设计 + +(按 **状态** 明确分支,而不是条件 if/else 堆在一起) + +--- + +## 总体设计原则(先给你“架构约束”) + +### 1️⃣ Team 页面 = 状态容器(不是功能容器) + +```text +TeamPage +└── TeamStateContainer // 只负责:Idle / Active / Disband +``` + +* **永远只挂载一个子树** +* 状态切换 = 删除旧子树 + 创建新子树 +* 不在同一树里 hide/show + +👉 这是防止 LVGL 页面“变成屎山”的关键 + +--- + +### 2️⃣ 页面分三棵互斥的树 + +```text +TeamState = { IDLE, ACTIVE, DISBAND } +``` + +* `IdleTree` +* `ActiveTree` +* `DisbandTree`(瞬态,通常很短) + +--- + +## 一、Team 页面根节点(所有状态共用) + +```text +TeamPage (lv_obj_t*) +├── TopBar +│ ├── BackButton +│ └── TitleLabel ("Team") +│ +└── TeamStateContainer (lv_obj_t*) + └── +``` + +说明: + +* `TopBar` 永远存在 +* `TeamStateContainer` 是**唯一可变区域** + +--- + +## 二、状态 1:未建队(IDLE) + +### 用户心智 + +> “我现在不在任何 Team 里。” + +--- + +### 组件树:IdleTree + +```text +IdleTree (lv_obj_t*) +├── CenterContainer +│ ├── StatusIcon // 简单图标:空队伍 +│ ├── StatusLabel // "You are not in a Team" +│ └── Spacer +│ +├── ActionContainer +│ ├── CreateTeamButton // 主按钮 +│ └── JoinTeamButton // 次按钮 +``` + +--- + +### 组件职责说明 + +* `CreateTeamButton` + + * 点击 → **触发 UI 行为:Create Team** + * 后续状态切换由控制器决定 + +* `JoinTeamButton` + + * 跳转到 Join 流程页(输入码 / 扫码) + * 成功后切换到 `ACTIVE` + +📌 **IdleTree 不关心任何协议或密钥** + +--- + +## 三、状态 2:Team 进行中(ACTIVE) + +这是 **停留时间最长、最重要的状态**。 + +### 用户心智 + +> “我在一个 Team 里,我能看到状态,也能做决定。” + +--- + +### 组件树:ActiveTree(完整) + +```text +ActiveTree (lv_obj_t*) +├── SummaryCard +│ ├── TeamStatusLabel // "Team Active" +│ ├── MemberCountLabel // "Members: N" +│ └── PrivacyBadge // "Strong Privacy / E2EE" +│ +├── Divider +│ +├── ActionList +│ ├── ViewMapItem // → Team Map +│ ├── ShareTeamItem // → 显示 Team Code / QR +│ └── (optional) RotateKeyItem // Leader only +│ +├── Divider +│ +└── DangerZone + └── DisbandButton / LeaveButton +``` + +--- + +### 关键:Leader / Member 的分支(不是不同页面) + +#### 普通成员(Member) + +```text +DangerZone +└── LeaveTeamButton +``` + +#### Leader(建队者) + +```text +DangerZone +└── DisbandTeamButton +``` + +> ⚠️ **不要在 UI 层暴露“Leader”概念** +> 只通过按钮存在与否体现 + +--- + +### 各组件的“协议触发点” + +#### `ViewMapItem` + +* 只做一件事:跳转到 Team Map 页面 +* Team Map 页面只消费: + + * `TEAM_POSITION_APP` + * `TEAM_WAYPOINT_APP` + +#### `ShareTeamItem` + +* 进入 **Share Subpage** +* 只读数据: + + * Team Code + * QR +* ❌ 不触发任何报文 + +#### `RotateKeyItem`(Leader-only) + +* 点击 → 进入确认页 +* 确认后: + + * 触发 **Rotate 状态** + * 发送 `TEAM_STATUS (new key_id)` + +#### `DisbandTeamButton` + +* 点击 → 进入 **DisbandTree** + +--- + +## 四、状态 3:解散中(DISBAND) + +这是一个 **瞬态状态**,但非常重要。 + +### 用户心智 + +> “这件事是不可逆的。” + +--- + +### 组件树:DisbandTree + +```text +DisbandTree (lv_obj_t*) +├── WarningIcon +├── WarningLabel +│ └── "Disbanding Team..." +│ +└── ProgressIndicator +``` + +--- + +### 行为绑定(关键) + +* **进入 DisbandTree 的同时** + + * 本地立即销毁: + + * TeamKey + * ChannelPSK + * 所有派生密钥 + * 停止 Team 报文 + +* **不等待网络结果** + +* UI 停留时间极短(例如 300–500ms) + +--- + +### Disband 完成后 + +```text +DISBAND → IDLE +``` + +* 销毁 `DisbandTree` +* 创建 `IdleTree` + +--- + +## 五、状态切换 = 组件树切换(推荐实现模式) + +### 推荐控制逻辑(伪代码) + +```cpp +void TeamPage::setState(TeamState newState) { + if (currentTree) { + lv_obj_del(currentTree); + currentTree = nullptr; + } + + switch (newState) { + case IDLE: + currentTree = buildIdleTree(container); + break; + case ACTIVE: + currentTree = buildActiveTree(container); + break; + case DISBAND: + currentTree = buildDisbandTree(container); + break; + } +} +``` + +📌 **没有 show/hide** +📌 **没有 if (isLeader) scattered everywhere** +📌 **所有状态边界在一个地方** + +--- + +## 六、这套组件树设计的三个“硬优势” + +1. **状态与 UI 完全同构** + 状态机 = UI 结构,不是注释 + +2. **协议失败不会污染 UI** + + * 解散不依赖报文成功 + * UI 只反映本地密钥状态 + +3. **未来扩展不会炸** + + * 加 Team Settings + * 加 Key Rotation + * 加 Debug Info + 都只是在 `ActiveTree` 里加一支 + +---你这个“拉回”的动作非常对。 +我们现在**明确只做一件事**: + +> **围绕「建队状态机 + 密钥交换流程」,哪些数据必须持久化?哪些绝不能持久化?哪些是状态性的?** + +不谈 UI、不谈地图、不谈未来扩展,只谈**数据与生命周期**。 + +--- + +# Team 建队 & 密钥交换阶段 + +## 数据持久化整理(严格按状态机) + +--- + +## 一、先给结论(总表) + +> **不是所有参与建队的东西都应该持久化** +> 持久化的唯一判断标准是: +> 👉 *“设备重启后,是否还能继续处于同一个 Team 状态”* + +### 数据分三类: + +| 类别 | 是否持久化 | 原因 | +| ---------- | ----- | ------------- | +| Team 身份与密钥 | ✅ 必须 | 否则重启=自动退队 | +| Channel 配置 | ✅ 必须 | 否则无法接收 Team 包 | +| 协议瞬态状态 | ❌ 不应 | 可通过报文重建 | +| UI / 交互状态 | ❌ 不应 | 完全是表现层 | + +--- + +## 二、按状态机逐状态分析 + +--- + +## 状态 0:Idle(未建队) + +### 持久化数据 + +**无。** + +```text +(no Team-related persistent data) +``` + +### 原则 + +* Idle 状态下 **设备上不存在任何 Team 痕迹** +* 这是安全与心理边界的起点 + +--- + +## 状态 1:Create(本地建队,尚未 Active) + +> 这是一个**非常短暂的状态** +> 只存在于一次 UI 操作中 + +### 是否需要持久化? + +❌ **不需要** + +### 原因 + +* Create 是一次**原子操作** +* 要么成功进入 Active +* 要么失败回到 Idle + +👉 **Create 阶段失败 = 不留下任何痕迹** + +--- + +## 状态 2:Active(Team 正在进行) + +这是**唯一需要持久化的核心状态**。 + +--- + +### 1️⃣ Team 身份类(必须持久化) + +```text +TeamIdentity +├── team_id (bytes) +├── team_role (enum: LEADER / MEMBER) +├── key_id (uint32) +``` + +#### 说明 + +* `team_id`:所有 Team 数据的索引键 +* `team_role`:影响 UI 与允许的动作 +* `key_id`:当前密钥版本(用于解密判断) + +✅ **MUST persist** + +--- + +### 2️⃣ Team 密钥类(必须持久化,安全存储) + +```text +TeamSecrets +├── team_key (bytes) // 根密钥 +├── mgmt_key (bytes) +├── pos_key (bytes) +├── wp_key (bytes) +``` + +#### 说明 + +* 派生密钥 **可以重算**,但不建议 +* 重算依赖 KDF 版本一致,风险高 +* 实践中直接存派生结果更稳 + +✅ **MUST persist** +⚠️ **必须使用安全存储(NVS / encrypted storage)** + +--- + +### 3️⃣ Channel 配置类(必须持久化) + +```text +TeamChannelConfig +├── channel_index (uint8 / uint32) +├── channel_psk (bytes) +``` + +#### 说明 + +* 重启后必须能重新监听 CHT +* 否则会“逻辑上还在 Team,物理上听不到” + +✅ **MUST persist** + +--- + +### 4️⃣ Team 行为参数(建议持久化) + +```text +TeamParams +├── position_interval_ms +├── position_precision_level +├── advertise_enabled +├── join_policy +``` + +#### 说明 + +* 这些参数**由 Leader 决定** +* 成员端只是执行 + +🟡 **SHOULD persist** +(不存也能跑,但体验不一致) + +--- + +### 5️⃣ 不应持久化的东西(非常重要) + +#### ❌ 协议瞬态状态 + +```text +DO NOT persist: +- 已发送但未确认的 JOIN_REQUEST +- JOIN_CONFIRM 状态 +- 最近一次 TEAM_STATUS 内容 +- 成员列表(可通过广播重建) +``` + +原因: + +* 都是 **软状态** +* 断电/重启后自然恢复 + +--- + +#### ❌ UI 状态 + +```text +DO NOT persist: +- 当前是否在 Team 页面 +- 是否展开某个子菜单 +- 是否刚刚显示过 QR +``` + +--- + +## 状态 3:Rotate(密钥轮换中) + +> Rotate 是 **Active 的子状态** + +### 持久化策略:**两阶段提交** + +#### 临时状态(不持久化) + +```text +RotateContext (RAM only) +├── new_key_id +├── new_mgmt_key +├── new_pos_key +├── new_wp_key +``` + +#### 切换成功瞬间(持久化) + +```text +TeamSecrets (overwrite) +├── key_id = new_key_id +├── mgmt_key = new_mgmt_key +├── pos_key = new_pos_key +├── wp_key = new_wp_key +``` + +📌 **不要持久化“正在轮换中”** +📌 **要么成功覆盖,要么保持旧密钥** + +这是**避免掉电/异常导致“半轮换”**的关键。 + +--- + +## 状态 4:Disband(解散) + +### 行为规则(强制) + +> **Disband = 数据销毁** + +### 必须执行的持久化动作 + +```text +DELETE persistent: +- TeamIdentity +- TeamSecrets +- TeamChannelConfig +- TeamParams +``` + +### 不允许的行为 + +* ❌ 不允许标记“已解散但可恢复” +* ❌ 不允许保留 team_id +* ❌ 不允许缓存旧密钥 + +📌 **Disband 是密码学意义上的死亡** + +--- + +## 三、Join 流程中的持久化时机(精确) + +### 成员设备(Member) + +| 时刻 | 是否持久化 | 内容 | +| -------------------- | ----- | -------------------------------- | +| 输入短码 | ❌ | (仅 RAM) | +| 发送 JOIN_REQUEST | ❌ | | +| 收到 JOIN_ACCEPT 并成功解密 | ✅ | TeamIdentity + Secrets + Channel | +| 切换到 CHT 成功 | ✅ | 同上 | +| JOIN_CONFIRM | ❌ | | + +--- + +### 建队者(Leader) + +| 时刻 | 是否持久化 | 内容 | +| ----------- | ----- | ---------- | +| Create Team | ✅ | 全量 Team 数据 | +| 接受新成员 | ❌ | | +| Rotate 成功 | ✅ | 覆盖密钥 | +| Disband | ❌(删除) | | + +--- + +## 四、你可以直接写进协议/实现文档的一句话规则 + +> **只有“定义 Team 身份连续性的数据”才允许持久化; +> 所有报文级、交互级状态都必须是可丢弃的。** + +--- + +## 五、一个非常实用的工程建议(重要) + +**把 Team 持久化做成一个“单一对象”** + +```text +PersistentTeamState +├── identity +├── secrets +├── channel +├── params +``` + +* 有这个对象 → Team = Active +* 没有这个对象 → Team = Idle + +📌 **不要拆散到多个 KV** +📌 **不要用 flag 表示“在不在 Team”** + +--- + +# 1️⃣ 建队流程(Leader) + +## 持久化读写时序图 + +``` +时间 ↓ + +Leader Device +────────────────────────────────────────────────────────────── + +[UI] Click "Create Team" + | + v +📦 RAM: generate TeamKey +📦 RAM: derive team_id, ChannelPSK, MgmtKey, PosKey, WpKey +📦 RAM: select Team Channel (CHT) + + | + | (still NOT persistent) + | + v +[UI] Confirm Create + | + v +💾 PERSIST: write PersistentTeamState + ├── identity.team_id + ├── identity.role = LEADER + ├── identity.key_id = 0 + ├── secrets.team_key + ├── secrets.mgmt_key + ├── secrets.pos_key + ├── secrets.wp_key + ├── channel.channel_index = CHT + ├── channel.channel_psk + └── params (optional) + + | + v +[STATE] Team = ACTIVE + | + v +[PROTO] start TEAM_ADVERTISE (CH0) +[PROTO] accept JOIN_REQUEST +[PROTO] send JOIN_ACCEPT +``` + +### 关键规则(必须遵守) + +* ❌ **在 UI Confirm 之前,绝不写 Flash** +* ✅ **第一次持久化 = Team 正式存在** +* 💡 掉电在 Confirm 之前 → 自动回到 Idle(无残留) + +--- + +# 2️⃣ 加入流程(Member) + +## 持久化读写时序图(最容易出 bug 的地方) + +``` +时间 ↓ + +Member Device +────────────────────────────────────────────────────────────── + +[UI] Click "Join Team" + | + v +[UI] Input Code / Scan QR + | + v +📦 RAM: obtain TeamKey +📦 RAM: derive team_id, MgmtKey, PosKey, WpKey + + | + | (still NOT persistent) + | + v +[PROTO] send TEAM_JOIN_REQUEST (CH0) + | + v +[PROTO] receive TEAM_JOIN_ACCEPT (CH0) + + | + v +[SEC] try decrypt JOIN_ACCEPT payload + | + |-- decryption FAIL --> ❌ abort (NO write) + | + v +📦 RAM: extract channel_index, channel_psk, key_id + + | + | (still NOT persistent) + | + v +[PROTO] switch to Team Channel (CHT) + | + |-- switch FAIL --> ❌ abort (NO write) + | + v +💾 PERSIST: write PersistentTeamState + ├── identity.team_id + ├── identity.role = MEMBER + ├── identity.key_id + ├── secrets.team_key + ├── secrets.mgmt_key + ├── secrets.pos_key + ├── secrets.wp_key + ├── channel.channel_index + ├── channel.channel_psk + └── params (optional) + + | + v +[STATE] Team = ACTIVE + | + v +[PROTO] send TEAM_JOIN_CONFIRM (CHT) +[PROTO] start TEAM_POSITION_APP +``` + +### 关键规则(这是重点) + +* ❌ **收到 JOIN_ACCEPT ≠ 可以写 Flash** +* ❌ **解密成功 ≠ 可以写 Flash** +* ✅ **只有在“成功切换到 CHT”之后才允许持久化** + +> 这是为了防止: +> **“Flash 里记录着 Team,但无线层根本进不了 Team Channel”** + +--- + +# 3️⃣ 解散流程(Disband) + +## 持久化删除时序图(最安全的一条) + +### 3A. Leader 解散 Team + +``` +时间 ↓ + +Leader Device +────────────────────────────────────────────────────────────── + +[UI] Click "Disband Team" + | + v +[UI] Confirm Disband + | + v +❌ DELETE: erase PersistentTeamState + ├── identity + ├── secrets + ├── channel + └── params + + | + v +📦 RAM: clear all Team-related state + | + v +[STATE] Team = IDLE + | + v +[PROTO] (optional) send TEAM_STATUS / TEAM_END +``` + +### 3B. 普通成员离开 Team(Leave) + +``` +时间 ↓ + +Member Device +────────────────────────────────────────────────────────────── + +[UI] Click "Leave Team" + | + v +❌ DELETE: erase PersistentTeamState + | + v +📦 RAM: clear Team state + | + v +[STATE] Team = IDLE + | + v +[PROTO] (optional) send LEAVE_NOTICE +``` + +### 关键规则(必须统一) + +* ❌ **不要先发报文再删数据** +* ❌ **不要等待网络 ACK** +* ✅ **删除持久化 = Team 立刻不存在** + +> 报文是“礼貌”, +> **删除密钥才是“事实”。** + +--- + +# 4️⃣ 旋转密钥(Rotate)补充:持久化原子性 + +``` +Leader Device +────────────────────────────────────────────────────────────── + +[UI] Click "Rotate Key" + | + v +📦 RAM: generate new subkeys +📦 RAM: new_key_id + + | + v +[PROTO] broadcast TEAM_STATUS(new_key_id) + + | + |-- timeout / abort --> ❌ discard RAM only + | + v +💾 PERSIST (ATOMIC overwrite): + ├── identity.key_id = new_key_id + ├── secrets.mgmt_key = new + ├── secrets.pos_key = new + └── secrets.wp_key = new + + | + v +[STATE] Team still ACTIVE (new key) +``` + +### 关键规则 + +* ❌ **不允许“半写入”** +* ❌ **不持久化 Rotate 进行中状态** +* ✅ **要么全换,要么不换** + +--- + +# 5️⃣ 一条可以写进实现规范的铁律 + +> **持久化只发生在“状态跃迁完成之后”, +> 删除只发生在“状态跃迁开始之时”。** + +--- + +# 6️⃣ 最终工程检查清单(你可以直接用) + +* [ ] Create Confirm 前没有任何 Flash 写入 +* [ ] Join 成功切换 CHT 前没有任何 Flash 写入 +* [ ] Disband / Leave 第一动作就是删除持久化 +* [ ] Rotate 只有一个原子写点 +* [ ] PersistentTeamState 是否是“单一对象” + +--- + +# Team 持久化存储设计 + +## NVS / Flash Layout & Wear Strategy + +> 设计目标: +> +> * 掉电安全 +> * 原子更新 +> * 最小擦写 +> * 明确“Team 存在 / 不存在”的判据 + +--- + +## 一、总原则(先立铁律) + +### 1️⃣ Team 持久化 = **单一对象** + +> **Flash 中只有一个“Team 是否存在”的真相源** + +```text +PersistentTeamState +``` + +* 有它 → Team = Active +* 没有它 → Team = Idle + +❌ 不使用多个 flag +❌ 不在不同 namespace 里拆散 + +--- + +### 2️⃣ 写入次数极少,是“生命周期级别”的 + +| 操作 | 写 Flash? | +| ----------- | -------- | +| 建队成功 | ✅ 一次 | +| 成员加入成功 | ✅ 一次 | +| Rotate Key | ✅ 一次 | +| 正常使用(位置/聊天) | ❌ | +| 解散 / 离开 | ✅ 删除 | + +> **不是高频写场景** +> 所以重点是 **正确性 > 极限性能** + +--- + +## 二、推荐存储方式(ESP32 实践) + +### ✅ 推荐:NVS(Non-Volatile Storage) + +原因: + +* 自带 wear leveling +* 支持 blob +* 支持 namespace +* 原子语义清晰 + +> 你现在的需求 **完全不需要自定义 raw flash** + +--- + +## 三、NVS Namespace 设计 + +```text +NVS Namespace: "team" +``` + +**只有这一个 namespace 存 Team。** + +--- + +## 四、Key 布局(极简但完整) + +### 核心 Key 列表 + +| Key | 类型 | 说明 | +| ------------ | ---- | ---------------------- | +| `version` | u32 | 结构版本 | +| `team_state` | blob | 整个 PersistentTeamState | + +> ❗**不要拆成几十个 key** +> 拆了就很难保证一致性与原子性 + +--- + +## 五、PersistentTeamState 结构(Flash 中的唯一真相) + +### 逻辑结构(与你之前分析一致) + +```text +PersistentTeamState +├── header +│ ├── magic +│ ├── version +│ ├── length +│ └── crc32 +│ +├── identity +│ ├── team_id +│ ├── role (LEADER / MEMBER) +│ └── key_id +│ +├── secrets +│ ├── team_key +│ ├── mgmt_key +│ ├── pos_key +│ └── wp_key +│ +├── channel +│ ├── channel_index +│ └── channel_psk +│ +└── params (optional) + ├── position_interval_ms + ├── precision_level + └── flags +``` + +--- + +### Header 设计(非常重要) + +| 字段 | 作用 | +| --------- | ----------- | +| `magic` | 判断是否存在 Team | +| `version` | 结构版本 | +| `length` | 防截断 | +| `crc32` | 掉电 / 半写检测 | + +📌 **header 是你对抗掉电与损坏的最后防线** + +--- + +## 六、写入策略(什么时候写,怎么写) + +### 1️⃣ 写入只发生在“成功跃迁后” + +你前面定的规则,现在变成存储规则: + +| 场景 | 写入内容 | +| --------------- | ----------------------- | +| Create Confirm | 写完整 PersistentTeamState | +| Join 成功切换 CHT | 写完整 PersistentTeamState | +| Rotate 成功 | **整体覆盖写** | +| Disband / Leave | 删除 key | + +--- + +### 2️⃣ 写入方式:整体覆盖(不是增量) + +```text +nvs_set_blob("team", "team_state", &state, sizeof(state)) +nvs_commit() +``` + +* 不修改子字段 +* 不做 patch +* 不维护“正在 rotate”状态 + +📌 **整体写 + CRC = 最稳** + +--- + +## 七、擦写 / 删除策略(关键) + +### 解散 / 离开 Team + +```text +nvs_erase_key("team", "team_state") +nvs_commit() +``` + +* **删除即事实** +* 不保留“历史 Team” +* 不留可恢复路径 + +--- + +### 为什么不用 flag 表示“已解散” + +因为: + +* flag 本身也是状态 +* flag 需要维护一致性 +* flag 会引入“幽灵 Team” + +**不存在 = 最干净** + +--- + +## 八、掉电与损坏恢复策略 + +### 启动时的判断逻辑(唯一入口) + +```text +if (nvs_has_key("team", "team_state")) { + read blob + if (magic ok && crc ok) { + Team = ACTIVE + } else { + erase key + Team = IDLE + } +} else { + Team = IDLE +} +``` + +📌 **任何异常 → 删除 → Idle** + +--- + +## 九、Key Rotation 的原子性保证 + +### 正确做法(你前面已经定了) + +1. 新密钥 **只存在 RAM** +2. 广播 Rotate +3. 同步完成 +4. **一次性覆盖写 PersistentTeamState** + +```text +old_state → new_state +nvs_set_blob(...) +nvs_commit() +``` + +❌ 不写中间态 +❌ 不写 `rotating = true` + +--- + +## 十、Flash 磨损分析(给你信心) + +### 最坏情况估算 + +| 操作 | 次数 | +| ------ | ------- | +| 建队 | 1 | +| Rotate | 假设 20 次 | +| 解散 | 1 | + +**< 50 次写入 / Team 生命周期** + +NVS 默认 wear leveling 足以支撑 **数十万次**。 + +👉 **可以放心** + +--- + +## 十一、一个非常重要的安全建议(强烈) + +### TeamSecrets 存储 + +* 如果平台支持: + + * 使用 **NVS encrypted partition** +* 或: + + * 在写入前,用 device-unique key 再包一层 + +你不需要军工级,只需要: + +> **Flash 被 dump,TeamKey 不可直接读出** + +--- + +## 十二、最终“实现检查清单”(你可以直接用) + +* [ ] Team 只占用一个 NVS key +* [ ] PersistentTeamState 有 magic + crc +* [ ] 所有写入都是整体 blob +* [ ] 解散 = erase key +* [ ] 启动异常 = erase key +* [ ] Rotate 只有一个 commit 点 + +--- + +## 一句话总结这套设计 + +> **Flash 里不保存“过程”, +> 只保存“事实”。** + +--- + +## 1) 复盘的三种级别(先定产品边界) + +你要“整个生命周期所有事件落地”,但落地可以有不同强度: + +### A. 本地复盘(推荐默认) + +* **每台设备只记录自己看到/发出的事件** +* 不需要全员上报 +* 最少协议改动 +* 复盘不一定 100%完整(取决于你当时是否收到包) + +### B. 领队完整复盘(可选增强) + +* Leader 设备尽量收集全队事件(仍然是“尽量”,无线环境无法保证 100%) +* 更接近“行动记录仪” + +### C. 全队一致复盘(最重,不建议一开始做) + +* 需要全队 ACK/补齐/对账 +* 立刻把系统复杂度拉爆 + +> 我建议:**先做 A**,再做 B(Leader 模式),C 暂时不碰。 + +--- + +## 2) 你要记录什么:事件模型(Event Sourcing 风格) + +把 Team 的一生理解为:**事件序列 + 状态重建**。 + +### 事件必须包含的元信息(所有事件通用) + +* `event_id`:唯一(可用 `sender_id + seq`) +* `team_id` +* `ts`:事件发生时间(设备本地时间 + 可选 mesh 时间) +* `sender_id`:节点 ID +* `event_type` +* `payload`:按类型携带字段 +* `key_id`:当时使用的密钥版本(很重要,便于复盘解密) + +### 事件类型建议(覆盖你当前系统) + +**生命周期** + +* `TEAM_CREATED`(Leader 本地) +* `TEAM_ADVERTISE_SENT/RECEIVED` +* `TEAM_JOIN_REQUEST_SENT/RECEIVED` +* `TEAM_JOIN_ACCEPT_SENT/RECEIVED` +* `TEAM_JOIN_CONFIRM_SENT/RECEIVED` +* `TEAM_STATUS_SENT/RECEIVED` +* `TEAM_KEY_ROTATED`(含 old/new key_id) +* `TEAM_DISBANDED` / `TEAM_LEFT` + +**行动** + +* `TEAM_POSITION_RX`(建议只存“采样后的”位置点) +* `TEAM_WAYPOINT_RX` +* `TEAM_COMMAND_RX`(你未来的集合/求助等指令) + +**异常** + +* `DECRYPT_FAIL`(重要:复盘时能解释“为什么当时没显示”) +* `REPLAY_DROP` +* `MEMBER_LOST` / `MEMBER_RECOVERED` + +--- + +## 3) 日志存储位置:Flash vs SD 的现实选择 + +### 如果你有 SD 卡(强烈推荐) + +* 用 SD 文件做**追加写日志**(Flash 磨损问题直接变小) +* 文件系统好用(导出、查看、同步) + +### 只有 Flash(NVS/自定义分区) + +* 也能做,但要用**环形日志(ring buffer)** +* 写放大、磨损要精心控制 +* 不适合存“高频位置点” + +> 结论:**位置轨迹复盘几乎必然要 SD**,否则你必须极度降采样。 + +--- + +## 4) 最关键:复盘与“解散销毁密钥”如何兼容? + +你之前的安全模型是:Disband 立即销毁 TeamKey → 历史不可读。 +而复盘要求:解散后仍能读历史。 + +所以你要引入一个独立的日志密钥: + +### 新增密钥:`LogKey` + +* `LogKey = KDF(TeamKey, "log")` **可以**,但如果 Disband 会销毁 TeamKey,那 LogKey 也丢了。 +* 更合理:**创建 Team 时生成独立随机 LogKey**,并设置“是否保留”的策略。 + +### 两种策略(对应产品开关) + +1. **默认:Disband 销毁 LogKey** + + * 不可复盘 + * 最强隐私承诺(默认建议) + +2. **开启复盘:Disband 保留 LogKey(仅本机)** + + * 日志文件仍然是加密的 + * 只有该设备能复盘 + * 你仍可承诺:**TeamKey 销毁,网络权限消失;但本机保留行动记录** + +这其实很符合户外真实需求: + +* **通信权限**随队伍结束而结束 +* **行动记录**可由个人/领队保留 + +--- + +## 5) 写入策略:怎样既完整又不爆存储? + +### 位置点不要“每包都落盘” + +位置是最高频数据,必须做降采样: + +* 规则一:**按时间采样**(例如每 10s/30s 记录一次) +* 规则二:**按距离采样**(移动超过 20m 才记) +* 规则三:异常优先(SOS/集合时临时升频) + +### 事件日志推荐结构:Append-only + Checkpoint + +为了复盘速度与稳定性: + +* `events.log`:追加写(每条事件一条记录) +* `snapshot.bin`:偶尔写一个“当前 Team 状态快照”(比如每 5 分钟或每 200 条事件) + + * 复盘时:从最近快照开始回放事件,速度快 + +--- + +## 6) 存储格式建议(别纠结,选一个能跑的) + +你已经在 protobuf 体系里,最顺: + +* **protobuf event**(一条 event message) +* 外层封一层 `LogRecord`: + + * `len`(u16/u32) + * `record_type`(event/snapshot) + * `ciphertext` + * `crc32`(可选) + +日志内容建议 **AEAD(LogKey)** 加密,保证: + +* 落地文件被拷走也看不懂 +* 文件被篡改能检测出来 + +--- + +## 7) 复盘的输出是什么样? + +### 最小可用复盘(MVP) + +* 时间线:加入/离开/集合/求助/掉线/恢复 +* 地图回放:按时间推进显示轨迹(不要求动画,先做“按时间跳点”) + +### Leader 复盘增强 + +* 统计:队伍最大拉开距离、成员掉线次数、集合耗时 +* 异常:谁长时间静止、谁电量低、何时丢包严重 + +--- + +## 8) 最重要的落地决策:你要不要“复盘开关”? + +我强烈建议在建队时就决定: + +* `Record mode: Off / Local / Leader` +* `Retention: 1 day / 7 days / manual delete` +* `Privacy: Keep logs encrypted (default)` + +并且在解散时: + +* 如果 record off → 删除日志 + 销毁 LogKey +* 如果 record on → 写入 `TEAM_DISBANDED` 事件 + 关闭记录,但保留 LogKey(本机) + +--- + +## 9) 你问的“整理一下”:需要持久化的新增数据 + +在你之前 `PersistentTeamState` 基础上,加这几项就够: + +**当 Record=ON 时:** + +* `log_enabled`(bool) +* `log_mode`(enum: LOCAL / LEADER) +* `log_key`(bytes)✅(独立于 TeamKey) +* `log_file_id` / `log_path`(如果 SD) +* `log_seq`(用于事件递增编号) +* `last_snapshot_offset`(可选) + +**当 Record=OFF 时:** + +* 不持久化任何 log 相关内容 + +--- + +# 1) SD 文件布局 + +## 1.1 目录结构(按 Team 会话分桶) + +建议每次 Team 生命周期(Create→Disband)生成一个 **session_id**(随机 64-bit 或时间戳+随机),作为一个会话目录。 + +``` +/trail-mate/ + /team_logs/ + /YYYYMMDD/ + /T_/ + meta.json + keys.bin + events.log + snapshot.idx + snapshots.bin +``` + +### 文件职责 + +* `meta.json`(明文、可读) + + * 仅放非敏感信息:创建时间、设备名、版本号、记录模式等 +* `keys.bin`(加密/保护) + + * 存 LogKey 的封装(详见 3) +* `events.log`(核心追加写日志) + + * 事件流(加密记录) +* `snapshot.idx`(小索引,方便快速定位) + + * 每个快照对应 `events.log` 的 offset + `snapshots.bin` 的 offset +* `snapshots.bin`(快照流,追加写) + + * 复盘加速的状态快照(加密记录) + +> **MUST**:`events.log` 与 `snapshots.bin` 都是 *append-only*(只追加、不改写、不截断)。 +> **SHOULD**:每天分目录,方便清理/归档。 + +--- + +# 2) LogRecord 二进制格式(通用记录容器) + +`events.log` 和 `snapshots.bin` 使用同一种 record 容器:`LogRecord`。 +每条记录都是:**固定头 + 变长密文 + 可选 CRC**。 + +## 2.1 LogRecord 结构(小端序 Little-endian) + +``` ++-------------------------------+ +| magic (4) "TLOG" | +| version (1) = 1 | +| type (1) 1=EVENT 2=SNAP| +| flags (2) | +| header_len (2) bytes | +| body_len (4) bytes | +| session_id (8) | +| seq (8) monotonic | +| ts_ms (8) unix ms | +| key_id (4) | +| nonce (12) | +| aad_crc32 (4) | (optional but recommended) +|-------------------------------- +| AAD (header extension...) | (header_len - fixed_header bytes) +|-------------------------------- +| ciphertext (body_len bytes) | AEAD output (may include tag) +|-------------------------------- +| trailer_crc32 (4) optional | ++-------------------------------+ +``` + +### 固定头字段说明 + +* `magic`:用于快速扫描与恢复 +* `version`:格式版本 +* `type`:EVENT / SNAP +* `flags`:位标志(是否有 trailer_crc、是否压缩等) +* `header_len`:头部总长度(方便未来扩展 AAD) +* `body_len`:密文长度 +* `session_id`:会话绑定(防止混写) +* `seq`:单调递增序号(掉电恢复、去重、对齐) +* `ts_ms`:记录写入时间(或事件发生时间,看你定义) +* `key_id`:LogKey 轮换版本(可选但强烈推荐) +* `nonce(12)`:AEAD nonce(每条记录唯一) +* `aad_crc32`:对 AAD 计算 CRC(用来快速判断 header 是否被破坏) + +> **MUST**:`seq` 单调递增且不重复(同一 session 内)。 +> **MUST**:`nonce` 在同一 `key_id` 下不可重复。 +> **SHOULD**:`header_len` 允许携带扩展 AAD(比如 sender_id、event_type 等的明文字段)。 + +--- + +## 2.2 flags 位定义(建议) + +* bit0: `HAS_TRAILER_CRC`(记录末尾带 `trailer_crc32`) +* bit1: `BODY_COMPRESSED`(密文内部的明文在加密前压缩) +* bit2: `TS_IS_EVENT_TIME`(ts_ms 表示事件发生时间,否则表示写入时间) +* bit3: `RESERVED` + +> 推荐默认打开 `HAS_TRAILER_CRC`。即使 AEAD 能校验密文完整性,CRC 仍对**快速定位损坏/截断**很有价值。 + +--- + +# 3) 加密与密钥存放(LogKey) + +## 3.1 LogKey 的定位 + +* TeamKey 用于 Team 通信(可能在 Disband 时销毁) +* **LogKey 用于日志加密**,是否保留由 “Record Mode/Retention Policy” 决定 + +> 你想要“可复盘”,就必须 **在 Disband 后仍可获得 LogKey**(至少在本机)。 + +## 3.2 keys.bin(推荐结构) + +`keys.bin` 不要明文放 LogKey。建议用设备唯一密钥封装(例如 ESP32 NVS 加密分区 / eFuse key / 或你自己的 device key)。 + +`keys.bin` 内容建议也是一个小的 `KeyRecord`: + +``` +magic "TKEY" (4) +version (1) +flags (1) +reserved(2) +session_id (8) +key_id (4) +nonce (12) +ciphertext (var) = AEAD(DeviceKey, LogKeyMaterial) +crc32 (4) +``` + +其中 `LogKeyMaterial` 至少包含: + +* `log_key` (32 bytes) +* `kdf_info`/algo id(可选) +* `created_ts`(可选) + +> **MUST**:设备没有解封 `keys.bin` 的能力时,不允许进入复盘(避免误显示)。 + +--- + +# 4) events.log 里明文到底放什么(EVENT 内容) + +EVENT 的明文建议用 protobuf(或你自定义 TLV),然后 AEAD 加密。 + +### 明文建议结构:`TeamEvent`(protobuf) + +包含: + +* `event_type` +* `sender_id` +* `team_id` +* `payload`(按类型 oneof) +* `mesh_ts`(可选) +* `rx_rssi/snr`(可选) +* `seq_in_mesh`(可选) + +> **SHOULD**:把 `event_type` 放到 LogRecord 的 AAD 扩展里(明文),这样你可以不解密就做快速过滤/统计;但会泄露“事件类别”——如果你非常在意隐私,就不要这么做,把一切放密文里。 + +--- + +# 5) CRC 策略(掉电与损坏恢复的关键) + +你有三层完整性: + +1. AEAD tag:保证密文不可篡改(但无法判断“文件截断”位置) +2. `aad_crc32`:快速判断 header 是否损坏 +3. `trailer_crc32`:对整个 record(从 magic 到 ciphertext)做 CRC,便于恢复扫描 + +### trailer_crc32 计算范围(建议) + +对 `LogRecord` 从 `magic` 开始到 `ciphertext` 末尾(不含 trailer_crc32 本身)做 CRC32。 + +> **MUST**:读取时若 CRC 不匹配,该 record 之后的数据可视为不可信,进入恢复扫描模式。 + +--- + +# 6) 追加写策略(append-only) + +## 6.1 写入流程(每条 record) + +1. 组装明文 event +2. 生成 nonce +3. AEAD 加密得到 ciphertext +4. 写入 LogRecord 头 + ciphertext +5. 写入 trailer_crc32(若启用) +6. `fsync/flush`(看你平台:至少在关键事件/周期性 flush) + +### Flush 策略(SD 实用建议) + +* 普通事件:可每 N 条或每 T 秒 flush 一次(例如 2s) +* 关键事件(TEAM_CREATED / DISBANDED / ROTATED / SOS):**立即 flush** + +> **SHOULD**:实现一个 `LogWriter`,内部有小缓冲;但不要大到掉电损失太多。 + +--- + +# 7) 快照策略(snapshot + index) + +复盘如果只靠 replay events,会越来越慢。快照就是为了“从某个点开始回放”。 + +## 7.1 快照内容 + +快照明文建议存: + +* 当前 `key_id` +* 成员表(最近可见成员、最后位置) +* 当前 waypoint/assemble point +* 关键参数(采样策略) +* `last_event_seq`(快照覆盖到的最后事件序号) + +然后 AEAD(LogKey) 加密,写入 `snapshots.bin`(同 LogRecord 格式,type=SNAP)。 + +## 7.2 何时写快照(建议) + +* 每 **N 条事件**(例如 200) +* 或每 **T 秒**(例如 60s) +* 或关键事件后(rotate/disband) + +> **MUST**:快照写入不应影响实时通信,必须可丢弃(失败不影响主功能)。 + +## 7.3 snapshot.idx(小索引,明文) + +索引是一条条固定长度记录,追加写,便于快速定位最新快照: + +``` +IdxEntry (fixed 32 bytes): +- magic "SIDX" (4) (optional per file header) +- version (1) +- reserved(3) +- session_id (8) +- snap_seq (8) // snapshot record seq +- events_offset (8) // events.log offset at snapshot time +- snaps_offset (8) // snapshots.bin offset for this snapshot +``` + +> **SHOULD**:`snapshot.idx` 可明文,因为它只暴露 offset;如果你认为“有无快照/频率”也敏感,可把 idx 也加密,但工程复杂度会上升。 + +--- + +# 8) 恢复与复盘读取流程(掉电后可自愈) + +## 8.1 打开一个 session 的复盘步骤 + +1. 读取 `meta.json`(可选) +2. 解封 `keys.bin` 得到 LogKey +3. 读取 `snapshot.idx`,找到最后一个可用快照(校验 snap record CRC/AEAD) +4. 从快照恢复状态 +5. 从 `events.log` 的 `events_offset` 开始,顺序读取 record: + + * magic 检查 + * header/aad_crc32 检查 + * trailer_crc32(若启用) + * AEAD 解密 + * 解析 event → 回放更新状态 + +## 8.2 恢复扫描(文件损坏/截断) + +当发现 CRC 不匹配或读到半条 record: + +* 进入扫描模式:按字节滑动找下一个 `"TLOG"` magic +* 找到后尝试解析 header_len/body_len 是否合理 +* CRC 通过则继续 + +> **MUST**:扫描必须有上限(避免在损坏文件上死循环)。 + +--- + +# 9) 文件滚动与保留策略(防止无限增长) + +## 9.1 单 session 文件滚动 + +建议 `events.log` 达到阈值就切分: + +* `events_0001.log` +* `events_0002.log` + +同理快照。 + +阈值建议: + +* 4MB / 16MB / 64MB 任选一个(看 SD/需求) +* 位置点多的话建议更小,便于导出与修复 + +## 9.2 Retention(保留期) + +* `meta.json` 里记录 retention policy +* 定期清理 `YYYYMMDD` 目录 + +> **MUST**:用户应能“一键删除某次行动记录”(删除整个 `T_` 目录)。 + +--- + +# 10) 最小实现版本(你可以先落地这个) + +如果你要先快速跑起来,我建议 MVP: + +* 只要三个文件: + + * `meta.json` + * `keys.bin` + * `events.log` +* `events.log`:LogRecord + AEAD + trailer_crc32 +* 不做快照、不做 idx +* 位置事件做强制降采样(例如 10s 一条) + +后续再加: + +* `snapshots.bin` + `snapshot.idx` + +--- diff --git a/docs/team/IMPLEMENTATION_CHECKLIST.md b/docs/team/IMPLEMENTATION_CHECKLIST.md new file mode 100644 index 00000000..d6ff051e --- /dev/null +++ b/docs/team/IMPLEMENTATION_CHECKLIST.md @@ -0,0 +1,85 @@ +# 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/images/team.png b/images/team.png new file mode 100644 index 00000000..d7e6c70a Binary files /dev/null and b/images/team.png differ diff --git a/src/app/app_context.cpp b/src/app/app_context.cpp index 1a8a76c3..8bdc7c6d 100644 --- a/src/app/app_context.cpp +++ b/src/app/app_context.cpp @@ -8,6 +8,7 @@ #include "../gps/usecase/gps_service.h" #include "../sys/event_bus.h" #include "../ui/widgets/system_notification.h" +#include "../ui/ui_common.h" #include "app_tasks.h" #include @@ -27,6 +28,7 @@ bool AppContext::init(TLoRaPagerBoard& board, bool use_mock_adapter, uint32_t di // Load configuration config_.load(preferences_); + (void)ui_get_timezone_offset_min(); gps::GpsService::getInstance().begin( board, @@ -75,6 +77,13 @@ bool AppContext::init(TLoRaPagerBoard& board, bool use_mock_adapter, uint32_t di chat_service_ = std::make_unique( *chat_model_, *mesh_adapter_, *chat_store_); + // Create team service (protocol-only for now) + team_crypto_ = std::make_unique(); + team_event_sink_ = std::make_unique(); + team_service_ = std::make_unique( + *team_crypto_, *mesh_adapter_, *team_event_sink_); + team_controller_ = std::make_unique(*team_service_); + // Load persisted messages into model (no unread) if (flash_store_) { @@ -91,7 +100,7 @@ bool AppContext::init(TLoRaPagerBoard& board, bool use_mock_adapter, uint32_t di { chat_model_->onIncoming(msg); } - chat::ConversationId conv(msg.channel, msg.peer ? msg.peer : msg.from); + chat::ConversationId conv(msg.channel, msg.peer); touched.push_back(conv); } for (const auto& conv : touched) @@ -119,6 +128,10 @@ void AppContext::update() { chat_service_->processIncoming(); } + if (team_service_) + { + team_service_->processIncoming(); + } // Update UI controller if (ui_controller_) @@ -156,7 +169,16 @@ void AppContext::update() } // Show system notification - ui::SystemNotification::show(msg_event->text, 10000); + ui::SystemNotification::show(msg_event->text, 3000); + break; + } + case sys::EventType::ChatSendResult: + { + sys::ChatSendResultEvent* result_event = (sys::ChatSendResultEvent*)event; + if (chat_service_) + { + chat_service_->handleSendResult(result_event->msg_id, result_event->success); + } break; } case sys::EventType::NodeInfoUpdate: diff --git a/src/app/app_context.h b/src/app/app_context.h index 7a463a27..e0bc59d3 100644 --- a/src/app/app_context.h +++ b/src/app/app_context.h @@ -8,6 +8,10 @@ #include "../chat/domain/chat_model.h" #include "../chat/usecase/chat_service.h" #include "../chat/usecase/contact_service.h" +#include "../team/infra/crypto/team_crypto.h" +#include "../team/infra/event/team_event_bus_sink.h" +#include "../team/usecase/team_controller.h" +#include "../team/usecase/team_service.h" namespace app { @@ -75,6 +79,14 @@ class AppContext return ui_controller_.get(); } + /** + * @brief Get team controller (UI/state entry points) + */ + team::TeamController* getTeamController() + { + return team_controller_.get(); + } + /** * @brief Get configuration */ @@ -140,6 +152,10 @@ class AppContext // Use case std::unique_ptr chat_service_; std::unique_ptr contact_service_; + std::unique_ptr team_crypto_; + std::unique_ptr team_event_sink_; + std::unique_ptr team_service_; + std::unique_ptr team_controller_; // UI std::unique_ptr ui_controller_; diff --git a/src/chat/domain/chat_model.cpp b/src/chat/domain/chat_model.cpp index 8f9e1464..1720193f 100644 --- a/src/chat/domain/chat_model.cpp +++ b/src/chat/domain/chat_model.cpp @@ -18,7 +18,7 @@ ChatModel::~ChatModel() void ChatModel::onIncoming(const ChatMessage& msg) { - ConversationId conv(msg.channel, msg.peer ? msg.peer : msg.from); + ConversationId conv(msg.channel, msg.peer); ConversationData& data = getConvData(conv); data.messages.append(msg); @@ -41,7 +41,10 @@ void ChatModel::onSendQueued(const ChatMessage& msg) { copy.msg_id = next_msg_id_++; } - copy.status = MessageStatus::Queued; + if (copy.status != MessageStatus::Failed) + { + copy.status = MessageStatus::Queued; + } data.messages.append(copy); data.preview = copy.text; @@ -56,14 +59,13 @@ void ChatModel::onSendResult(MessageId msg_id, bool ok) ConversationData& data = pair.second; for (size_t i = 0; i < data.messages.count(); i++) { - const ChatMessage* msg = data.messages.get(i); + ChatMessage* msg = data.messages.get(i); if (msg && msg->msg_id == msg_id) { + msg->status = ok ? MessageStatus::Sent : MessageStatus::Failed; if (!ok) { - ChatMessage failed_msg = *msg; - failed_msg.status = MessageStatus::Failed; - failed_messages_.append(failed_msg); + failed_messages_.append(*msg); } return; } diff --git a/src/chat/domain/chat_types.h b/src/chat/domain/chat_types.h index 1eccb613..fef04624 100644 --- a/src/chat/domain/chat_types.h +++ b/src/chat/domain/chat_types.h @@ -8,6 +8,7 @@ #include #include #include +#include namespace chat { @@ -108,6 +109,7 @@ struct MeshIncomingText { ChannelId channel; NodeId from; + NodeId to; MessageId msg_id; uint32_t timestamp; std::string text; @@ -115,6 +117,26 @@ struct MeshIncomingText bool encrypted; // Whether message was encrypted }; +/** + * @brief Incoming non-text mesh payload + */ +struct MeshIncomingData +{ + uint32_t portnum; + NodeId from; + NodeId to; + MessageId packet_id; + ChannelId channel; + uint8_t channel_hash; + bool want_response; + std::vector payload; + + MeshIncomingData() + : portnum(0), from(0), to(0), packet_id(0), + channel(ChannelId::PRIMARY), channel_hash(0), + want_response(false) {} +}; + /** * @brief Mesh configuration */ diff --git a/src/chat/infra/meshcore/meshcore_adapter.cpp b/src/chat/infra/meshcore/meshcore_adapter.cpp index 4f4a40e8..a40ded8d 100644 --- a/src/chat/infra/meshcore/meshcore_adapter.cpp +++ b/src/chat/infra/meshcore/meshcore_adapter.cpp @@ -4,6 +4,7 @@ */ #include "meshcore_adapter.h" +#include "../../time_utils.h" #include #include @@ -189,6 +190,25 @@ bool MeshCoreAdapter::pollIncomingText(MeshIncomingText* out) return true; } +bool MeshCoreAdapter::sendAppData(ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + NodeId dest, bool want_ack) +{ + (void)channel; + (void)portnum; + (void)payload; + (void)len; + (void)dest; + (void)want_ack; + return false; +} + +bool MeshCoreAdapter::pollIncomingData(MeshIncomingData* out) +{ + (void)out; + return false; +} + void MeshCoreAdapter::applyConfig(const MeshConfig& config) { config_ = config; @@ -263,8 +283,9 @@ void MeshCoreAdapter::handleRawPacket(const uint8_t* data, size_t size) MeshIncomingText incoming; incoming.channel = ChannelId::PRIMARY; incoming.from = 0; + incoming.to = 0xFFFFFFFF; incoming.msg_id = next_msg_id_++; - incoming.timestamp = millis() / 1000; + incoming.timestamp = now_message_timestamp(); incoming.text.assign(reinterpret_cast(payload), payload_len); incoming.hop_limit = 0; incoming.encrypted = false; @@ -280,4 +301,4 @@ void MeshCoreAdapter::processSendQueue() } } // namespace meshcore -} // namespace chat \ No newline at end of file +} // namespace chat diff --git a/src/chat/infra/meshcore/meshcore_adapter.h b/src/chat/infra/meshcore/meshcore_adapter.h index 2a637e1b..4e6dd6fd 100644 --- a/src/chat/infra/meshcore/meshcore_adapter.h +++ b/src/chat/infra/meshcore/meshcore_adapter.h @@ -41,6 +41,12 @@ class MeshCoreAdapter : public IMeshAdapter bool pollIncomingText(MeshIncomingText* out) override; + bool sendAppData(ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + NodeId dest = 0, bool want_ack = false) override; + + bool pollIncomingData(MeshIncomingData* out) override; + void applyConfig(const MeshConfig& config) override; bool isReady() const override; @@ -87,4 +93,4 @@ class MeshCoreAdapter : public IMeshAdapter }; } // namespace meshcore -} // namespace chat \ No newline at end of file +} // namespace chat diff --git a/src/chat/infra/meshtastic/mt_adapter.cpp b/src/chat/infra/meshtastic/mt_adapter.cpp index b574cc1d..9b92c74e 100644 --- a/src/chat/infra/meshtastic/mt_adapter.cpp +++ b/src/chat/infra/meshtastic/mt_adapter.cpp @@ -6,6 +6,7 @@ #include "mt_adapter.h" #include "../../../sys/event_bus.h" #include "../../domain/contact_types.h" +#include "../../../team/protocol/team_portnum.h" #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #ifndef LORA_LOG_ENABLE #define LORA_LOG_ENABLE 1 @@ -46,6 +48,14 @@ constexpr uint8_t kLoraSyncWord = 0x2b; constexpr uint16_t kLoraPreambleLen = 16; constexpr uint8_t kBitfieldWantResponseMask = 0x02; +bool allowPkiForPortnum(uint32_t portnum) +{ + return portnum != meshtastic_PortNum_NODEINFO_APP && + portnum != meshtastic_PortNum_ROUTING_APP && + portnum != meshtastic_PortNum_POSITION_APP && + portnum != meshtastic_PortNum_TRACEROUTE_APP; +} + static const char* portName(uint32_t portnum) { switch (portnum) @@ -62,10 +72,61 @@ static const char* portName(uint32_t portnum) return "TELEMETRY"; case meshtastic_PortNum_REMOTE_HARDWARE_APP: return "REMOTEHW"; + case meshtastic_PortNum_ROUTING_APP: + return "ROUTING"; case meshtastic_PortNum_TRACEROUTE_APP: return "TRACEROUTE"; case meshtastic_PortNum_WAYPOINT_APP: return "WAYPOINT"; + 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"; + default: + return "UNKNOWN"; + } +} + +static const char* routingErrorName(meshtastic_Routing_Error err) +{ + switch (err) + { + case meshtastic_Routing_Error_NONE: + return "NONE"; + case meshtastic_Routing_Error_NO_ROUTE: + return "NO_ROUTE"; + case meshtastic_Routing_Error_GOT_NAK: + return "GOT_NAK"; + case meshtastic_Routing_Error_TIMEOUT: + return "TIMEOUT"; + case meshtastic_Routing_Error_NO_INTERFACE: + return "NO_INTERFACE"; + case meshtastic_Routing_Error_MAX_RETRANSMIT: + return "MAX_RETRANSMIT"; + case meshtastic_Routing_Error_NO_CHANNEL: + return "NO_CHANNEL"; + case meshtastic_Routing_Error_TOO_LARGE: + return "TOO_LARGE"; + case meshtastic_Routing_Error_NO_RESPONSE: + return "NO_RESPONSE"; + case meshtastic_Routing_Error_DUTY_CYCLE_LIMIT: + return "DUTY_CYCLE_LIMIT"; + case meshtastic_Routing_Error_BAD_REQUEST: + return "BAD_REQUEST"; + case meshtastic_Routing_Error_NOT_AUTHORIZED: + return "NOT_AUTHORIZED"; + case meshtastic_Routing_Error_PKI_FAILED: + return "PKI_FAILED"; + case meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY: + return "PKI_UNKNOWN_PUBKEY"; + case meshtastic_Routing_Error_ADMIN_BAD_SESSION_KEY: + return "ADMIN_BAD_SESSION_KEY"; + case meshtastic_Routing_Error_ADMIN_PUBLIC_KEY_UNAUTHORIZED: + return "ADMIN_PUBLIC_KEY_UNAUTHORIZED"; + case meshtastic_Routing_Error_RATE_LIMIT_EXCEEDED: + return "RATE_LIMIT_EXCEEDED"; default: return "UNKNOWN"; } @@ -354,6 +415,7 @@ using chat::meshtastic::buildWirePacket; using chat::meshtastic::decodeTextMessage; using chat::meshtastic::decryptPayload; using chat::meshtastic::encodeNodeInfoMessage; +using chat::meshtastic::encodeAppData; using chat::meshtastic::encodeTextMessage; using chat::meshtastic::PacketHeaderWire; using chat::meshtastic::parseWirePacket; @@ -385,6 +447,7 @@ MtAdapter::MtAdapter(TLoRaPagerBoard& board) config_ = MeshConfig(); // Default config initNodeIdentity(); initPkiKeys(); + loadPkiNodeKeys(); updateChannelKeys(); } @@ -402,6 +465,7 @@ bool MtAdapter::sendText(ChannelId channel, const std::string& text, PendingSend pending; pending.channel = channel; + pending.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; pending.text = text; pending.msg_id = next_packet_id_++; pending.dest = (peer != 0) ? peer : 0xFFFFFFFF; @@ -434,6 +498,132 @@ bool MtAdapter::pollIncomingText(MeshIncomingText* out) return true; } +bool MtAdapter::sendAppData(ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + NodeId dest, bool want_ack) +{ + if (!ready_) + { + return false; + } + + uint8_t data_buffer[256]; + size_t data_size = sizeof(data_buffer); + if (!encodeAppData(portnum, payload, len, want_ack, data_buffer, &data_size)) + { + return false; + } + + uint8_t wire_buffer[512]; + size_t wire_size = sizeof(wire_buffer); + + ChannelId out_channel = channel; + uint8_t channel_hash = + (out_channel == ChannelId::SECONDARY) ? secondary_channel_hash_ : primary_channel_hash_; + const uint8_t* psk = + (out_channel == ChannelId::SECONDARY) ? secondary_psk_ : primary_psk_; + size_t psk_len = + (out_channel == ChannelId::SECONDARY) ? secondary_psk_len_ : primary_psk_len_; + uint8_t hop_limit = config_.hop_limit; + uint32_t dest_node = (dest != 0) ? dest : 0xFFFFFFFF; + bool want_ack_flag = want_ack && (dest_node != 0xFFFFFFFF); + MessageId msg_id = next_packet_id_++; + + const uint8_t* out_payload = data_buffer; + size_t out_len = data_size; + bool pki_disabled = false; + if (dest_node != 0xFFFFFFFF) + { + uint32_t now_ms = millis(); + auto disable_it = pki_disabled_until_ms_.find(dest_node); + if (disable_it != pki_disabled_until_ms_.end()) + { + if (now_ms < disable_it->second) + { + pki_disabled = true; + } + else + { + pki_disabled_until_ms_.erase(disable_it); + } + } + } + bool use_pki = (dest_node != 0xFFFFFFFF) && pki_ready_ && + allowPkiForPortnum(portnum) && + !pki_disabled && + (node_public_keys_.find(dest_node) != node_public_keys_.end()); + uint8_t pki_buf[256]; + size_t pki_len = sizeof(pki_buf); + if (use_pki && encryptPkiPayload(dest_node, msg_id, data_buffer, data_size, pki_buf, &pki_len)) + { + out_payload = pki_buf; + out_len = pki_len; + channel_hash = 0; // PKI channel + want_ack_flag = true; + psk = nullptr; + psk_len = 0; + } + else if (dest_node != 0xFFFFFFFF && out_channel == ChannelId::PRIMARY) + { + if (pki_disabled) + { + uint32_t until_ms = pki_disabled_until_ms_[dest_node]; + LORA_LOG("[LORA] PKI disabled for %08lX until=%lu\n", + (unsigned long)dest_node, + (unsigned long)until_ms); + } + auto it = node_last_channel_.find(dest_node); + if (it != node_last_channel_.end()) + { + out_channel = it->second; + channel_hash = + (out_channel == ChannelId::SECONDARY) ? secondary_channel_hash_ : primary_channel_hash_; + psk = (out_channel == ChannelId::SECONDARY) ? secondary_psk_ : primary_psk_; + psk_len = (out_channel == ChannelId::SECONDARY) ? secondary_psk_len_ : primary_psk_len_; + } + } + + if (!buildWirePacket(out_payload, out_len, node_id_, msg_id, + dest_node, channel_hash, hop_limit, want_ack_flag, + psk, psk_len, wire_buffer, &wire_size)) + { + return false; + } + + if (!board_.isHardwareOnline(HW_RADIO_ONLINE)) + { + return false; + } + + int state = RADIOLIB_ERR_UNSUPPORTED; +#if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) + state = board_.radio.transmit(wire_buffer, wire_size); +#endif + + bool ok = (state == RADIOLIB_ERR_NONE); + LORA_LOG("[LORA] TX app port=%u len=%u ok=%d\n", + (unsigned)portnum, + (unsigned)wire_size, + ok ? 1 : 0); + if (ok) + { + startRadioReceive(); + } + return ok; +} + +bool MtAdapter::pollIncomingData(MeshIncomingData* out) +{ + if (app_receive_queue_.empty()) + { + return false; + } + + *out = app_receive_queue_.front(); + app_receive_queue_.pop(); + return true; +} + void MtAdapter::applyConfig(const MeshConfig& config) { config_ = config; @@ -506,6 +696,15 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) header.channel, header.flags, (unsigned)payload_size); + const char* channel_kind = "UNKNOWN"; + if (header.channel == 0) { + channel_kind = "PKI"; + } else if (header.channel == primary_channel_hash_) { + channel_kind = "PRIMARY"; + } else if (header.channel == secondary_channel_hash_) { + channel_kind = "SECONDARY"; + } + LORA_LOG("[LORA] RX channel kind=%s hash=0x%02X\n", channel_kind, header.channel); LORA_LOG("[LORA] RX full packet hex: %s\n", full_hex.c_str()); if (header.from == node_id_) { @@ -638,6 +837,7 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) std::array key{}; memcpy(key.data(), user.public_key.bytes, 32); node_public_keys_[node_id] = key; + savePkiNodeKey(node_id, key.data(), key.size()); LORA_LOG("[LORA] PKI key stored for %08lX\n", (unsigned long)node_id); } @@ -700,12 +900,71 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) } } + if (decoded.portnum == meshtastic_PortNum_ROUTING_APP && decoded.payload.size > 0) + { + meshtastic_Routing routing = meshtastic_Routing_init_default; + pb_istream_t rstream = + pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); + if (pb_decode(&rstream, meshtastic_Routing_fields, &routing)) + { + LORA_LOG("[LORA] RX routing from=%08lX req=%08lX dest=%08lX src=%08lX\n", + (unsigned long)header.from, + (unsigned long)decoded.request_id, + (unsigned long)decoded.dest, + (unsigned long)decoded.source); + if (decoded.request_id != 0 && header.to == node_id_) + { + bool ok = true; + if (routing.which_variant == meshtastic_Routing_error_reason_tag && + routing.error_reason != meshtastic_Routing_Error_NONE) + { + ok = false; + } + if (routing.which_variant == meshtastic_Routing_error_reason_tag && + (routing.error_reason == meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY || + routing.error_reason == meshtastic_Routing_Error_NO_CHANNEL)) + { + uint32_t now_ms = millis(); + pki_disabled_until_ms_[header.from] = now_ms + PKI_BACKOFF_MS; + sendNodeInfoTo(header.from, true); + LORA_LOG("[LORA] PKI backoff for %08lX reason=%s until=%lu\n", + (unsigned long)header.from, + routingErrorName(routing.error_reason), + (unsigned long)(now_ms + PKI_BACKOFF_MS)); + } + pending_ack_ms_.erase(decoded.request_id); + LORA_LOG("[LORA] RX ack reason=%u (%s)\n", + static_cast(routing.error_reason), + routingErrorName(routing.error_reason)); + LORA_LOG("[LORA] RX ack from=%08lX req=%08lX ok=%d\n", + (unsigned long)header.from, + (unsigned long)decoded.request_id, + ok ? 1 : 0); + sys::EventBus::publish( + new sys::ChatSendResultEvent(decoded.request_id, ok), 0); + } + } + else + { + LORA_LOG("[LORA] RX Routing decode fail from=%08lX err=%s\n", + (unsigned long)header.from, + PB_GET_ERROR(&rstream)); + } + } + bool want_ack_flag = (header.flags & PACKET_FLAGS_WANT_ACK_MASK) != 0; bool want_response = decoded.want_response || (decoded.has_bitfield && ((decoded.bitfield & kBitfieldWantResponseMask) != 0)); bool to_us = (header.to == node_id_); bool is_text_port = (decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP || decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP); + bool is_nodeinfo_port = (decoded.portnum == meshtastic_PortNum_NODEINFO_APP); + ChannelId channel_id = + (header.channel == secondary_channel_hash_) ? ChannelId::SECONDARY : ChannelId::PRIMARY; + if (header.channel != 0 && header.from != node_id_) + { + node_last_channel_[header.from] = channel_id; + } if ((want_ack_flag || want_response) && to_us && is_text_port) { if (sendRoutingAck(header.from, header.id, header.channel, psk, psk_len)) @@ -721,6 +980,24 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) (unsigned long)header.id); } } + + if (!is_text_port && !is_nodeinfo_port && decoded.payload.size > 0) + { + MeshIncomingData incoming; + incoming.portnum = decoded.portnum; + incoming.from = header.from; + incoming.to = header.to; + incoming.packet_id = header.id; + incoming.channel = channel_id; + incoming.channel_hash = header.channel; + incoming.want_response = want_response; + incoming.payload.assign(decoded.payload.bytes, + decoded.payload.bytes + decoded.payload.size); + if (app_receive_queue_.size() < MAX_APP_QUEUE) + { + app_receive_queue_.push(incoming); + } + } } else { @@ -738,6 +1015,7 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) { // Fill in packet info from header incoming.from = header.from; + incoming.to = header.to; incoming.msg_id = header.id; if (header.channel == secondary_channel_hash_) { @@ -769,6 +1047,20 @@ void MtAdapter::processSendQueue() maybeBroadcastNodeInfo(now); + for (auto it = pending_ack_ms_.begin(); it != pending_ack_ms_.end();) + { + if (now - it->second >= ACK_TIMEOUT_MS) + { + LORA_LOG("[LORA] RX ack timeout req=%08lX\n", + (unsigned long)it->first); + sys::EventBus::publish( + new sys::ChatSendResultEvent(it->first, false), 0); + it = pending_ack_ms_.erase(it); + continue; + } + ++it; + } + if (!send_queue_.empty()) { LORA_LOG("[LORA] TX queue pending=%u\n", (unsigned)send_queue_.size()); @@ -827,8 +1119,9 @@ bool MtAdapter::sendPacket(const PendingSend& pending) uint8_t wire_buffer[512]; size_t wire_size = sizeof(wire_buffer); + ChannelId channel = pending.channel; uint8_t channel_hash = - (pending.channel == ChannelId::SECONDARY) ? secondary_channel_hash_ : primary_channel_hash_; + (channel == ChannelId::SECONDARY) ? secondary_channel_hash_ : primary_channel_hash_; uint8_t hop_limit = config_.hop_limit; bool want_ack = false; // Broadcast doesn't need ACK unless PKI direct uint32_t dest = (pending.dest != 0) ? pending.dest : 0xFFFFFFFF; @@ -838,7 +1131,26 @@ bool MtAdapter::sendPacket(const PendingSend& pending) size_t payload_len = data_size; const uint8_t* psk = nullptr; size_t psk_len = 0; + bool pki_disabled = false; + if (dest != 0xFFFFFFFF) + { + uint32_t now_ms = millis(); + auto disable_it = pki_disabled_until_ms_.find(dest); + if (disable_it != pki_disabled_until_ms_.end()) + { + if (now_ms < disable_it->second) + { + pki_disabled = true; + } + else + { + pki_disabled_until_ms_.erase(disable_it); + } + } + } bool use_pki = (dest != 0xFFFFFFFF) && pki_ready_ && + allowPkiForPortnum(pending.portnum) && + !pki_disabled && (node_public_keys_.find(dest) != node_public_keys_.end()); uint8_t pki_buf[256]; size_t pki_len = sizeof(pki_buf); @@ -851,8 +1163,25 @@ bool MtAdapter::sendPacket(const PendingSend& pending) } else { + if (dest != 0xFFFFFFFF && channel == ChannelId::PRIMARY) + { + if (pki_disabled) + { + uint32_t until_ms = pki_disabled_until_ms_[dest]; + LORA_LOG("[LORA] PKI disabled for %08lX until=%lu\n", + (unsigned long)dest, + (unsigned long)until_ms); + } + auto it = node_last_channel_.find(dest); + if (it != node_last_channel_.end()) + { + channel = it->second; + channel_hash = + (channel == ChannelId::SECONDARY) ? secondary_channel_hash_ : primary_channel_hash_; + } + } // Fallback to PSK - if (pending.channel == ChannelId::SECONDARY) + if (channel == ChannelId::SECONDARY) { psk = secondary_psk_; psk_len = secondary_psk_len_; @@ -864,6 +1193,15 @@ bool MtAdapter::sendPacket(const PendingSend& pending) } } + const char* channel_name = + (channel == ChannelId::SECONDARY) ? kSecondaryChannelName : kPrimaryChannelName; + LORA_LOG("[LORA] TX channel name='%s' hash=0x%02X psk=%u pki=%u dest=%08lX\n", + channel_name, + channel_hash, + (unsigned)psk_len, + use_pki ? 1U : 0U, + (unsigned long)dest); + if (!buildWirePacket(payload, payload_len, from_node, pending.msg_id, dest, channel_hash, hop_limit, want_ack, psk, psk_len, wire_buffer, &wire_size)) @@ -899,9 +1237,13 @@ bool MtAdapter::sendPacket(const PendingSend& pending) bool ok = (state == RADIOLIB_ERR_NONE); LORA_LOG("[LORA] TX text id=%08lX ch=%u len=%u ok=%d\n", (unsigned long)pending.msg_id, - static_cast(pending.channel), + static_cast(channel), (unsigned)wire_size, ok ? 1 : 0); + if (ok && want_ack) + { + pending_ack_ms_[pending.msg_id] = millis(); + } if (ok) { startRadioReceive(); @@ -1156,14 +1498,20 @@ void MtAdapter::updateChannelKeys() computeChannelHash(kSecondaryChannelName, (secondary_psk_len_ > 0) ? secondary_psk_ : nullptr, secondary_psk_len_); - LORA_LOG("[LORA] channel primary='%s' hash=0x%02X psk=%u\n", + std::string primary_psk_hex = toHex(primary_psk_, primary_psk_len_, primary_psk_len_); + std::string secondary_psk_hex = toHex(secondary_psk_, secondary_psk_len_, secondary_psk_len_); + if (primary_psk_hex.empty()) primary_psk_hex = "-"; + if (secondary_psk_hex.empty()) secondary_psk_hex = "-"; + LORA_LOG("[LORA] channel primary='%s' hash=0x%02X psk=%u hex=%s\n", kPrimaryChannelName, primary_channel_hash_, - (unsigned)primary_psk_len_); - LORA_LOG("[LORA] channel secondary='%s' hash=0x%02X psk=%u\n", + (unsigned)primary_psk_len_, + primary_psk_hex.c_str()); + LORA_LOG("[LORA] channel secondary='%s' hash=0x%02X psk=%u hex=%s\n", kSecondaryChannelName, secondary_channel_hash_, - (unsigned)secondary_psk_len_); + (unsigned)secondary_psk_len_, + secondary_psk_hex.c_str()); } void MtAdapter::startRadioReceive() @@ -1219,6 +1567,92 @@ bool MtAdapter::initPkiKeys() return pki_ready_; } +void MtAdapter::loadPkiNodeKeys() +{ + Preferences prefs; + prefs.begin(kPkiPrefsNs, true); + size_t total = prefs.getBytesLength(kPkiPrefsKey); + struct PkiKeyEntry { + uint32_t node_id; + uint8_t key[32]; + }; + if (total < sizeof(PkiKeyEntry)) { + prefs.end(); + return; + } + size_t count = total / sizeof(PkiKeyEntry); + if (count > kMaxPkiNodes) { + count = kMaxPkiNodes; + } + std::vector entries(count); + size_t read = prefs.getBytes(kPkiPrefsKey, entries.data(), + entries.size() * sizeof(PkiKeyEntry)); + prefs.end(); + + size_t actual = read / sizeof(PkiKeyEntry); + for (size_t i = 0; i < actual; ++i) { + if (entries[i].node_id == 0) { + continue; + } + std::array key{}; + memcpy(key.data(), entries[i].key, sizeof(entries[i].key)); + node_public_keys_[entries[i].node_id] = key; + } + if (actual > 0) { + LORA_LOG("[LORA] PKI keys loaded=%u\n", (unsigned)actual); + } +} + +void MtAdapter::savePkiNodeKey(uint32_t node_id, const uint8_t* key, size_t key_len) +{ + if (node_id == 0 || !key || key_len != 32) { + return; + } + struct PkiKeyEntry { + uint32_t node_id; + uint8_t key[32]; + }; + std::vector entries; + + Preferences prefs; + prefs.begin(kPkiPrefsNs, false); + size_t total = prefs.getBytesLength(kPkiPrefsKey); + if (total >= sizeof(PkiKeyEntry)) { + size_t count = total / sizeof(PkiKeyEntry); + if (count > kMaxPkiNodes) { + count = kMaxPkiNodes; + } + entries.resize(count); + prefs.getBytes(kPkiPrefsKey, entries.data(), + entries.size() * sizeof(PkiKeyEntry)); + } + + bool updated = false; + for (auto& entry : entries) { + if (entry.node_id == node_id) { + memcpy(entry.key, key, 32); + updated = true; + break; + } + } + if (!updated) { + if (entries.size() < kMaxPkiNodes) { + PkiKeyEntry entry{}; + entry.node_id = node_id; + memcpy(entry.key, key, 32); + entries.push_back(entry); + } else if (!entries.empty()) { + entries.back().node_id = node_id; + memcpy(entries.back().key, key, 32); + } + } + if (!entries.empty()) { + prefs.putBytes(kPkiPrefsKey, entries.data(), + entries.size() * sizeof(PkiKeyEntry)); + } + prefs.end(); +} + bool MtAdapter::decryptPkiPayload(uint32_t from, uint32_t packet_id, const uint8_t* cipher, size_t cipher_len, uint8_t* out_plain, size_t* out_plain_len) diff --git a/src/chat/infra/meshtastic/mt_adapter.h b/src/chat/infra/meshtastic/mt_adapter.h index 3a78c558..1c517410 100644 --- a/src/chat/infra/meshtastic/mt_adapter.h +++ b/src/chat/infra/meshtastic/mt_adapter.h @@ -36,6 +36,10 @@ class MtAdapter : public chat::IMeshAdapter bool sendText(ChannelId channel, const std::string& text, MessageId* out_msg_id, NodeId peer = 0) override; bool pollIncomingText(MeshIncomingText* out) override; + bool sendAppData(ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + NodeId dest = 0, bool want_ack = false) override; + bool pollIncomingData(MeshIncomingData* out) override; void applyConfig(const MeshConfig& config) override; bool isReady() const override; @@ -84,7 +88,10 @@ class MtAdapter : public chat::IMeshAdapter std::array pki_public_key_; std::array pki_private_key_; std::map> node_public_keys_; + std::map node_last_channel_; + std::map pki_disabled_until_ms_; std::map nodeinfo_last_seen_ms_; + std::map pending_ack_ms_; // Raw packet data storage for protocol detection uint8_t last_raw_packet_[256]; @@ -94,6 +101,7 @@ class MtAdapter : public chat::IMeshAdapter struct PendingSend { ChannelId channel; + uint32_t portnum; std::string text; MessageId msg_id; NodeId dest; @@ -103,12 +111,19 @@ class MtAdapter : public chat::IMeshAdapter std::queue send_queue_; std::queue receive_queue_; + std::queue app_receive_queue_; static constexpr size_t MAX_PACKET_SIZE = 255; static constexpr uint32_t RETRY_DELAY_MS = 1000; static constexpr uint8_t MAX_RETRIES = 1; static constexpr uint32_t NODEINFO_INTERVAL_MS = 3 * 60 * 60 * 1000; static constexpr uint32_t NODEINFO_REPLY_SUPPRESS_MS = 12 * 60 * 60 * 1000; + static constexpr uint32_t PKI_BACKOFF_MS = 5 * 60 * 1000; + static constexpr size_t MAX_APP_QUEUE = 10; + static constexpr uint32_t ACK_TIMEOUT_MS = 15000; + static constexpr size_t kMaxPkiNodes = 16; + static constexpr const char* kPkiPrefsNs = "chat"; + static constexpr const char* kPkiPrefsKey = "pki_nodes"; bool sendPacket(const PendingSend& pending); bool sendNodeInfo(); @@ -119,6 +134,8 @@ class MtAdapter : public chat::IMeshAdapter void updateChannelKeys(); void startRadioReceive(); bool initPkiKeys(); + void loadPkiNodeKeys(); + void savePkiNodeKey(uint32_t node_id, const uint8_t* key, size_t key_len); bool decryptPkiPayload(uint32_t from, uint32_t packet_id, const uint8_t* cipher, size_t cipher_len, uint8_t* out_plain, size_t* out_plain_len); diff --git a/src/chat/infra/meshtastic/mt_codec_pb.cpp b/src/chat/infra/meshtastic/mt_codec_pb.cpp index 18c19d1f..76ff1aec 100644 --- a/src/chat/infra/meshtastic/mt_codec_pb.cpp +++ b/src/chat/infra/meshtastic/mt_codec_pb.cpp @@ -6,6 +6,7 @@ */ #include "mt_codec_pb.h" +#include "../../time_utils.h" #include "compression/unishox2.h" #include @@ -111,7 +112,7 @@ bool decodeTextMessage(const uint8_t* buffer, size_t size, MeshIncomingText* out // This will be done in mt_adapter when decoding full packet out->from = 0; // Will be set from packet header out->msg_id = 0; // Will be set from packet header - out->timestamp = millis() / 1000; + out->timestamp = now_message_timestamp(); out->channel = ChannelId::PRIMARY; // Will be set from packet header out->hop_limit = 2; out->encrypted = false; @@ -181,6 +182,44 @@ bool encodeNodeInfoMessage(const std::string& user_id, const std::string& long_n return true; } +bool encodeAppData(uint32_t portnum, const uint8_t* payload, size_t payload_len, + bool want_response, uint8_t* out_buffer, size_t* out_size) +{ + if (!out_buffer || !out_size) + { + return false; + } + + meshtastic_Data data = meshtastic_Data_init_default; + data.portnum = static_cast(portnum); + data.want_response = want_response; + data.has_bitfield = true; + data.bitfield = 0; + + if (payload_len > sizeof(data.payload.bytes)) + { + return false; + } + data.payload.size = static_cast(payload_len); + if (payload_len > 0) + { + if (!payload) + { + return false; + } + memcpy(data.payload.bytes, payload, payload_len); + } + + pb_ostream_t data_stream = pb_ostream_from_buffer(out_buffer, *out_size); + if (!pb_encode(&data_stream, meshtastic_Data_fields, &data)) + { + return false; + } + + *out_size = data_stream.bytes_written; + return true; +} + bool encodeMeshPacket(const meshtastic_MeshPacket& packet, uint8_t* out_buffer, size_t* out_size) { if (!out_buffer || !out_size) diff --git a/src/chat/infra/meshtastic/mt_codec_pb.h b/src/chat/infra/meshtastic/mt_codec_pb.h index 7375f4ba..4c3d92a0 100644 --- a/src/chat/infra/meshtastic/mt_codec_pb.h +++ b/src/chat/infra/meshtastic/mt_codec_pb.h @@ -67,6 +67,19 @@ bool encodeNodeInfoMessage(const std::string& user_id, const std::string& long_n const uint8_t macaddr[6], const uint8_t* public_key, size_t public_key_len, bool want_response, uint8_t* out_buffer, size_t* out_size); +/** + * @brief Encode app payload to Meshtastic Data message + * @param portnum Application port number + * @param payload Payload bytes + * @param payload_len Payload length + * @param want_response Request response + * @param out_buffer Output buffer + * @param out_size Output buffer size (updated with actual size) + * @return true if successful + */ +bool encodeAppData(uint32_t portnum, const uint8_t* payload, size_t payload_len, + bool want_response, uint8_t* out_buffer, size_t* out_size); + /** * @brief Encode MeshPacket to buffer * @param packet MeshPacket structure diff --git a/src/chat/infra/store/flash_store.cpp b/src/chat/infra/store/flash_store.cpp index b3bc6e0b..326b69e7 100644 --- a/src/chat/infra/store/flash_store.cpp +++ b/src/chat/infra/store/flash_store.cpp @@ -148,6 +148,28 @@ void FlashStore::clearChannel(ChannelId channel) } } +bool FlashStore::updateMessageStatus(MessageId msg_id, MessageStatus status) +{ + if (!ready_ || msg_id == 0) return false; + + for (size_t i = 0; i < count_; ++i) + { + size_t idx = (head_ + kMaxMessages - 1 - i) % kMaxMessages; + Record& rec = records_[idx]; + if (rec.text_len == 0) continue; + if (rec.msg_id != msg_id) continue; + if (rec.from != 0) continue; // only update outgoing messages + uint8_t new_status = static_cast(status); + if (rec.status == new_status) { + return true; + } + rec.status = new_status; + persistRecord(static_cast(idx)); + return true; + } + return false; +} + void FlashStore::loadFromPrefs() { uint8_t ver = prefs_.getUChar(kKeyVer, 0); diff --git a/src/chat/infra/store/flash_store.h b/src/chat/infra/store/flash_store.h index 37c986d5..8d644df8 100644 --- a/src/chat/infra/store/flash_store.h +++ b/src/chat/infra/store/flash_store.h @@ -29,6 +29,7 @@ class FlashStore : public IChatStore void setUnread(ChannelId channel, int unread) override; int getUnread(ChannelId channel) const override; void clearChannel(ChannelId channel) override; + bool updateMessageStatus(MessageId msg_id, MessageStatus status) override; std::vector loadAll() const; diff --git a/src/chat/infra/store/log_store.cpp b/src/chat/infra/store/log_store.cpp index 74e58edb..aaa3787e 100644 --- a/src/chat/infra/store/log_store.cpp +++ b/src/chat/infra/store/log_store.cpp @@ -168,6 +168,12 @@ void LogStore::clearChannel(ChannelId channel) } } +bool LogStore::updateMessageStatus(MessageId, MessageStatus) +{ + // Append-only log; no in-place status updates. + return false; +} + bool LogStore::loadIndex(ChannelId ch) { if (!fs_) return false; diff --git a/src/chat/infra/store/log_store.h b/src/chat/infra/store/log_store.h index 95e8deb3..72fee71a 100644 --- a/src/chat/infra/store/log_store.h +++ b/src/chat/infra/store/log_store.h @@ -43,6 +43,7 @@ class LogStore : public IChatStore void setUnread(ChannelId channel, int unread) override; int getUnread(ChannelId channel) const override; void clearChannel(ChannelId channel) override; + bool updateMessageStatus(MessageId msg_id, MessageStatus status) override; private: fs::FS* fs_; diff --git a/src/chat/infra/store/ram_store.cpp b/src/chat/infra/store/ram_store.cpp index b2f34160..3d9828ab 100644 --- a/src/chat/infra/store/ram_store.cpp +++ b/src/chat/infra/store/ram_store.cpp @@ -64,6 +64,26 @@ void RamStore::clearChannel(ChannelId channel) storage.unread_count = 0; } +bool RamStore::updateMessageStatus(MessageId msg_id, MessageStatus status) +{ + if (msg_id == 0) return false; + for (auto& pair : channels_) + { + ChannelStorage& storage = pair.second; + size_t count = storage.messages.count(); + for (size_t i = 0; i < count; ++i) + { + ChatMessage* msg = storage.messages.get(i); + if (!msg) continue; + if (msg->msg_id != msg_id) continue; + if (msg->from != 0) continue; // only update outgoing messages + msg->status = status; + return true; + } + } + return false; +} + RamStore::ChannelStorage& RamStore::getChannelStorage(ChannelId channel) { auto it = channels_.find(channel); diff --git a/src/chat/infra/store/ram_store.h b/src/chat/infra/store/ram_store.h index 9e2ae536..6e06801b 100644 --- a/src/chat/infra/store/ram_store.h +++ b/src/chat/infra/store/ram_store.h @@ -30,6 +30,7 @@ class RamStore : public IChatStore void setUnread(ChannelId channel, int unread) override; int getUnread(ChannelId channel) const override; void clearChannel(ChannelId channel) override; + bool updateMessageStatus(MessageId msg_id, MessageStatus status) override; private: struct ChannelStorage diff --git a/src/chat/ports/i_chat_store.h b/src/chat/ports/i_chat_store.h index a1e1f09c..66c51156 100644 --- a/src/chat/ports/i_chat_store.h +++ b/src/chat/ports/i_chat_store.h @@ -53,6 +53,14 @@ class IChatStore * @param channel Channel ID */ virtual void clearChannel(ChannelId channel) = 0; + + /** + * @brief Update stored message status by message ID + * @param msg_id Message ID + * @param status New status + * @return true if updated + */ + virtual bool updateMessageStatus(MessageId msg_id, MessageStatus status) = 0; }; } // namespace chat diff --git a/src/chat/ports/i_mesh_adapter.h b/src/chat/ports/i_mesh_adapter.h index efb95ba4..82c4e297 100644 --- a/src/chat/ports/i_mesh_adapter.h +++ b/src/chat/ports/i_mesh_adapter.h @@ -36,6 +36,27 @@ class IMeshAdapter */ virtual bool pollIncomingText(MeshIncomingText* out) = 0; + /** + * @brief Send app payload data (non-text) + * @param channel Channel ID + * @param portnum Application port number + * @param payload Payload bytes + * @param len Payload length + * @param dest Destination node (0 for broadcast) + * @param want_ack Request ACK if supported + * @return true if queued successfully + */ + virtual bool sendAppData(ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + NodeId dest = 0, bool want_ack = false) = 0; + + /** + * @brief Poll for incoming app payload data + * @param out Output data (if available) + * @return true if data available + */ + virtual bool pollIncomingData(MeshIncomingData* out) = 0; + /** * @brief Apply mesh configuration * @param config Configuration to apply diff --git a/src/chat/time_utils.h b/src/chat/time_utils.h new file mode 100644 index 00000000..e09c13d5 --- /dev/null +++ b/src/chat/time_utils.h @@ -0,0 +1,41 @@ +/** + * @file time_utils.h + * @brief Time helpers for chat timestamps + */ + +#pragma once + +#include +#include +#include + +namespace chat { + +constexpr uint32_t kMinValidEpochSeconds = 1577836800U; // 2020-01-01 + +inline bool is_valid_epoch(uint32_t ts) +{ + return ts >= kMinValidEpochSeconds; +} + +inline uint32_t now_epoch_seconds() +{ + time_t now = time(nullptr); + if (now < 0) { + return 0; + } + return static_cast(now); +} + +inline uint32_t now_uptime_seconds() +{ + return static_cast(millis() / 1000U); +} + +inline uint32_t now_message_timestamp() +{ + uint32_t now = now_epoch_seconds(); + return is_valid_epoch(now) ? now : now_uptime_seconds(); +} + +} // namespace chat diff --git a/src/chat/usecase/chat_service.cpp b/src/chat/usecase/chat_service.cpp index daf7afc4..9f479829 100644 --- a/src/chat/usecase/chat_service.cpp +++ b/src/chat/usecase/chat_service.cpp @@ -4,6 +4,7 @@ */ #include "chat_service.h" +#include "../time_utils.h" #include "../../sys/event_bus.h" namespace chat @@ -22,36 +23,24 @@ MessageId ChatService::sendText(ChannelId channel, const std::string& text, Node return 0; } + // Try to send via adapter first to get packet ID + MessageId msg_id = 0; + bool queued = adapter_.sendText(channel, text, &msg_id, peer); + ChatMessage msg; msg.channel = channel; msg.from = 0; // Local message msg.peer = peer; - msg.timestamp = millis() / 1000; // Convert to seconds + msg.msg_id = msg_id; + msg.timestamp = now_message_timestamp(); msg.text = text; - msg.status = MessageStatus::Queued; + msg.status = queued ? MessageStatus::Queued : MessageStatus::Failed; // Queue in model model_.onSendQueued(msg); - - // Try to send via adapter - MessageId msg_id = 0; - if (adapter_.sendText(channel, text, &msg_id, peer)) + if (!queued && msg_id != 0) { - // Update message ID if adapter provided one - if (msg_id != 0) - { - // Find and update message (simplified - in real impl might need better tracking) - const ChatMessage* found = model_.getMessage(msg_id); - if (found) - { - // Message ID already set - } - } - } - else - { - // Send failed, mark as failed - model_.onSendResult(msg.msg_id, false); + model_.onSendResult(msg_id, false); } // Store message @@ -139,9 +128,13 @@ void ChatService::processIncoming() ChatMessage msg; msg.channel = incoming.channel; msg.from = incoming.from; - msg.peer = incoming.from; + if (incoming.to == 0xFFFFFFFF || incoming.to == 0) { + msg.peer = 0; + } else { + msg.peer = incoming.from; + } msg.msg_id = incoming.msg_id; - msg.timestamp = incoming.timestamp ? incoming.timestamp : (millis() / 1000); + msg.timestamp = incoming.timestamp ? incoming.timestamp : now_message_timestamp(); msg.text = incoming.text; msg.status = MessageStatus::Incoming; @@ -158,4 +151,14 @@ void ChatService::processIncoming() } } +void ChatService::handleSendResult(MessageId msg_id, bool ok) +{ + if (msg_id == 0) return; + model_.onSendResult(msg_id, ok); + if (!ok) + { + store_.updateMessageStatus(msg_id, MessageStatus::Failed); + } +} + } // namespace chat diff --git a/src/chat/usecase/chat_service.h b/src/chat/usecase/chat_service.h index ce56add4..b626fbc5 100644 --- a/src/chat/usecase/chat_service.h +++ b/src/chat/usecase/chat_service.h @@ -72,6 +72,13 @@ class ChatService */ void processIncoming(); + /** + * @brief Handle send result (ack/timeout) + * @param msg_id Message ID + * @param ok true if sent successfully + */ + void handleSendResult(MessageId msg_id, bool ok); + /** * @brief Get current channel */ diff --git a/src/main.cpp b/src/main.cpp index ec1e03b4..4a712731 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,6 +20,7 @@ extern "C" extern const lv_image_dsc_t Chat; extern const lv_image_dsc_t Setting; extern const lv_image_dsc_t contact; + extern const lv_image_dsc_t team_icon; extern const lv_image_dsc_t shutdown; // Note: img_usb is already declared in images.h (C++ linkage) } @@ -30,11 +31,18 @@ extern "C" // Forward declarations for app entry functions (implemented in ui_*.cpp) void ui_gps_enter(lv_obj_t* parent); +void ui_gps_exit(lv_obj_t* parent); void ui_chat_enter(lv_obj_t* parent); +void ui_chat_exit(lv_obj_t* parent); void ui_contacts_enter(lv_obj_t* parent); +void ui_contacts_exit(lv_obj_t* parent); +void ui_team_enter(lv_obj_t* parent); +void ui_team_exit(lv_obj_t* parent); void ui_setting_enter(lv_obj_t* parent); +void ui_setting_exit(lv_obj_t* parent); #ifdef ARDUINO_USB_MODE void ui_usb_enter(lv_obj_t* parent); +void ui_usb_exit(lv_obj_t* parent); #endif // GPS data access - now provided by GpsService @@ -81,29 +89,37 @@ typedef struct void* user_data; } app_t; +static app_t* s_active_app = nullptr; + // App entry functions (implemented in ui_*.cpp files, global scope) app_t ui_gps_main = { .setup_func_cb = ui_gps_enter, - .exit_func_cb = nullptr, + .exit_func_cb = ui_gps_exit, .user_data = nullptr, }; app_t ui_chat_main = { .setup_func_cb = ui_chat_enter, - .exit_func_cb = nullptr, + .exit_func_cb = ui_chat_exit, .user_data = nullptr, }; app_t ui_contacts_main = { .setup_func_cb = ui_contacts_enter, - .exit_func_cb = nullptr, + .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_setting_main = { .setup_func_cb = ui_setting_enter, - .exit_func_cb = nullptr, + .exit_func_cb = ui_setting_exit, .user_data = nullptr, }; @@ -124,21 +140,21 @@ app_t ui_shutdown_main = { #ifdef ARDUINO_USB_MODE app_t ui_usb_main = { .setup_func_cb = ui_usb_enter, - .exit_func_cb = nullptr, + .exit_func_cb = ui_usb_exit, .user_data = nullptr, }; #endif #ifdef ARDUINO_USB_MODE -const char* kAppNames[6] = {"GPS", "Chat", "Contacts", "USB Mass Storage", "Setting", "Shutdown"}; -const lv_image_dsc_t* kAppImages[6] = {&gps_icon, &Chat, &contact, &img_usb, &Setting, &shutdown}; -app_t* kAppFuncs[6] = {&ui_gps_main, &ui_chat_main, &ui_contacts_main, &ui_usb_main, &ui_setting_main, &ui_shutdown_main}; -#define NUM_APPS 6 +const char* kAppNames[7] = {"GPS", "Chat", "Contacts", "Team", "USB Mass Storage", "Setting", "Shutdown"}; +const lv_image_dsc_t* kAppImages[7] = {&gps_icon, &Chat, &contact, &team_icon, &img_usb, &Setting, &shutdown}; +app_t* kAppFuncs[7] = {&ui_gps_main, &ui_chat_main, &ui_contacts_main, &ui_team_main, &ui_usb_main, &ui_setting_main, &ui_shutdown_main}; +#define NUM_APPS 7 #else -const char* kAppNames[5] = {"GPS", "Chat", "Contacts", "Setting", "Shutdown"}; -const lv_image_dsc_t* kAppImages[5] = {&gps_icon, &Chat, &contact, &Setting, &shutdown}; -app_t* kAppFuncs[5] = {&ui_gps_main, &ui_chat_main, &ui_contacts_main, &ui_setting_main, &ui_shutdown_main}; -#define NUM_APPS 5 +const char* kAppNames[6] = {"GPS", "Chat", "Contacts", "Team", "Setting", "Shutdown"}; +const lv_image_dsc_t* kAppImages[6] = {&gps_icon, &Chat, &contact, &team_icon, &Setting, &shutdown}; +app_t* kAppFuncs[6] = {&ui_gps_main, &ui_chat_main, &ui_contacts_main, &ui_team_main, &ui_setting_main, &ui_shutdown_main}; +#define NUM_APPS 6 #endif #if LVGL_VERSION_MAJOR == 9 @@ -205,9 +221,13 @@ static void create_app(lv_obj_t* parent, const char* name, const lv_image_dsc_t* } 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; menu_hidden(); } }, LV_EVENT_CLICKED, app_fun); @@ -229,6 +249,10 @@ 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 diff --git a/src/sys/event_bus.h b/src/sys/event_bus.h index 79ad8486..b8aee983 100644 --- a/src/sys/event_bus.h +++ b/src/sys/event_bus.h @@ -10,6 +10,9 @@ #include "freertos/semphr.h" #include #include +#include + +#include "../team/domain/team_events.h" namespace sys { @@ -25,6 +28,14 @@ enum class EventType ChatChannelSwitched, // Channel switched NodeInfoUpdate, // Node info updated (from mesh network) NodeProtocolUpdate, // Node protocol update (from message) + TeamAdvertise, // Team advertise received + TeamJoinRequest, // Team join request received + TeamJoinAccept, // Team join accept received + TeamJoinConfirm, // Team join confirm received + TeamStatus, // Team status received + TeamPosition, // Team position received + TeamWaypoint, // Team waypoint received + TeamError, // Team protocol error InputEvent, // Input event (keyboard/rotary) SystemTick // System tick (for periodic tasks) }; @@ -138,6 +149,94 @@ struct NodeProtocolUpdateEvent : public Event : Event(EventType::NodeProtocolUpdate), node_id(id), timestamp(ts), protocol(proto) {} }; +/** + * @brief Team advertise event + */ +struct TeamAdvertiseEvent : public Event +{ + team::TeamAdvertiseEvent data; + + explicit TeamAdvertiseEvent(const team::TeamAdvertiseEvent& evt) + : Event(EventType::TeamAdvertise), data(evt) {} +}; + +/** + * @brief Team join request event + */ +struct TeamJoinRequestEvent : public Event +{ + team::TeamJoinRequestEvent data; + + explicit TeamJoinRequestEvent(const team::TeamJoinRequestEvent& evt) + : Event(EventType::TeamJoinRequest), data(evt) {} +}; + +/** + * @brief Team join accept event + */ +struct TeamJoinAcceptEvent : public Event +{ + team::TeamJoinAcceptEvent data; + + explicit TeamJoinAcceptEvent(const team::TeamJoinAcceptEvent& evt) + : Event(EventType::TeamJoinAccept), data(evt) {} +}; + +/** + * @brief Team join confirm event + */ +struct TeamJoinConfirmEvent : public Event +{ + team::TeamJoinConfirmEvent data; + + explicit TeamJoinConfirmEvent(const team::TeamJoinConfirmEvent& evt) + : Event(EventType::TeamJoinConfirm), data(evt) {} +}; + +/** + * @brief Team status event + */ +struct TeamStatusEvent : public Event +{ + team::TeamStatusEvent data; + + explicit TeamStatusEvent(const team::TeamStatusEvent& evt) + : Event(EventType::TeamStatus), data(evt) {} +}; + +/** + * @brief Team position event + */ +struct TeamPositionEvent : public Event +{ + team::TeamPositionEvent data; + + explicit TeamPositionEvent(const team::TeamPositionEvent& evt) + : Event(EventType::TeamPosition), data(evt) {} +}; + +/** + * @brief Team waypoint event + */ +struct TeamWaypointEvent : public Event +{ + team::TeamWaypointEvent data; + + explicit TeamWaypointEvent(const team::TeamWaypointEvent& evt) + : Event(EventType::TeamWaypoint), data(evt) {} +}; + +/** + * @brief Team error event + */ +struct TeamErrorEvent : public Event +{ + team::TeamErrorEvent data; + + explicit TeamErrorEvent(const team::TeamErrorEvent& evt) + : Event(EventType::TeamError), data(evt) {} +}; + /** * @brief Input event */ diff --git a/src/sys/ringbuf.h b/src/sys/ringbuf.h index 93796bc4..43418db5 100644 --- a/src/sys/ringbuf.h +++ b/src/sys/ringbuf.h @@ -59,6 +59,21 @@ class RingBuffer return &buffer_[pos]; } + /** + * @brief Get mutable element at index (0 = oldest, count-1 = newest) + * @param index Index + * @return Pointer to element, or nullptr if index out of range + */ + T* get(size_t index) + { + if (index >= count_) + { + return nullptr; + } + size_t pos = (tail_ + index) % N; + return &buffer_[pos]; + } + /** * @brief Get the newest element * @return Pointer to newest element, or nullptr if empty diff --git a/src/team/domain/team_events.h b/src/team/domain/team_events.h new file mode 100644 index 00000000..05cf7c28 --- /dev/null +++ b/src/team/domain/team_events.h @@ -0,0 +1,76 @@ +#pragma once + +#include "team_types.h" +#include "../protocol/team_mgmt.h" +#include +#include + +namespace team +{ + +struct TeamEventContext +{ + TeamId team_id{}; + uint32_t key_id = 0; + uint32_t from = 0; + uint32_t timestamp = 0; +}; + +struct TeamAdvertiseEvent +{ + TeamEventContext ctx; + team::proto::TeamAdvertise msg; +}; + +struct TeamJoinRequestEvent +{ + TeamEventContext ctx; + team::proto::TeamJoinRequest msg; +}; + +struct TeamJoinAcceptEvent +{ + TeamEventContext ctx; + team::proto::TeamJoinAccept msg; +}; + +struct TeamJoinConfirmEvent +{ + TeamEventContext ctx; + team::proto::TeamJoinConfirm msg; +}; + +struct TeamStatusEvent +{ + TeamEventContext ctx; + team::proto::TeamStatus msg; +}; + +struct TeamPositionEvent +{ + TeamEventContext ctx; + std::vector payload; +}; + +struct TeamWaypointEvent +{ + TeamEventContext ctx; + std::vector payload; +}; + +enum class TeamProtocolError +{ + DecryptFail, + DecodeFail, + KeyMismatch, + UnknownVersion +}; + +struct TeamErrorEvent +{ + TeamEventContext ctx; + TeamProtocolError error; + uint32_t portnum = 0; +}; + +} // namespace team diff --git a/src/team/domain/team_types.h b/src/team/domain/team_types.h new file mode 100644 index 00000000..c6ed7454 --- /dev/null +++ b/src/team/domain/team_types.h @@ -0,0 +1,24 @@ +#pragma once + +#include "../protocol/team_wire.h" +#include +#include + +namespace team +{ + +constexpr size_t kTeamKeySize = 32; + +using TeamId = std::array; + +struct TeamKeys +{ + TeamId team_id{}; + uint32_t key_id = 0; + std::array mgmt_key{}; + std::array pos_key{}; + std::array wp_key{}; + bool valid = false; +}; + +} // namespace team diff --git a/src/team/infra/crypto/team_crypto.cpp b/src/team/infra/crypto/team_crypto.cpp new file mode 100644 index 00000000..ac2fd294 --- /dev/null +++ b/src/team/infra/crypto/team_crypto.cpp @@ -0,0 +1,139 @@ +#include "team_crypto.h" + +#include +#include +#include + +namespace team::infra +{ +namespace +{ + +constexpr size_t kChaChaTagSize = 16; + +bool sha256Kdf(const uint8_t* key, size_t key_len, const char* info, + uint8_t* out, size_t out_len) +{ + if (!key || !info || !out || out_len > 32) + { + return false; + } + + SHA256 hash; + hash.reset(); + hash.update(key, key_len); + hash.update(reinterpret_cast(info), strlen(info)); + uint8_t digest[32]; + hash.finalize(digest, sizeof(digest)); + memcpy(out, digest, out_len); + return true; +} + +bool chachaEncrypt(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, + std::vector& out_cipher) +{ + if (!key || !nonce || !plain) + { + return false; + } + + ChaChaPoly cipher; + if (!cipher.setKey(key, key_len)) + { + return false; + } + if (!cipher.setIV(nonce, nonce_len)) + { + return false; + } + + if (aad_len > 0 && aad) + { + cipher.addAuthData(aad, aad_len); + } + + out_cipher.assign(plain_len + kChaChaTagSize, 0); + if (plain_len > 0) + { + cipher.encrypt(out_cipher.data(), plain, plain_len); + } + cipher.computeTag(out_cipher.data() + plain_len, kChaChaTagSize); + return true; +} + +bool chachaDecrypt(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, + std::vector& out_plain) +{ + if (!key || !nonce || !cipher || cipher_len < kChaChaTagSize) + { + return false; + } + + ChaChaPoly cipher_ctx; + if (!cipher_ctx.setKey(key, key_len)) + { + return false; + } + if (!cipher_ctx.setIV(nonce, nonce_len)) + { + return false; + } + + if (aad_len > 0 && aad) + { + cipher_ctx.addAuthData(aad, aad_len); + } + + size_t plain_len = cipher_len - kChaChaTagSize; + out_plain.assign(plain_len, 0); + if (plain_len > 0) + { + cipher_ctx.decrypt(out_plain.data(), cipher, plain_len); + } + + const uint8_t* tag = cipher + plain_len; + if (!cipher_ctx.checkTag(tag, kChaChaTagSize)) + { + out_plain.clear(); + return false; + } + + return true; +} + +} // namespace + +bool TeamCrypto::deriveKey(const uint8_t* key, size_t key_len, + const char* info, + uint8_t* out, size_t out_len) +{ + return sha256Kdf(key, key_len, info, out, out_len); +} + +bool TeamCrypto::aeadEncrypt(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, + std::vector& out_cipher) +{ + return chachaEncrypt(key, key_len, nonce, nonce_len, + aad, aad_len, plain, plain_len, out_cipher); +} + +bool TeamCrypto::aeadDecrypt(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, + std::vector& out_plain) +{ + return chachaDecrypt(key, key_len, nonce, nonce_len, + aad, aad_len, cipher, cipher_len, out_plain); +} + +} // namespace team::infra diff --git a/src/team/infra/crypto/team_crypto.h b/src/team/infra/crypto/team_crypto.h new file mode 100644 index 00000000..86fdbaf6 --- /dev/null +++ b/src/team/infra/crypto/team_crypto.h @@ -0,0 +1,28 @@ +#pragma once + +#include "../../ports/i_team_crypto.h" + +namespace team::infra +{ + +class TeamCrypto : public team::ITeamCrypto +{ + public: + bool deriveKey(const uint8_t* key, size_t key_len, + const char* info, + uint8_t* out, size_t out_len) override; + + bool aeadEncrypt(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, + std::vector& out_cipher) override; + + bool aeadDecrypt(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, + std::vector& out_plain) override; +}; + +} // namespace team::infra diff --git a/src/team/infra/event/team_event_bus_sink.cpp b/src/team/infra/event/team_event_bus_sink.cpp new file mode 100644 index 00000000..55fec37c --- /dev/null +++ b/src/team/infra/event/team_event_bus_sink.cpp @@ -0,0 +1,48 @@ +#include "team_event_bus_sink.h" + +#include "../../../sys/event_bus.h" + +namespace team::infra +{ + +void TeamEventBusSink::onTeamAdvertise(const TeamAdvertiseEvent& event) +{ + sys::EventBus::publish(new sys::TeamAdvertiseEvent(event), 0); +} + +void TeamEventBusSink::onTeamJoinRequest(const TeamJoinRequestEvent& event) +{ + sys::EventBus::publish(new sys::TeamJoinRequestEvent(event), 0); +} + +void TeamEventBusSink::onTeamJoinAccept(const TeamJoinAcceptEvent& event) +{ + sys::EventBus::publish(new sys::TeamJoinAcceptEvent(event), 0); +} + +void TeamEventBusSink::onTeamJoinConfirm(const TeamJoinConfirmEvent& event) +{ + sys::EventBus::publish(new sys::TeamJoinConfirmEvent(event), 0); +} + +void TeamEventBusSink::onTeamStatus(const TeamStatusEvent& event) +{ + sys::EventBus::publish(new sys::TeamStatusEvent(event), 0); +} + +void TeamEventBusSink::onTeamPosition(const TeamPositionEvent& event) +{ + sys::EventBus::publish(new sys::TeamPositionEvent(event), 0); +} + +void TeamEventBusSink::onTeamWaypoint(const TeamWaypointEvent& event) +{ + sys::EventBus::publish(new sys::TeamWaypointEvent(event), 0); +} + +void TeamEventBusSink::onTeamError(const TeamErrorEvent& event) +{ + sys::EventBus::publish(new sys::TeamErrorEvent(event), 0); +} + +} // namespace team::infra diff --git a/src/team/infra/event/team_event_bus_sink.h b/src/team/infra/event/team_event_bus_sink.h new file mode 100644 index 00000000..d8bebc02 --- /dev/null +++ b/src/team/infra/event/team_event_bus_sink.h @@ -0,0 +1,21 @@ +#pragma once + +#include "../../ports/i_team_event_sink.h" + +namespace team::infra +{ + +class TeamEventBusSink : public team::ITeamEventSink +{ + public: + void onTeamAdvertise(const TeamAdvertiseEvent& event) override; + void onTeamJoinRequest(const TeamJoinRequestEvent& event) override; + void onTeamJoinAccept(const TeamJoinAcceptEvent& event) override; + void onTeamJoinConfirm(const TeamJoinConfirmEvent& event) override; + void onTeamStatus(const TeamStatusEvent& event) override; + void onTeamPosition(const TeamPositionEvent& event) override; + void onTeamWaypoint(const TeamWaypointEvent& event) override; + void onTeamError(const TeamErrorEvent& event) override; +}; + +} // namespace team::infra diff --git a/src/team/ports/i_team_crypto.h b/src/team/ports/i_team_crypto.h new file mode 100644 index 00000000..43a1c15b --- /dev/null +++ b/src/team/ports/i_team_crypto.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +namespace team +{ + +class ITeamCrypto +{ + public: + virtual ~ITeamCrypto() = default; + + virtual bool deriveKey(const uint8_t* key, size_t key_len, + const char* info, + uint8_t* out, size_t out_len) = 0; + + virtual bool aeadEncrypt(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, + std::vector& out_cipher) = 0; + + virtual bool aeadDecrypt(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, + std::vector& out_plain) = 0; +}; + +} // namespace team diff --git a/src/team/ports/i_team_event_sink.h b/src/team/ports/i_team_event_sink.h new file mode 100644 index 00000000..4edf5881 --- /dev/null +++ b/src/team/ports/i_team_event_sink.h @@ -0,0 +1,23 @@ +#pragma once + +#include "../domain/team_events.h" + +namespace team +{ + +class ITeamEventSink +{ + public: + virtual ~ITeamEventSink() = default; + + virtual void onTeamAdvertise(const TeamAdvertiseEvent& event) = 0; + virtual void onTeamJoinRequest(const TeamJoinRequestEvent& event) = 0; + virtual void onTeamJoinAccept(const TeamJoinAcceptEvent& event) = 0; + virtual void onTeamJoinConfirm(const TeamJoinConfirmEvent& 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 onTeamError(const TeamErrorEvent& event) = 0; +}; + +} // namespace team diff --git a/src/team/protocol/team_mgmt.cpp b/src/team/protocol/team_mgmt.cpp new file mode 100644 index 00000000..b6f76458 --- /dev/null +++ b/src/team/protocol/team_mgmt.cpp @@ -0,0 +1,380 @@ +#include "team_mgmt.h" + +#include + +namespace team::proto +{ +namespace +{ + +struct ByteWriter +{ + explicit ByteWriter(std::vector& out_ref) : out(out_ref) {} + + void putU8(uint8_t v) { out.push_back(v); } + void putU16(uint16_t v) + { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + } + void putU32(uint32_t v) + { + for (int i = 0; i < 4; ++i) + { + out.push_back(static_cast((v >> (8 * i)) & 0xFF)); + } + } + void putU64(uint64_t v) + { + for (int i = 0; i < 8; ++i) + { + out.push_back(static_cast((v >> (8 * i)) & 0xFF)); + } + } + void putBytes(const uint8_t* data, size_t len) + { + out.insert(out.end(), data, data + len); + } + + std::vector& out; +}; + +struct ByteReader +{ + ByteReader(const uint8_t* data_ref, size_t len_ref) : data(data_ref), len(len_ref) {} + + bool getU8(uint8_t* out_val) + { + if (!ensure(1)) return false; + *out_val = data[pos++]; + return true; + } + + bool getU16(uint16_t* out_val) + { + if (!ensure(2)) return false; + *out_val = static_cast(data[pos]) | + static_cast(data[pos + 1] << 8); + pos += 2; + return true; + } + + bool getU32(uint32_t* out_val) + { + if (!ensure(4)) return false; + uint32_t v = 0; + for (int i = 0; i < 4; ++i) + { + v |= static_cast(data[pos + i]) << (8 * i); + } + *out_val = v; + pos += 4; + return true; + } + + bool getU64(uint64_t* out_val) + { + if (!ensure(8)) return false; + uint64_t v = 0; + for (int i = 0; i < 8; ++i) + { + v |= static_cast(data[pos + i]) << (8 * i); + } + *out_val = v; + pos += 8; + return true; + } + + bool getBytes(uint8_t* out_buf, size_t out_len) + { + if (!ensure(out_len)) return false; + memcpy(out_buf, data + pos, out_len); + pos += out_len; + return true; + } + + bool ensure(size_t count) const { return (pos + count) <= len; } + + const uint8_t* data; + size_t len; + size_t pos = 0; +}; + +bool encodeTeamParams(const TeamParams& params, ByteWriter& writer) +{ + if (!params.has_params) + { + return true; + } + writer.putU32(params.position_interval_ms); + writer.putU8(params.precision_level); + writer.putU32(params.flags); + return true; +} + +bool decodeTeamParams(ByteReader& reader, TeamParams* params) +{ + if (!params) + { + return false; + } + params->has_params = true; + if (!reader.getU32(¶ms->position_interval_ms)) return false; + if (!reader.getU8(¶ms->precision_level)) return false; + if (!reader.getU32(¶ms->flags)) return false; + return true; +} + +} // namespace + +bool encodeTeamMgmtMessage(TeamMgmtType type, + const std::vector& payload, + std::vector& out) +{ + if (payload.size() > 0xFFFF) + { + return false; + } + out.clear(); + ByteWriter writer(out); + writer.putU8(kTeamMgmtVersion); + writer.putU8(static_cast(type)); + writer.putU16(0); // reserved + writer.putU16(static_cast(payload.size())); + if (!payload.empty()) + { + writer.putBytes(payload.data(), payload.size()); + } + return true; +} + +bool decodeTeamMgmtMessage(const uint8_t* data, size_t len, + uint8_t* out_version, + TeamMgmtType* out_type, + std::vector& out_payload) +{ + if (!data || !out_version || !out_type) + { + return false; + } + ByteReader reader(data, len); + uint16_t reserved = 0; + uint16_t payload_len = 0; + uint8_t type_raw = 0; + + if (!reader.getU8(out_version)) return false; + if (!reader.getU8(&type_raw)) return false; + if (!reader.getU16(&reserved)) return false; + if (!reader.getU16(&payload_len)) return false; + if (!reader.ensure(payload_len)) return false; + + out_payload.assign(data + reader.pos, data + reader.pos + payload_len); + *out_type = static_cast(type_raw); + return true; +} + +bool encodeTeamAdvertise(const TeamAdvertise& input, std::vector& out) +{ + out.clear(); + ByteWriter writer(out); + writer.putBytes(input.team_id.data(), input.team_id.size()); + + uint16_t flags = 0; + if (input.has_join_hint) flags |= 0x01; + if (input.has_channel_index) flags |= 0x02; + if (input.has_expires_at) flags |= 0x04; + writer.putU16(flags); + + if (input.has_join_hint) writer.putU32(input.join_hint); + if (input.has_channel_index) writer.putU8(input.channel_index); + if (input.has_expires_at) writer.putU64(input.expires_at); + writer.putU64(input.nonce); + return true; +} + +bool decodeTeamAdvertise(const uint8_t* data, size_t len, TeamAdvertise* out) +{ + if (!data || !out) + { + return false; + } + ByteReader reader(data, len); + uint16_t flags = 0; + + if (!reader.getBytes(out->team_id.data(), out->team_id.size())) return false; + if (!reader.getU16(&flags)) return false; + + out->has_join_hint = (flags & 0x01) != 0; + out->has_channel_index = (flags & 0x02) != 0; + out->has_expires_at = (flags & 0x04) != 0; + + if (out->has_join_hint && !reader.getU32(&out->join_hint)) return false; + if (out->has_channel_index && !reader.getU8(&out->channel_index)) return false; + if (out->has_expires_at && !reader.getU64(&out->expires_at)) return false; + if (!reader.getU64(&out->nonce)) return false; + return true; +} + +bool encodeTeamJoinRequest(const TeamJoinRequest& input, std::vector& out) +{ + if (input.has_member_pub && input.member_pub_len > input.member_pub.size()) + { + return false; + } + out.clear(); + ByteWriter writer(out); + writer.putBytes(input.team_id.data(), input.team_id.size()); + + uint16_t flags = 0; + if (input.has_member_pub) flags |= 0x01; + if (input.has_capabilities) flags |= 0x02; + writer.putU16(flags); + + if (input.has_member_pub) + { + writer.putU8(input.member_pub_len); + writer.putBytes(input.member_pub.data(), input.member_pub_len); + } + if (input.has_capabilities) writer.putU32(input.capabilities); + writer.putU64(input.nonce); + return true; +} + +bool decodeTeamJoinRequest(const uint8_t* data, size_t len, TeamJoinRequest* out) +{ + if (!data || !out) + { + return false; + } + ByteReader reader(data, len); + uint16_t flags = 0; + if (!reader.getBytes(out->team_id.data(), out->team_id.size())) return false; + if (!reader.getU16(&flags)) return false; + + out->has_member_pub = (flags & 0x01) != 0; + out->has_capabilities = (flags & 0x02) != 0; + + if (out->has_member_pub) + { + uint8_t pub_len = 0; + if (!reader.getU8(&pub_len)) return false; + if (pub_len > out->member_pub.size()) return false; + out->member_pub_len = pub_len; + if (!reader.getBytes(out->member_pub.data(), pub_len)) return false; + } + if (out->has_capabilities && !reader.getU32(&out->capabilities)) return false; + if (!reader.getU64(&out->nonce)) return false; + return true; +} + +bool encodeTeamJoinAccept(const TeamJoinAccept& input, std::vector& out) +{ + if (input.channel_psk_len > input.channel_psk.size()) + { + return false; + } + out.clear(); + ByteWriter writer(out); + writer.putU8(input.channel_index); + writer.putU8(input.channel_psk_len); + writer.putBytes(input.channel_psk.data(), input.channel_psk_len); + writer.putU32(input.key_id); + + uint16_t flags = 0; + if (input.params.has_params) flags |= 0x01; + writer.putU16(flags); + if (!encodeTeamParams(input.params, writer)) return false; + + return true; +} + +bool decodeTeamJoinAccept(const uint8_t* data, size_t len, TeamJoinAccept* out) +{ + if (!data || !out) + { + return false; + } + ByteReader reader(data, len); + uint16_t flags = 0; + + if (!reader.getU8(&out->channel_index)) 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; + if (!reader.getU32(&out->key_id)) return false; + if (!reader.getU16(&flags)) return false; + + out->params.has_params = (flags & 0x01) != 0; + if (out->params.has_params && !decodeTeamParams(reader, &out->params)) return false; + return true; +} + +bool encodeTeamJoinConfirm(const TeamJoinConfirm& input, std::vector& out) +{ + out.clear(); + ByteWriter writer(out); + writer.putU8(input.ok ? 1 : 0); + + uint16_t flags = 0; + if (input.has_capabilities) flags |= 0x01; + if (input.has_battery) flags |= 0x02; + writer.putU16(flags); + + if (input.has_capabilities) writer.putU32(input.capabilities); + if (input.has_battery) writer.putU8(input.battery); + return true; +} + +bool decodeTeamJoinConfirm(const uint8_t* data, size_t len, TeamJoinConfirm* out) +{ + if (!data || !out) + { + return false; + } + ByteReader reader(data, len); + uint16_t flags = 0; + uint8_t ok = 0; + if (!reader.getU8(&ok)) return false; + out->ok = (ok != 0); + if (!reader.getU16(&flags)) return false; + + out->has_capabilities = (flags & 0x01) != 0; + out->has_battery = (flags & 0x02) != 0; + + if (out->has_capabilities && !reader.getU32(&out->capabilities)) return false; + if (out->has_battery && !reader.getU8(&out->battery)) return false; + return true; +} + +bool encodeTeamStatus(const TeamStatus& input, std::vector& out) +{ + out.clear(); + ByteWriter writer(out); + writer.putBytes(input.member_list_hash.data(), input.member_list_hash.size()); + writer.putU32(input.key_id); + + uint16_t flags = 0; + if (input.params.has_params) flags |= 0x01; + writer.putU16(flags); + if (!encodeTeamParams(input.params, writer)) return false; + return true; +} + +bool decodeTeamStatus(const uint8_t* data, size_t len, TeamStatus* out) +{ + if (!data || !out) + { + return false; + } + ByteReader reader(data, len); + uint16_t flags = 0; + if (!reader.getBytes(out->member_list_hash.data(), out->member_list_hash.size())) return false; + if (!reader.getU32(&out->key_id)) return false; + if (!reader.getU16(&flags)) return false; + + out->params.has_params = (flags & 0x01) != 0; + if (out->params.has_params && !decodeTeamParams(reader, &out->params)) return false; + return true; +} + +} // namespace team::proto diff --git a/src/team/protocol/team_mgmt.h b/src/team/protocol/team_mgmt.h new file mode 100644 index 00000000..7f9508a1 --- /dev/null +++ b/src/team/protocol/team_mgmt.h @@ -0,0 +1,107 @@ +#pragma once + +#include "team_wire.h" +#include +#include +#include +#include + +namespace team::proto +{ + +constexpr uint8_t kTeamMgmtVersion = 1; +constexpr size_t kTeamMemberListHashSize = 32; +constexpr size_t kTeamChannelPskSize = 16; + +enum class TeamMgmtType : uint8_t +{ + Advertise = 1, + JoinRequest = 2, + JoinAccept = 3, + JoinConfirm = 4, + Status = 5, + Rotate = 6, + Leave = 7, + Disband = 8 +}; + +struct TeamParams +{ + uint32_t position_interval_ms = 0; + uint8_t precision_level = 0; + uint32_t flags = 0; + bool has_params = false; +}; + +struct TeamAdvertise +{ + std::array team_id{}; + uint32_t join_hint = 0; + uint8_t channel_index = 0; + uint64_t expires_at = 0; + uint64_t nonce = 0; + bool has_join_hint = false; + bool has_channel_index = false; + bool has_expires_at = false; +}; + +struct TeamJoinRequest +{ + std::array team_id{}; + std::array member_pub{}; + uint8_t member_pub_len = 0; + uint32_t capabilities = 0; + uint64_t nonce = 0; + bool has_member_pub = false; + bool has_capabilities = false; +}; + +struct TeamJoinAccept +{ + uint8_t channel_index = 0; + std::array channel_psk{}; + uint8_t channel_psk_len = 0; + uint32_t key_id = 0; + TeamParams params; +}; + +struct TeamJoinConfirm +{ + bool ok = false; + uint32_t capabilities = 0; + uint8_t battery = 0; + bool has_capabilities = false; + bool has_battery = false; +}; + +struct TeamStatus +{ + std::array member_list_hash{}; + uint32_t key_id = 0; + TeamParams params; +}; + +bool encodeTeamMgmtMessage(TeamMgmtType type, + const std::vector& payload, + std::vector& out); +bool decodeTeamMgmtMessage(const uint8_t* data, size_t len, + uint8_t* out_version, + TeamMgmtType* out_type, + std::vector& out_payload); + +bool encodeTeamAdvertise(const TeamAdvertise& input, std::vector& out); +bool decodeTeamAdvertise(const uint8_t* data, size_t len, TeamAdvertise* out); + +bool encodeTeamJoinRequest(const TeamJoinRequest& input, std::vector& out); +bool decodeTeamJoinRequest(const uint8_t* data, size_t len, TeamJoinRequest* out); + +bool encodeTeamJoinAccept(const TeamJoinAccept& input, std::vector& out); +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 encodeTeamStatus(const TeamStatus& input, std::vector& out); +bool decodeTeamStatus(const uint8_t* data, size_t len, TeamStatus* out); + +} // namespace team::proto diff --git a/src/team/protocol/team_portnum.h b/src/team/protocol/team_portnum.h new file mode 100644 index 00000000..9c3bd073 --- /dev/null +++ b/src/team/protocol/team_portnum.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace team::proto +{ + +constexpr uint32_t TEAM_MGMT_APP = 300; +constexpr uint32_t TEAM_POSITION_APP = 301; +constexpr uint32_t TEAM_WAYPOINT_APP = 302; + +} // namespace team::proto diff --git a/src/team/protocol/team_wire.cpp b/src/team/protocol/team_wire.cpp new file mode 100644 index 00000000..15c8b849 --- /dev/null +++ b/src/team/protocol/team_wire.cpp @@ -0,0 +1,140 @@ +#include "team_wire.h" + +#include + +namespace team::proto +{ +namespace +{ + +struct ByteWriter +{ + explicit ByteWriter(std::vector& out_ref) : out(out_ref) {} + + void putU8(uint8_t v) { out.push_back(v); } + void putU16(uint16_t v) + { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + } + void putU32(uint32_t v) + { + for (int i = 0; i < 4; ++i) + { + out.push_back(static_cast((v >> (8 * i)) & 0xFF)); + } + } + void putBytes(const uint8_t* data, size_t len) + { + out.insert(out.end(), data, data + len); + } + + std::vector& out; +}; + +struct ByteReader +{ + ByteReader(const uint8_t* data_ref, size_t len_ref) : data(data_ref), len(len_ref) {} + + bool getU8(uint8_t* out_val) + { + if (!ensure(1)) return false; + *out_val = data[pos++]; + return true; + } + + bool getU16(uint16_t* out_val) + { + if (!ensure(2)) return false; + *out_val = static_cast(data[pos]) | + static_cast(data[pos + 1] << 8); + pos += 2; + return true; + } + + bool getU32(uint32_t* out_val) + { + if (!ensure(4)) return false; + uint32_t v = 0; + for (int i = 0; i < 4; ++i) + { + v |= static_cast(data[pos + i]) << (8 * i); + } + *out_val = v; + pos += 4; + return true; + } + + bool getBytes(uint8_t* out_buf, size_t out_len) + { + if (!ensure(out_len)) return false; + memcpy(out_buf, data + pos, out_len); + pos += out_len; + return true; + } + + bool skip(size_t count) + { + if (!ensure(count)) return false; + pos += count; + return true; + } + + bool ensure(size_t count) const { return (pos + count) <= len; } + + const uint8_t* data; + size_t len; + size_t pos = 0; +}; + +} // namespace + +bool encodeTeamEncrypted(const TeamEncrypted& input, std::vector& out) +{ + if (input.ciphertext.size() > 0xFFFF) + { + return false; + } + + out.clear(); + ByteWriter writer(out); + writer.putU8(input.version); + writer.putU8(input.aad_flags); + writer.putU16(0); // reserved + writer.putU32(input.key_id); + writer.putBytes(input.team_id.data(), input.team_id.size()); + writer.putBytes(input.nonce.data(), input.nonce.size()); + writer.putU16(static_cast(input.ciphertext.size())); + if (!input.ciphertext.empty()) + { + writer.putBytes(input.ciphertext.data(), input.ciphertext.size()); + } + + return true; +} + +bool decodeTeamEncrypted(const uint8_t* data, size_t len, TeamEncrypted* out) +{ + if (!data || !out) + { + return false; + } + + ByteReader reader(data, len); + uint16_t reserved = 0; + uint16_t cipher_len = 0; + + if (!reader.getU8(&out->version)) return false; + if (!reader.getU8(&out->aad_flags)) return false; + if (!reader.getU16(&reserved)) return false; + if (!reader.getU32(&out->key_id)) return false; + if (!reader.getBytes(out->team_id.data(), out->team_id.size())) return false; + if (!reader.getBytes(out->nonce.data(), out->nonce.size())) return false; + if (!reader.getU16(&cipher_len)) return false; + if (!reader.ensure(cipher_len)) return false; + + out->ciphertext.assign(data + reader.pos, data + reader.pos + cipher_len); + return true; +} + +} // namespace team::proto diff --git a/src/team/protocol/team_wire.h b/src/team/protocol/team_wire.h new file mode 100644 index 00000000..b0abdc67 --- /dev/null +++ b/src/team/protocol/team_wire.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +namespace team::proto +{ + +constexpr size_t kTeamIdSize = 8; +constexpr size_t kTeamNonceSize = 12; +constexpr uint8_t kTeamEnvelopeVersion = 1; + +struct TeamEncrypted +{ + uint8_t version = kTeamEnvelopeVersion; + uint8_t aad_flags = 0; + uint32_t key_id = 0; + std::array team_id{}; + std::array nonce{}; + std::vector ciphertext; +}; + +bool encodeTeamEncrypted(const TeamEncrypted& input, std::vector& out); +bool decodeTeamEncrypted(const uint8_t* data, size_t len, TeamEncrypted* out); + +} // namespace team::proto diff --git a/src/team/usecase/team_controller.cpp b/src/team/usecase/team_controller.cpp new file mode 100644 index 00000000..76670910 --- /dev/null +++ b/src/team/usecase/team_controller.cpp @@ -0,0 +1,71 @@ +#include "team_controller.h" + +namespace team +{ + +TeamController::TeamController(TeamService& service) + : service_(service) +{ +} + +void TeamController::setKeys(const TeamKeys& keys) +{ + service_.setKeys(keys); +} + +void TeamController::clearKeys() +{ + service_.clearKeys(); + state_ = TeamUiState::Idle; +} + +bool TeamController::onCreateTeam(const team::proto::TeamAdvertise& advertise, + chat::ChannelId channel) +{ + bool ok = service_.sendAdvertise(advertise, channel); + if (ok) + { + state_ = TeamUiState::Active; + } + return ok; +} + +bool TeamController::onJoinTeam(const team::proto::TeamJoinRequest& join_request, + chat::ChannelId channel, chat::NodeId dest) +{ + bool ok = service_.sendJoinRequest(join_request, channel, dest); + if (ok) + { + state_ = TeamUiState::PendingJoin; + } + return ok; +} + +bool TeamController::onAcceptJoin(const team::proto::TeamJoinAccept& accept, + chat::ChannelId channel, chat::NodeId dest) +{ + bool ok = service_.sendJoinAccept(accept, channel, dest); + if (ok) + { + state_ = TeamUiState::Active; + } + return ok; +} + +bool TeamController::onConfirmJoin(const team::proto::TeamJoinConfirm& confirm, + chat::ChannelId channel, chat::NodeId dest) +{ + bool ok = service_.sendJoinConfirm(confirm, channel, dest); + if (ok) + { + state_ = TeamUiState::Active; + } + return ok; +} + +void TeamController::resetUiState() +{ + state_ = TeamUiState::Idle; +} + +} // namespace team diff --git a/src/team/usecase/team_controller.h b/src/team/usecase/team_controller.h new file mode 100644 index 00000000..6e45fc20 --- /dev/null +++ b/src/team/usecase/team_controller.h @@ -0,0 +1,43 @@ +#pragma once + +#include "../../chat/domain/chat_types.h" +#include "../domain/team_types.h" +#include "../protocol/team_mgmt.h" +#include "team_service.h" + +namespace team +{ + +enum class TeamUiState +{ + Idle, + PendingJoin, + Active +}; + +class TeamController +{ + public: + explicit TeamController(TeamService& service); + + void setKeys(const TeamKeys& keys); + void clearKeys(); + + bool onCreateTeam(const team::proto::TeamAdvertise& advertise, + chat::ChannelId channel); + 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); + + TeamUiState getState() const { return state_; } + void resetUiState(); + + private: + TeamService& service_; + TeamUiState state_ = TeamUiState::Idle; +}; + +} // namespace team diff --git a/src/team/usecase/team_service.cpp b/src/team/usecase/team_service.cpp new file mode 100644 index 00000000..1feb36d1 --- /dev/null +++ b/src/team/usecase/team_service.cpp @@ -0,0 +1,470 @@ +#include "team_service.h" + +#include "../domain/team_events.h" +#include "../protocol/team_mgmt.h" +#include "../protocol/team_portnum.h" +#include "../protocol/team_wire.h" +#include + +namespace team +{ +namespace +{ + +std::vector buildAad(const team::proto::TeamEncrypted& envelope) +{ + std::vector aad; + aad.reserve(1 + 1 + 4 + envelope.team_id.size()); + aad.push_back(envelope.version); + aad.push_back(envelope.aad_flags); + + uint32_t key_id = envelope.key_id; + for (int i = 0; i < 4; ++i) + { + aad.push_back(static_cast((key_id >> (8 * i)) & 0xFF)); + } + + aad.insert(aad.end(), envelope.team_id.begin(), envelope.team_id.end()); + return aad; +} + +team::TeamEventContext makeContext(const chat::MeshIncomingData& data, + const team::proto::TeamEncrypted* envelope) +{ + team::TeamEventContext ctx; + if (envelope) + { + ctx.team_id = envelope->team_id; + ctx.key_id = envelope->key_id; + } + ctx.from = data.from; + ctx.timestamp = millis() / 1000; + return ctx; +} + +team::TeamEventContext makeContext(const chat::MeshIncomingData& data, + const team::TeamId& team_id) +{ + team::TeamEventContext ctx; + ctx.team_id = team_id; + ctx.key_id = 0; + ctx.from = data.from; + ctx.timestamp = millis() / 1000; + return ctx; +} + +void fillNonce(std::array& nonce) +{ + for (size_t i = 0; i < nonce.size(); ++i) + { + nonce[i] = static_cast(random(0, 256)); + } +} + +} // namespace + +TeamService::TeamService(team::ITeamCrypto& crypto, + chat::IMeshAdapter& mesh, + team::ITeamEventSink& sink) + : crypto_(crypto), mesh_(mesh), sink_(sink) +{ +} + +void TeamService::setKeys(const TeamKeys& keys) +{ + keys_ = keys; + keys_.valid = true; +} + +void TeamService::clearKeys() +{ + keys_ = TeamKeys{}; +} + +void TeamService::processIncoming() +{ + chat::MeshIncomingData data; + while (mesh_.pollIncomingData(&data)) + { + if (data.portnum == team::proto::TEAM_MGMT_APP) + { + team::proto::TeamEncrypted envelope; + std::vector plain; + bool decoded_encrypted = + decodeEncryptedPayload(data, keys_.mgmt_key.data(), keys_.mgmt_key.size(), + &envelope, plain, false); + + uint8_t version = 0; + team::proto::TeamMgmtType type = team::proto::TeamMgmtType::Advertise; + std::vector payload; + + if (decoded_encrypted) + { + if (!team::proto::decodeTeamMgmtMessage( + plain.data(), plain.size(), + &version, &type, payload)) + { + emitError(data, TeamProtocolError::DecodeFail, &envelope); + continue; + } + if (version != team::proto::kTeamMgmtVersion) + { + emitError(data, TeamProtocolError::UnknownVersion, &envelope); + continue; + } + } + else + { + if (!team::proto::decodeTeamMgmtMessage( + data.payload.data(), data.payload.size(), + &version, &type, payload)) + { + continue; + } + if (version != team::proto::kTeamMgmtVersion) + { + continue; + } + } + + switch (type) + { + case team::proto::TeamMgmtType::Advertise: + { + team::proto::TeamAdvertise msg; + if (!team::proto::decodeTeamAdvertise(payload.data(), payload.size(), &msg)) + { + emitError(data, TeamProtocolError::DecodeFail, + decoded_encrypted ? &envelope : nullptr); + break; + } + TeamAdvertiseEvent event{makeContext(data, msg.team_id), msg}; + sink_.onTeamAdvertise(event); + break; + } + case team::proto::TeamMgmtType::JoinRequest: + { + team::proto::TeamJoinRequest msg; + if (!team::proto::decodeTeamJoinRequest(payload.data(), payload.size(), &msg)) + { + emitError(data, TeamProtocolError::DecodeFail, + decoded_encrypted ? &envelope : nullptr); + break; + } + 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)) + { + emitError(data, TeamProtocolError::DecodeFail, + decoded_encrypted ? &envelope : nullptr); + break; + } + TeamJoinAcceptEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg}; + sink_.onTeamJoinAccept(event); + break; + } + case team::proto::TeamMgmtType::JoinConfirm: + { + if (!decoded_encrypted) + { + break; + } + team::proto::TeamJoinConfirm msg; + if (!team::proto::decodeTeamJoinConfirm(payload.data(), payload.size(), &msg)) + { + emitError(data, TeamProtocolError::DecodeFail, + decoded_encrypted ? &envelope : nullptr); + break; + } + TeamJoinConfirmEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg}; + sink_.onTeamJoinConfirm(event); + break; + } + case team::proto::TeamMgmtType::Status: + { + if (!decoded_encrypted) + { + break; + } + team::proto::TeamStatus msg; + if (!team::proto::decodeTeamStatus(payload.data(), payload.size(), &msg)) + { + emitError(data, TeamProtocolError::DecodeFail, + decoded_encrypted ? &envelope : nullptr); + break; + } + TeamStatusEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg}; + sink_.onTeamStatus(event); + break; + } + default: + break; + } + } + else if (data.portnum == team::proto::TEAM_POSITION_APP) + { + team::proto::TeamEncrypted envelope; + std::vector plain; + if (!decodeEncryptedPayload(data, keys_.pos_key.data(), keys_.pos_key.size(), + &envelope, plain, true)) + { + continue; + } + + TeamPositionEvent event{makeContext(data, &envelope), plain}; + sink_.onTeamPosition(event); + } + else if (data.portnum == team::proto::TEAM_WAYPOINT_APP) + { + team::proto::TeamEncrypted envelope; + std::vector plain; + if (!decodeEncryptedPayload(data, keys_.wp_key.data(), keys_.wp_key.size(), + &envelope, plain, true)) + { + continue; + } + + TeamWaypointEvent event{makeContext(data, &envelope), plain}; + sink_.onTeamWaypoint(event); + } + } +} + +bool TeamService::sendAdvertise(const team::proto::TeamAdvertise& msg, + chat::ChannelId channel) +{ + std::vector payload; + if (!team::proto::encodeTeamAdvertise(msg, payload)) + { + return false; + } + return sendMgmtPlain(team::proto::TeamMgmtType::Advertise, payload, channel, 0); +} + +bool TeamService::sendJoinRequest(const team::proto::TeamJoinRequest& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + std::vector payload; + if (!team::proto::encodeTeamJoinRequest(msg, payload)) + { + return false; + } + return sendMgmtPlain(team::proto::TeamMgmtType::JoinRequest, payload, channel, dest); +} + +bool TeamService::sendJoinAccept(const team::proto::TeamJoinAccept& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + std::vector payload; + if (!team::proto::encodeTeamJoinAccept(msg, payload)) + { + return false; + } + return sendMgmtEncrypted(team::proto::TeamMgmtType::JoinAccept, payload, channel, dest); +} + +bool TeamService::sendJoinConfirm(const team::proto::TeamJoinConfirm& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + std::vector payload; + if (!team::proto::encodeTeamJoinConfirm(msg, payload)) + { + return false; + } + return sendMgmtEncrypted(team::proto::TeamMgmtType::JoinConfirm, payload, channel, dest); +} + +bool TeamService::sendStatus(const team::proto::TeamStatus& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + std::vector payload; + if (!team::proto::encodeTeamStatus(msg, payload)) + { + return false; + } + return sendMgmtEncrypted(team::proto::TeamMgmtType::Status, payload, channel, dest); +} + +bool TeamService::sendPosition(const std::vector& payload, + chat::ChannelId channel) +{ + if (!keys_.valid) + { + return false; + } + + team::proto::TeamEncrypted envelope; + std::vector wire; + if (!encodeEncryptedPayload(payload, keys_.pos_key.data(), keys_.pos_key.size(), + &envelope, wire)) + { + return false; + } + return mesh_.sendAppData(channel, team::proto::TEAM_POSITION_APP, + wire.data(), wire.size(), 0, false); +} + +bool TeamService::sendWaypoint(const std::vector& payload, + chat::ChannelId channel) +{ + if (!keys_.valid) + { + return false; + } + + team::proto::TeamEncrypted envelope; + std::vector wire; + if (!encodeEncryptedPayload(payload, keys_.wp_key.data(), keys_.wp_key.size(), + &envelope, wire)) + { + return false; + } + return mesh_.sendAppData(channel, team::proto::TEAM_WAYPOINT_APP, + wire.data(), wire.size(), 0, false); +} + +bool TeamService::decodeEncryptedPayload(const chat::MeshIncomingData& data, + const uint8_t* key, size_t key_len, + team::proto::TeamEncrypted* envelope, + std::vector& out_plain, + bool emit_errors) +{ + if (!envelope) + { + return false; + } + if (!team::proto::decodeTeamEncrypted(data.payload.data(), + data.payload.size(), + envelope)) + { + if (emit_errors) + { + emitError(data, TeamProtocolError::DecodeFail, nullptr); + } + return false; + } + if (envelope->version != team::proto::kTeamEnvelopeVersion) + { + if (emit_errors) + { + emitError(data, TeamProtocolError::UnknownVersion, envelope); + } + return false; + } + if (!keys_.valid || + envelope->team_id != keys_.team_id || + envelope->key_id != keys_.key_id) + { + if (emit_errors) + { + emitError(data, TeamProtocolError::KeyMismatch, envelope); + } + return false; + } + + std::vector aad = buildAad(*envelope); + if (!crypto_.aeadDecrypt(key, key_len, + envelope->nonce.data(), envelope->nonce.size(), + aad.data(), aad.size(), + envelope->ciphertext.data(), envelope->ciphertext.size(), + out_plain)) + { + if (emit_errors) + { + emitError(data, TeamProtocolError::DecryptFail, envelope); + } + return false; + } + return true; +} + +bool TeamService::encodeEncryptedPayload(const std::vector& plain, + const uint8_t* key, size_t key_len, + team::proto::TeamEncrypted* envelope, + std::vector& out_wire) +{ + if (!envelope || !keys_.valid) + { + return false; + } + + envelope->version = team::proto::kTeamEnvelopeVersion; + envelope->aad_flags = 0; + envelope->team_id = keys_.team_id; + envelope->key_id = keys_.key_id; + fillNonce(envelope->nonce); + + std::vector aad = buildAad(*envelope); + if (!crypto_.aeadEncrypt(key, key_len, + envelope->nonce.data(), envelope->nonce.size(), + aad.data(), aad.size(), + plain.data(), plain.size(), + envelope->ciphertext)) + { + return false; + } + + return team::proto::encodeTeamEncrypted(*envelope, out_wire); +} + +bool TeamService::sendMgmtPlain(const team::proto::TeamMgmtType type, + const std::vector& payload, + chat::ChannelId channel, chat::NodeId dest) +{ + std::vector wire; + if (!team::proto::encodeTeamMgmtMessage(type, payload, wire)) + { + return false; + } + return mesh_.sendAppData(channel, team::proto::TEAM_MGMT_APP, + wire.data(), wire.size(), dest, false); +} + +bool TeamService::sendMgmtEncrypted(const team::proto::TeamMgmtType type, + const std::vector& payload, + chat::ChannelId channel, chat::NodeId dest) +{ + if (!keys_.valid) + { + return false; + } + + std::vector mgmt_wire; + if (!team::proto::encodeTeamMgmtMessage(type, payload, mgmt_wire)) + { + return false; + } + + team::proto::TeamEncrypted envelope; + std::vector wire; + if (!encodeEncryptedPayload(mgmt_wire, keys_.mgmt_key.data(), keys_.mgmt_key.size(), + &envelope, wire)) + { + return false; + } + + return mesh_.sendAppData(channel, team::proto::TEAM_MGMT_APP, + wire.data(), wire.size(), dest, false); +} + +void TeamService::emitError(const chat::MeshIncomingData& data, + team::TeamProtocolError error, + const team::proto::TeamEncrypted* envelope) +{ + TeamErrorEvent event; + event.ctx = makeContext(data, envelope); + event.error = error; + event.portnum = data.portnum; + sink_.onTeamError(event); +} + +} // namespace team diff --git a/src/team/usecase/team_service.h b/src/team/usecase/team_service.h new file mode 100644 index 00000000..32eed16c --- /dev/null +++ b/src/team/usecase/team_service.h @@ -0,0 +1,68 @@ +#pragma once + +#include "../../chat/ports/i_mesh_adapter.h" +#include "../domain/team_types.h" +#include "../ports/i_team_crypto.h" +#include "../ports/i_team_event_sink.h" +#include + +namespace team +{ + +class TeamService +{ + public: + TeamService(team::ITeamCrypto& crypto, + chat::IMeshAdapter& mesh, + team::ITeamEventSink& sink); + + void setKeys(const TeamKeys& keys); + void clearKeys(); + + void processIncoming(); + + bool sendAdvertise(const team::proto::TeamAdvertise& msg, + chat::ChannelId channel); + bool sendJoinRequest(const team::proto::TeamJoinRequest& msg, + chat::ChannelId channel, chat::NodeId dest = 0); + bool sendJoinAccept(const team::proto::TeamJoinAccept& msg, + chat::ChannelId channel, chat::NodeId dest); + bool sendJoinConfirm(const team::proto::TeamJoinConfirm& msg, + chat::ChannelId channel, chat::NodeId dest = 0); + bool sendStatus(const team::proto::TeamStatus& msg, + chat::ChannelId channel, chat::NodeId dest = 0); + bool sendPosition(const std::vector& payload, + chat::ChannelId channel); + bool sendWaypoint(const std::vector& payload, + chat::ChannelId channel); + + private: + team::ITeamCrypto& crypto_; + chat::IMeshAdapter& mesh_; + team::ITeamEventSink& sink_; + TeamKeys keys_{}; + + bool decodeEncryptedPayload(const chat::MeshIncomingData& data, + const uint8_t* key, size_t key_len, + team::proto::TeamEncrypted* envelope, + std::vector& out_plain, + bool emit_errors); + + bool encodeEncryptedPayload(const std::vector& plain, + const uint8_t* key, size_t key_len, + team::proto::TeamEncrypted* envelope, + std::vector& out_wire); + + bool sendMgmtPlain(const team::proto::TeamMgmtType type, + const std::vector& payload, + chat::ChannelId channel, chat::NodeId dest); + bool sendMgmtEncrypted(const team::proto::TeamMgmtType type, + const std::vector& payload, + chat::ChannelId channel, chat::NodeId dest); + + void emitError(const chat::MeshIncomingData& data, + team::TeamProtocolError error, + const team::proto::TeamEncrypted* envelope); +}; + +} // namespace team diff --git a/src/ui/assets/team.c b/src/ui/assets/team.c new file mode 100644 index 00000000..ddd68a5f --- /dev/null +++ b/src/ui/assets/team.c @@ -0,0 +1,165 @@ +#ifdef __has_include + #if __has_include("lvgl.h") + #ifndef LV_LVGL_H_INCLUDE_SIMPLE + #define LV_LVGL_H_INCLUDE_SIMPLE + #endif + #endif +#endif + +#if defined(LV_LVGL_H_INCLUDE_SIMPLE) + #include "lvgl.h" +#else + #include "lvgl/lvgl.h" +#endif + + +#ifndef LV_ATTRIBUTE_MEM_ALIGN +#define LV_ATTRIBUTE_MEM_ALIGN +#endif + +#ifndef LV_ATTRIBUTE_IMAGE_TEAM +#define LV_ATTRIBUTE_IMAGE_TEAM +#endif + +const LV_ATTRIBUTE_MEM_ALIGN LV_ATTRIBUTE_LARGE_CONST LV_ATTRIBUTE_IMAGE_TEAM uint8_t team_map[] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xb1, 0x9c, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xef, 0x83, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0x38, 0xef, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x76, 0xd6, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xf4, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x76, 0xd6, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xb1, 0x9c, 0x0c, 0x63, 0xec, 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xec, 0x62, 0x0c, 0x63, 0x90, 0x94, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xec, 0x62, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xb7, 0xde, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xb7, 0xde, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x2b, 0x6b, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xb7, 0xde, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xef, 0x83, 0x3b, 0xef, 0x10, 0x84, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x68, 0xac, 0x44, 0xfe, 0xa8, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xb7, 0xde, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xef, 0x83, 0xdd, 0xff, 0xdd, 0xff, 0x3b, 0xef, 0x34, 0xad, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x49, 0xa4, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xa8, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xb7, 0xde, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xef, 0x83, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x3b, 0xef, 0x34, 0xad, 0x51, 0x8c, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x49, 0xa4, 0x49, 0xa4, 0x46, 0xd5, 0x66, 0xd5, 0xe5, 0xed, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xa8, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xb7, 0xde, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xef, 0x83, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xea, 0x8b, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xa8, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xb7, 0xde, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xef, 0x83, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0xcb, 0x83, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xa8, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, + 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xb7, 0xde, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xef, 0x83, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0xcb, 0x83, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xa8, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, + 0xec, 0x62, 0x0c, 0x63, 0x6d, 0x73, 0xb7, 0xde, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xef, 0x83, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0xcb, 0x83, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xa8, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x59, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0xec, 0x62, + 0x0c, 0x63, 0x0c, 0x63, 0x15, 0xc6, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xef, 0x83, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0xcb, 0x83, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xa8, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x73, 0xb5, 0x0c, 0x63, 0x0c, 0x63, + 0xec, 0x62, 0x0c, 0x63, 0x2f, 0x8c, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xef, 0x83, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0xeb, 0x8b, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xe5, 0xed, 0xa8, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x6b, 0x73, 0x49, 0x9c, 0x07, 0xc5, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xa8, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, + 0xec, 0x62, 0x0c, 0x63, 0x0c, 0x63, 0xd1, 0x9c, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xef, 0x83, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x99, 0xde, 0x0c, 0x63, 0x0c, 0x63, 0xeb, 0x8b, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x86, 0xdd, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6b, 0x73, 0xa8, 0xb4, 0x86, 0xdd, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xa8, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xfa, 0xff, 0xfa, 0xff, 0xfa, 0xff, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, + 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x2f, 0x8c, 0xb7, 0xde, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0x4d, 0x6b, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x8e, 0x73, 0x0c, 0x63, 0xec, 0x8b, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x86, 0xdd, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x74, 0xb5, 0x99, 0xde, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6b, 0x73, 0x6b, 0x73, 0x49, 0x9c, 0x07, 0xc5, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x49, 0x9c, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xb7, 0xde, 0x2f, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x00, 0x00, + 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x74, 0xb5, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x0c, 0x63, 0x0c, 0x63, 0x86, 0xdd, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x86, 0xdd, 0x0c, 0x63, 0x0c, 0x63, 0x8c, 0x73, 0x99, 0xde, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x6b, 0x73, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x86, 0xdd, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x74, 0xb5, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x3b, 0xef, 0x0c, 0x63, 0x0c, 0x63, 0xe5, 0xed, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x86, 0xdd, 0x0c, 0x63, 0x0c, 0x63, 0x8c, 0x73, 0x99, 0xde, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0xca, 0x8b, 0x07, 0xc5, 0xa8, 0xb4, 0x49, 0x9c, 0x07, 0xc5, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xe5, 0xed, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x74, 0xb5, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x8e, 0x73, 0x0c, 0x63, 0x4a, 0x9c, 0x44, 0xfe, 0x44, 0xfe, 0x07, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x8d, 0x73, 0x99, 0xde, 0xdd, 0xff, 0x3b, 0xef, 0xf6, 0xc5, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x49, 0x9c, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0xca, 0x8b, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x6b, 0x73, 0x6b, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x8d, 0x73, 0x99, 0xde, 0xdd, 0xff, 0xdd, 0xff, 0x30, 0x8c, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x49, 0x9c, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x86, 0xdd, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xd2, 0x9c, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x99, 0xde, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x49, 0x9c, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x49, 0x9c, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xec, 0x62, 0x0c, 0x63, 0x0c, 0x63, 0x99, 0xde, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x3b, 0xef, 0xf6, 0xc5, 0x74, 0xb5, 0x99, 0xde, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x49, 0x9c, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x6b, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xd2, 0x9c, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x49, 0x9c, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x07, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x6d, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x3b, 0xef, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x49, 0x9c, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x44, 0xfe, 0x49, 0x9c, 0x0c, 0x63, 0xec, 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xd2, 0x9c, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x99, 0xde, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x49, 0x9c, 0x44, 0xfe, 0x44, 0xfe, 0xe5, 0xed, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xf6, 0xc5, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x49, 0x9c, 0x44, 0xfe, 0xca, 0x8b, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0xec, 0x62, 0x0c, 0x63, 0x8e, 0x73, 0x30, 0x8c, 0x0c, 0x63, 0x8e, 0x73, 0xd2, 0x9c, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x6b, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0x3b, 0xef, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xec, 0x62, 0x0c, 0x63, 0x4a, 0x73, 0x66, 0xb4, 0x66, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x74, 0xb5, 0x99, 0xde, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0xc5, 0xc4, 0xe1, 0xfd, 0xe1, 0xfd, 0x29, 0x9c, 0x0c, 0x63, 0x30, 0x8c, 0xdd, 0xff, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x3b, 0xef, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x66, 0xb4, 0xe1, 0xfd, 0xe1, 0xfd, 0x08, 0x9c, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x74, 0xb5, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0xd2, 0x9c, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xec, 0x62, 0x0c, 0x63, 0x4b, 0x73, 0x08, 0x9c, 0x66, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xd2, 0x9c, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x4a, 0x73, 0x66, 0xb4, 0x66, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0xf6, 0xc5, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x0c, 0x63, 0x0c, 0x63, 0xc5, 0xc4, 0xe1, 0xfd, 0xe1, 0xfd, 0x08, 0x9c, 0x0c, 0x63, 0x30, 0x8c, 0xdd, 0xff, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x66, 0xb4, 0xe1, 0xfd, 0xe1, 0xfd, 0x08, 0x9c, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x74, 0xb5, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xec, 0x62, 0x0c, 0x63, 0x2b, 0x6b, 0x66, 0xb4, 0x67, 0xac, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x4a, 0x73, 0xc5, 0xc4, 0x66, 0xb4, 0x0c, 0x63, 0x0c, 0x63, 0xf6, 0xc5, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x74, 0xb5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x3b, 0xef, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x0c, 0x63, 0x0c, 0x63, 0x23, 0xdd, 0xe1, 0xfd, 0xe1, 0xfd, 0xca, 0x8b, 0x0c, 0x63, 0xd2, 0x9c, 0xdd, 0xff, 0xf6, 0xc5, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x30, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xdd, 0xff, 0xdd, 0xff, 0xdd, 0xff, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xc5, 0xc4, 0xe1, 0xfd, 0xe1, 0xfd, 0x6b, 0x73, 0x0c, 0x63, 0xd2, 0x9c, 0xdd, 0xff, 0xdd, 0xff, 0x30, 0x8c, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0x3b, 0xef, 0xdd, 0xff, 0xdd, 0xff, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xf6, 0xc5, 0xd2, 0x9c, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xec, 0x62, 0x0c, 0x63, 0x2b, 0x6b, 0x08, 0x9c, 0x4a, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0xf6, 0xc5, 0xdd, 0xff, 0xdd, 0xff, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xd2, 0x9c, 0x8e, 0x73, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x8e, 0x73, 0xd2, 0x9c, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x0c, 0x63, 0xec, 0x62, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x63, 0x0c, 0x63, 0xec, 0x62, 0x0c, 0x63, 0x0c, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7f, 0x9f, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0x9f, 0x9f, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xdf, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xf3, 0xff, 0xff, 0xdf, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xef, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xe3, 0xff, 0xff, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0xff, 0xff, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xeb, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0xff, 0xff, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf3, 0xff, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xe7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x9f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x20, 0x40, 0x60, 0x7f, 0xff, 0xff, 0xff, 0xef, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xf7, 0xff, 0xff, 0xdf, 0x9f, 0x60, 0x40, 0x20, 0x00, 0x20, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe7, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xef, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, 0xe7, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xef, 0xdb, 0xd7, 0xff, 0xff, 0xff, 0xff, 0xdf, 0xcf, 0xcf, 0xcf, 0xe7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xaf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, 0x00, + 0x00, 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf2, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0x00, + 0x40, 0xff, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf6, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x40, + 0xbf, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf6, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf6, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe3, + 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfd, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, + 0x60, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc3, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x60, + 0x00, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xeb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0x00, + 0x00, 0x20, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0x00, 0x00, + 0x00, 0x00, 0x20, 0x7f, 0xff, 0xff, 0xdf, 0x7f, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xdf, 0x7f, 0xdf, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xdf, 0xff, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfb, 0xff, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xbf, 0xff, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x9f, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xef, 0xff, 0xef, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x9f, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfb, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xff, 0xef, 0xff, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfb, 0xff, 0xff, 0xff, 0xff, 0x9f, 0xff, 0xff, 0xff, 0xff, 0xff, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xdf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfb, 0xff, 0xff, 0xff, 0xff, 0x9f, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x20, 0xbf, 0xff, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7f, 0xbf, 0x7f, 0x9f, 0xff, 0xff, 0xff, 0xff, 0xff, 0x60, 0x00, 0x20, 0x9f, 0xbf, 0x9f, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7f, 0xbf, 0x7f, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +const lv_image_dsc_t team_icon = { + .header.magic = LV_IMAGE_HEADER_MAGIC, + .header.cf = LV_COLOR_FORMAT_RGB565A8, + .header.flags = 0, + .header.w = 64, + .header.h = 64, + .header.stride = 128, + .data_size = sizeof(team_map), + .data = team_map, +}; diff --git a/src/ui/screens/chat/chat_compose_components.cpp b/src/ui/screens/chat/chat_compose_components.cpp index a9b5d06a..ce96833f 100644 --- a/src/ui/screens/chat/chat_compose_components.cpp +++ b/src/ui/screens/chat/chat_compose_components.cpp @@ -13,6 +13,8 @@ namespace chat::ui { +static constexpr size_t kMaxInputBytes = 233; + struct ChatComposeScreen::Impl { chat::ui::compose::layout::Spec spec; @@ -32,6 +34,12 @@ static void set_btn_label_white(lv_obj_t* btn) ChatComposeScreen::ChatComposeScreen(lv_obj_t* parent, chat::ConversationId conv) : conv_(conv) { + lv_obj_t* active = lv_screen_active(); + if (!active) { + Serial.printf("[ChatCompose] WARNING: lv_screen_active() is null\n"); + } else { + Serial.printf("[ChatCompose] init: active=%p parent=%p\n", active, parent); + } impl_ = new Impl(); @@ -42,7 +50,7 @@ ChatComposeScreen::ChatComposeScreen(lv_obj_t* parent, chat::ConversationId conv lv_textarea_set_placeholder_text(impl_->w.textarea, ""); lv_textarea_set_one_line(impl_->w.textarea, false); - lv_textarea_set_max_length(impl_->w.textarea, 220); + lv_textarea_set_max_length(impl_->w.textarea, kMaxInputBytes); lv_obj_add_event_cb(impl_->w.send_btn, on_action_click, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(impl_->w.cancel_btn, on_action_click, LV_EVENT_CLICKED, this); @@ -55,6 +63,13 @@ ChatComposeScreen::ChatComposeScreen(lv_obj_t* parent, chat::ConversationId conv input::bind_textarea_events(impl_->w, this, on_key, on_text_changed); input::setup_default_group_focus(impl_->w); + if (impl_->w.container && !lv_obj_is_valid(impl_->w.container)) { + Serial.printf("[ChatCompose] WARNING: container invalid\n"); + } + if (impl_->w.textarea && !lv_obj_is_valid(impl_->w.textarea)) { + Serial.printf("[ChatCompose] WARNING: textarea invalid\n"); + } + refresh_len(); } @@ -154,9 +169,10 @@ void ChatComposeScreen::refresh_len() const char* text = lv_textarea_get_text(impl_->w.textarea); size_t len = text ? strlen(text) : 0; + size_t remaining = (len < kMaxInputBytes) ? (kMaxInputBytes - len) : 0; char buf[16]; - snprintf(buf, sizeof(buf), "Len: %u", static_cast(len)); + snprintf(buf, sizeof(buf), "Remain: %u", static_cast(remaining)); lv_label_set_text(impl_->w.len_label, buf); } diff --git a/src/ui/screens/chat/chat_compose_layout.cpp b/src/ui/screens/chat/chat_compose_layout.cpp index 450ffd35..08c57152 100644 --- a/src/ui/screens/chat/chat_compose_layout.cpp +++ b/src/ui/screens/chat/chat_compose_layout.cpp @@ -83,7 +83,7 @@ void create(lv_obj_t* parent, const Spec& spec, Widgets& w) { lv_obj_clear_flag(spacer, LV_OBJ_FLAG_SCROLLABLE); w.len_label = lv_label_create(w.action_bar); - lv_label_set_text(w.len_label, "Len: 0"); + lv_label_set_text(w.len_label, "Remain: 233"); } } // namespace chat::ui::compose::layout diff --git a/src/ui/screens/chat/chat_conversation_components.cpp b/src/ui/screens/chat/chat_conversation_components.cpp index 4e789b82..e02cb0cd 100644 --- a/src/ui/screens/chat/chat_conversation_components.cpp +++ b/src/ui/screens/chat/chat_conversation_components.cpp @@ -5,11 +5,12 @@ #include "chat_conversation_components.h" #include "../ui_common.h" +#include "../../../app/app_context.h" #include "chat_conversation_layout.h" #include "chat_conversation_styles.h" #include "chat_conversation_input.h" -#include +#include #include #include @@ -19,11 +20,86 @@ namespace ui { // Keep original constant meaning (bubble max width and 70% rule) namespace { constexpr lv_coord_t kBubbleMaxWidth = 322; // same as original +constexpr uint32_t kSecondsPerDay = 24U * 60U * 60U; +constexpr uint32_t kSecondsPerMonth = 30U * kSecondsPerDay; +constexpr uint32_t kSecondsPerYear = 365U * kSecondsPerDay; +constexpr uint32_t kMinValidEpochSeconds = 1577836800U; // 2020-01-01 } // namespace +static bool is_valid_epoch_ts(uint32_t ts) +{ + return ts >= kMinValidEpochSeconds; +} + +static void format_hms(char* out, size_t out_len, uint32_t seconds) +{ + unsigned h = static_cast(seconds / 3600U); + unsigned m = static_cast((seconds / 60U) % 60U); + unsigned s = static_cast(seconds % 60U); + snprintf(out, out_len, "%02u:%02u:%02u", h, m, s); +} + +static void format_message_time(char* out, size_t out_len, uint32_t ts) +{ + if (!out || out_len == 0) return; + if (ts == 0) { + snprintf(out, out_len, "--"); + return; + } + + uint32_t now_epoch = static_cast(time(nullptr)); + bool ts_is_epoch = is_valid_epoch_ts(ts); + bool now_is_epoch = is_valid_epoch_ts(now_epoch); + uint32_t now_secs = now_is_epoch ? now_epoch : static_cast(millis() / 1000U); + if (ts_is_epoch && !now_is_epoch) { + ts_is_epoch = false; + } + if (now_secs < ts) { + now_secs = ts; + } + uint32_t diff = now_secs - ts; + + if (diff >= kSecondsPerYear) { + uint32_t years = diff / kSecondsPerYear; + if (years == 0) years = 1; + snprintf(out, out_len, "%uy ago", static_cast(years)); + return; + } + if (diff >= kSecondsPerMonth) { + uint32_t months = diff / kSecondsPerMonth; + if (months == 0) months = 1; + snprintf(out, out_len, "%um ago", static_cast(months)); + return; + } + if (diff >= kSecondsPerDay) { + uint32_t days = diff / kSecondsPerDay; + if (days == 0) days = 1; + snprintf(out, out_len, "%ud ago", static_cast(days)); + return; + } + if (ts_is_epoch) { + time_t t = ui_apply_timezone_offset(static_cast(ts)); + struct tm* info = gmtime(&t); + if (info) { + strftime(out, out_len, "%H:%M:%S", info); + } else { + snprintf(out, out_len, "--"); + } + return; + } + format_hms(out, out_len, ts % kSecondsPerDay); +} + ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::ConversationId conv) : conv_(conv) { + lv_obj_t* active = lv_screen_active(); + if (!active) { + Serial.printf("[ChatConversation] WARNING: lv_screen_active() is null\n"); + } else { + Serial.printf("[ChatConversation] init: active=%p parent=%p\n", active, parent); + } + // ----- Layout ----- auto w = chat::ui::layout::create_conversation_base(parent); container_ = w.root; @@ -48,6 +124,13 @@ ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::Conversat ::ui::widgets::top_bar_set_right_text(top_bar_, ""); ::ui::widgets::top_bar_set_back_callback(top_bar_, handle_back, this); + if (container_ && !lv_obj_is_valid(container_)) { + Serial.printf("[ChatConversation] WARNING: container invalid\n"); + } + if (msg_list_ && !lv_obj_is_valid(msg_list_)) { + Serial.printf("[ChatConversation] WARNING: msg_list invalid\n"); + } + // ----- Event (unchanged) ----- lv_obj_add_event_cb(reply_btn_, action_event_cb, LV_EVENT_CLICKED, this); @@ -124,7 +207,7 @@ void ChatConversationScreen::createMessageItem(const chat::ChatMessage& msg) MessageItem item; item.msg = msg; - // ----- Layout: row + bubble + text ----- + // ----- Layout: row + bubble + time + text + status ----- item.container = chat::ui::layout::create_message_row(msg_list_); chat::ui::conversation::styles::apply_message_row(item.container); @@ -147,10 +230,46 @@ void ChatConversationScreen::createMessageItem(const chat::ChatMessage& msg) chat::ui::conversation::styles::apply_bubble(bubble, is_self); chat::ui::layout::set_bubble_max_width(bubble, max_bubble_w); + item.time_label = chat::ui::layout::create_bubble_time(bubble); + chat::ui::conversation::styles::apply_bubble_time(item.time_label); + char time_buf[16]; + format_message_time(time_buf, sizeof(time_buf), msg.timestamp); + if (conv_.peer == 0) { + std::string sender; + if (msg.from == 0) { + sender = app::AppContext::getInstance().getConfig().short_name; + } else { + sender = app::AppContext::getInstance().getContactService().getContactName(msg.from); + if (sender.empty()) { + char buf[16]; + snprintf(buf, sizeof(buf), "%04lX", + static_cast(msg.from & 0xFFFF)); + sender = buf; + } + } + std::string line = sender + " " + time_buf; + lv_label_set_text(item.time_label, line.c_str()); + } else { + lv_label_set_text(item.time_label, time_buf); + } + item.text_label = chat::ui::layout::create_bubble_text(bubble); lv_label_set_text(item.text_label, msg.text.c_str()); chat::ui::conversation::styles::apply_bubble_text(item.text_label); + item.status_label = chat::ui::layout::create_bubble_status(bubble); + chat::ui::conversation::styles::apply_bubble_status(item.status_label); + if (msg.status == MessageStatus::Failed) + { + lv_label_set_text(item.status_label, "Failed"); + lv_obj_clear_flag(item.status_label, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_label_set_text(item.status_label, ""); + lv_obj_add_flag(item.status_label, LV_OBJ_FLAG_HIDDEN); + } + // Align row based on sender (same behavior) chat::ui::layout::align_message_row(item.container, is_self); diff --git a/src/ui/screens/chat/chat_conversation_components.h b/src/ui/screens/chat/chat_conversation_components.h index 32e3efd0..f9ee061a 100644 --- a/src/ui/screens/chat/chat_conversation_components.h +++ b/src/ui/screens/chat/chat_conversation_components.h @@ -24,6 +24,9 @@ public: void setActionCallback(void (*cb)(bool compose, void*), void* user_data); lv_obj_t* getObj() const { return container_; } + lv_obj_t* getMsgList() const { return msg_list_; } + lv_obj_t* getReplyBtn() const { return reply_btn_; } + lv_obj_t* getBackBtn() const { return top_bar_.back_btn; } chat::ChannelId getChannel() const { return conv_.channel; } diff --git a/src/ui/screens/chat/chat_conversation_input.cpp b/src/ui/screens/chat/chat_conversation_input.cpp index 42fad9da..d7fb272c 100644 --- a/src/ui/screens/chat/chat_conversation_input.cpp +++ b/src/ui/screens/chat/chat_conversation_input.cpp @@ -13,11 +13,67 @@ namespace chat::ui::conversation::input { static ChatConversationScreen* s_screen = nullptr; +namespace { +constexpr int kEncoderKeyRotateUp = 19; +constexpr int kEncoderKeyRotateDown = 20; +constexpr lv_coord_t kScrollStep = 24; +} + +static void on_msg_list_key(lv_event_t* e) +{ + auto* screen = static_cast(lv_event_get_user_data(e)); + if (!screen) return; + + uint32_t key = lv_event_get_key(e); + + if (lv_group_t* g = lv_group_get_default()) { + if (key == LV_KEY_ENTER) { + lv_group_set_editing(g, !lv_group_get_editing(g)); + lv_event_stop_processing(e); + return; + } + + if (!lv_group_get_editing(g)) { + return; + } + + lv_coord_t delta = 0; + if (key == LV_KEY_UP || key == kEncoderKeyRotateUp) { + delta = -kScrollStep; + } else if (key == LV_KEY_DOWN || key == kEncoderKeyRotateDown) { + delta = kScrollStep; + } + if (delta != 0) { + if (lv_obj_t* msg_list = screen->getMsgList()) { + lv_obj_scroll_by(msg_list, 0, delta, LV_ANIM_OFF); + lv_event_stop_processing(e); + } + } + } +} + void init(ChatConversationScreen* screen) { s_screen = screen; - // v0: no behavior change. Future: rotary scroll / focus reply button etc. - CHAT_CONV_INPUT_LOG("[ChatConversationInput] init (v0 no-op)\n"); + if (!s_screen) { + CHAT_CONV_INPUT_LOG("[ChatConversationInput] init (no screen)\n"); + return; + } + if (lv_group_t* g = lv_group_get_default()) { + if (lv_obj_t* msg_list = s_screen->getMsgList()) { + lv_group_add_obj(g, msg_list); + lv_group_focus_obj(msg_list); + lv_group_set_editing(g, true); + lv_obj_add_event_cb(msg_list, on_msg_list_key, LV_EVENT_KEY, s_screen); + } + if (lv_obj_t* reply_btn = s_screen->getReplyBtn()) { + lv_group_add_obj(g, reply_btn); + } + if (lv_obj_t* back_btn = s_screen->getBackBtn()) { + lv_group_add_obj(g, back_btn); + } + } + CHAT_CONV_INPUT_LOG("[ChatConversationInput] init (group focus msg list)\n"); } void cleanup() diff --git a/src/ui/screens/chat/chat_conversation_layout.cpp b/src/ui/screens/chat/chat_conversation_layout.cpp index 85212c4b..fc995052 100644 --- a/src/ui/screens/chat/chat_conversation_layout.cpp +++ b/src/ui/screens/chat/chat_conversation_layout.cpp @@ -134,6 +134,20 @@ lv_obj_t* create_bubble_text(lv_obj_t* bubble_parent) return label; } +lv_obj_t* create_bubble_time(lv_obj_t* bubble_parent) +{ + lv_obj_t* label = lv_label_create(bubble_parent); + lv_obj_set_width(label, LV_SIZE_CONTENT); + return label; +} + +lv_obj_t* create_bubble_status(lv_obj_t* bubble_parent) +{ + lv_obj_t* label = lv_label_create(bubble_parent); + lv_obj_set_width(label, LV_SIZE_CONTENT); + return label; +} + void align_message_row(lv_obj_t* row, bool is_self) { // Match original behavior: diff --git a/src/ui/screens/chat/chat_conversation_layout.h b/src/ui/screens/chat/chat_conversation_layout.h index 24b2b900..afd8e405 100644 --- a/src/ui/screens/chat/chat_conversation_layout.h +++ b/src/ui/screens/chat/chat_conversation_layout.h @@ -32,6 +32,12 @@ lv_obj_t* create_bubble(lv_obj_t* row_parent); // Create bubble text label lv_obj_t* create_bubble_text(lv_obj_t* bubble_parent); +// Create bubble time label +lv_obj_t* create_bubble_time(lv_obj_t* bubble_parent); + +// Create bubble status label +lv_obj_t* create_bubble_status(lv_obj_t* bubble_parent); + // Layout-only helpers to align message row left/right void align_message_row(lv_obj_t* row, bool is_self); diff --git a/src/ui/screens/chat/chat_conversation_styles.cpp b/src/ui/screens/chat/chat_conversation_styles.cpp index c5dde49c..c96d3144 100644 --- a/src/ui/screens/chat/chat_conversation_styles.cpp +++ b/src/ui/screens/chat/chat_conversation_styles.cpp @@ -19,6 +19,8 @@ static lv_style_t s_bubble_base; static lv_style_t s_bubble_self; static lv_style_t s_bubble_other; static lv_style_t s_bubble_text; +static lv_style_t s_bubble_time; +static lv_style_t s_bubble_status; static constexpr lv_coord_t kPadX = 8; static constexpr lv_coord_t kPadY = 6; @@ -110,6 +112,16 @@ void init_once() lv_style_set_text_color(&s_bubble_text, kTextColor); lv_style_set_text_align(&s_bubble_text, LV_TEXT_ALIGN_LEFT); lv_style_set_text_font(&s_bubble_text, &lv_font_noto_cjk_16_2bpp); + + lv_style_init(&s_bubble_time); + lv_style_set_text_color(&s_bubble_time, lv_color_hex(0x707070)); + lv_style_set_text_align(&s_bubble_time, LV_TEXT_ALIGN_LEFT); + lv_style_set_text_font(&s_bubble_time, &lv_font_montserrat_12); + + lv_style_init(&s_bubble_status); + lv_style_set_text_color(&s_bubble_status, lv_color_hex(0xB00020)); + lv_style_set_text_align(&s_bubble_status, LV_TEXT_ALIGN_LEFT); + lv_style_set_text_font(&s_bubble_status, &lv_font_montserrat_12); } void apply_root(lv_obj_t* root) @@ -162,4 +174,16 @@ void apply_bubble_text(lv_obj_t* label) lv_obj_add_style(label, &s_bubble_text, 0); } +void apply_bubble_time(lv_obj_t* label) +{ + init_once(); + lv_obj_add_style(label, &s_bubble_time, 0); +} + +void apply_bubble_status(lv_obj_t* label) +{ + init_once(); + lv_obj_add_style(label, &s_bubble_status, 0); +} + } // namespace chat::ui::conversation::styles diff --git a/src/ui/screens/chat/chat_conversation_styles.h b/src/ui/screens/chat/chat_conversation_styles.h index ff6ee429..8ceed165 100644 --- a/src/ui/screens/chat/chat_conversation_styles.h +++ b/src/ui/screens/chat/chat_conversation_styles.h @@ -19,5 +19,7 @@ void apply_reply_label(lv_obj_t* label); void apply_message_row(lv_obj_t* row); void apply_bubble(lv_obj_t* bubble, bool is_self); void apply_bubble_text(lv_obj_t* label); +void apply_bubble_time(lv_obj_t* label); +void apply_bubble_status(lv_obj_t* label); } // namespace chat::ui::conversation::styles diff --git a/src/ui/screens/chat/chat_message_list_components.cpp b/src/ui/screens/chat/chat_message_list_components.cpp index a94d827f..7ea1ddcf 100644 --- a/src/ui/screens/chat/chat_message_list_components.cpp +++ b/src/ui/screens/chat/chat_message_list_components.cpp @@ -25,8 +25,8 @@ static void format_time_hhmm(char out[16], uint32_t ts) snprintf(out, 16, "--:--"); return; } - time_t t = static_cast(ts); - struct tm* info = localtime(&t); + time_t t = ui_apply_timezone_offset(static_cast(ts)); + struct tm* info = gmtime(&t); if (info) { strftime(out, 16, "%H:%M", info); } else { @@ -38,30 +38,73 @@ static void format_time_hhmm(char out[16], uint32_t ts) ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) : container_(nullptr), - panel_(nullptr), + filter_panel_(nullptr), + list_panel_(nullptr), + direct_btn_(nullptr), + broadcast_btn_(nullptr), + list_back_btn_(nullptr), selected_index_(0), + filter_mode_(FilterMode::Direct), select_cb_(nullptr), select_cb_user_data_(nullptr), back_cb_(nullptr), back_cb_user_data_(nullptr) { + lv_obj_t* active = lv_screen_active(); + if (!active) { + Serial.printf("[ChatMessageList] WARNING: lv_screen_active() is null\n"); + } else { + Serial.printf("[ChatMessageList] init: active=%p parent=%p\n", active, parent); + } + + lv_group_t* prev_group = lv_group_get_default(); + set_default_group(nullptr); + // ---------- Layout ---------- - container_ = chat::ui::layout::create_root(parent); + auto w = chat::ui::layout::create_layout(parent); + container_ = w.root; + filter_panel_ = w.filter_panel; + list_panel_ = w.list_panel; + direct_btn_ = w.direct_btn; + broadcast_btn_ = w.broadcast_btn; // ---------- Styles ---------- chat::ui::message_list::styles::apply_root_container(container_); + chat::ui::message_list::styles::apply_filter_panel(filter_panel_); + chat::ui::message_list::styles::apply_panel(list_panel_); + if (direct_btn_) chat::ui::message_list::styles::apply_filter_btn(direct_btn_); + if (broadcast_btn_) chat::ui::message_list::styles::apply_filter_btn(broadcast_btn_); // ---------- Top bar (existing widget, unchanged) ---------- ::ui::widgets::top_bar_init(top_bar_, container_); ::ui::widgets::top_bar_set_title(top_bar_, "MESSAGES"); ::ui::widgets::top_bar_set_right_text(top_bar_, "--:-- --%"); ::ui::widgets::top_bar_set_back_callback(top_bar_, handle_back, this); + if (top_bar_.container) { + lv_obj_move_to_index(top_bar_.container, 0); + } - // ---------- Content panel ---------- - panel_ = chat::ui::layout::create_panel(container_); - chat::ui::message_list::styles::apply_panel(panel_); + // ---------- Filter events ---------- + if (direct_btn_) { + lv_obj_add_event_cb(direct_btn_, filter_focus_cb, LV_EVENT_FOCUSED, this); + lv_obj_add_event_cb(direct_btn_, filter_click_cb, LV_EVENT_CLICKED, this); + } + if (broadcast_btn_) { + lv_obj_add_event_cb(broadcast_btn_, filter_focus_cb, LV_EVENT_FOCUSED, this); + lv_obj_add_event_cb(broadcast_btn_, filter_click_cb, LV_EVENT_CLICKED, this); + } + updateFilterHighlight(); - // ---------- Input layer (explicit, no-op for now) ---------- + if (container_ && !lv_obj_is_valid(container_)) { + Serial.printf("[ChatMessageList] WARNING: container invalid\n"); + } + if (list_panel_ && !lv_obj_is_valid(list_panel_)) { + Serial.printf("[ChatMessageList] WARNING: list_panel invalid\n"); + } + + set_default_group(prev_group); + + // ---------- Input layer ---------- chat::ui::message_list::input::init(this); } @@ -84,7 +127,8 @@ ChatMessageListScreen::~ChatMessageListScreen() void ChatMessageListScreen::setConversations(const std::vector& convs) { - rebuildList(convs); + convs_ = convs; + rebuildList(); } void ChatMessageListScreen::setSelected(int index) @@ -92,7 +136,16 @@ void ChatMessageListScreen::setSelected(int index) if (index >= 0 && index < static_cast(items_.size()) && items_[index].btn != nullptr) { selected_index_ = index; - lv_group_focus_obj(items_[index].btn); + } +} + +void ChatMessageListScreen::setSelectedConversation(const chat::ConversationId& conv) +{ + for (size_t i = 0; i < items_.size(); ++i) { + if (items_[i].conv == conv) { + setSelected(static_cast(i)); + return; + } } } @@ -105,6 +158,14 @@ chat::ConversationId ChatMessageListScreen::getSelectedConversation() const return chat::ConversationId(); } +lv_obj_t* ChatMessageListScreen::getItemButton(size_t index) const +{ + if (index >= items_.size()) { + return nullptr; + } + return items_[index].btn; +} + void ChatMessageListScreen::setChannelSelectCallback( void (*cb)(chat::ChannelId, void*), void* user_data) @@ -127,19 +188,30 @@ void ChatMessageListScreen::setBackCallback(void (*cb)(void*), void* user_data) // ------------------------------------------------ // Core logic: rebuild list (behavior unchanged) // ------------------------------------------------ -void ChatMessageListScreen::rebuildList(const std::vector& convs) +void ChatMessageListScreen::rebuildList() { // Same behavior: clear and rebuild - lv_obj_clean(panel_); + lv_obj_clean(list_panel_); items_.clear(); + list_back_btn_ = nullptr; - for (const auto& conv : convs) { + std::vector filtered; + filtered.reserve(convs_.size()); + for (const auto& conv : convs_) { + if (filter_mode_ == FilterMode::Direct && conv.id.peer != 0) { + filtered.push_back(conv); + } else if (filter_mode_ == FilterMode::Broadcast && conv.id.peer == 0) { + filtered.push_back(conv); + } + } + + for (const auto& conv : filtered) { MessageItem item{}; item.conv = conv.id; item.unread_count = conv.unread; // ----- Layout ----- - auto w = chat::ui::layout::create_message_item(panel_); + auto w = chat::ui::layout::create_message_item(list_panel_); item.btn = w.btn; item.name_label = w.name_label; item.preview_label = w.preview_label; @@ -152,6 +224,7 @@ void ChatMessageListScreen::rebuildList(const std::vector(lv_event_get_user_data(e)); + if (!screen) { + return; + } + chat::ui::message_list::input::focus_filter(); +} + +void ChatMessageListScreen::filter_focus_cb(lv_event_t* e) +{ + auto* screen = + static_cast(lv_event_get_user_data(e)); + if (!screen) { + return; + } + lv_obj_t* tgt = static_cast(lv_event_get_target(e)); + if (tgt == screen->direct_btn_) { + screen->setFilterMode(FilterMode::Direct); + } else if (tgt == screen->broadcast_btn_) { + screen->setFilterMode(FilterMode::Broadcast); + } +} + +void ChatMessageListScreen::filter_click_cb(lv_event_t* e) +{ + auto* screen = + static_cast(lv_event_get_user_data(e)); + if (!screen) { + return; + } + lv_obj_t* tgt = static_cast(lv_event_get_target(e)); + if (tgt == screen->direct_btn_) { + screen->setFilterMode(FilterMode::Direct); + } else if (tgt == screen->broadcast_btn_) { + screen->setFilterMode(FilterMode::Broadcast); + } + chat::ui::message_list::input::focus_list(); +} + +void ChatMessageListScreen::updateFilterHighlight() +{ + if (!direct_btn_ || !broadcast_btn_) { + return; + } + lv_obj_clear_state(direct_btn_, LV_STATE_CHECKED); + lv_obj_clear_state(broadcast_btn_, LV_STATE_CHECKED); + if (filter_mode_ == FilterMode::Direct) { + lv_obj_add_state(direct_btn_, LV_STATE_CHECKED); + } else { + lv_obj_add_state(broadcast_btn_, LV_STATE_CHECKED); + } +} + +void ChatMessageListScreen::setFilterMode(FilterMode mode) +{ + if (filter_mode_ == mode) { + return; + } + filter_mode_ = mode; + selected_index_ = -1; + updateFilterHighlight(); + rebuildList(); +} + void ChatMessageListScreen::handle_back(void* user_data) { auto* screen = static_cast(user_data); diff --git a/src/ui/screens/chat/chat_message_list_components.h b/src/ui/screens/chat/chat_message_list_components.h index 7fd61b70..faf52c72 100644 --- a/src/ui/screens/chat/chat_message_list_components.h +++ b/src/ui/screens/chat/chat_message_list_components.h @@ -30,6 +30,7 @@ public: // Set selected item index void setSelected(int index); + void setSelectedConversation(const chat::ConversationId& conv); // Get selected conversation chat::ConversationId getSelectedConversation() const; @@ -39,13 +40,30 @@ public: // Get LVGL root object lv_obj_t* getObj() const { return container_; } + lv_obj_t* getDirectButton() const { return direct_btn_; } + lv_obj_t* getBroadcastButton() const { return broadcast_btn_; } + lv_obj_t* getBackButton() const { return top_bar_.back_btn; } + lv_obj_t* getListBackButton() const { return list_back_btn_; } + size_t getItemCount() const { return items_.size(); } + lv_obj_t* getItemButton(size_t index) const; + int getSelectedIndex() const { return selected_index_; } private: + enum class FilterMode { + Direct, + Broadcast + }; + lv_obj_t* container_ = nullptr; ::ui::widgets::TopBar top_bar_{}; - lv_obj_t* panel_ = nullptr; + lv_obj_t* filter_panel_ = nullptr; + lv_obj_t* list_panel_ = nullptr; + lv_obj_t* direct_btn_ = nullptr; + lv_obj_t* broadcast_btn_ = nullptr; + lv_obj_t* list_back_btn_ = nullptr; int selected_index_ = 0; + FilterMode filter_mode_ = FilterMode::Direct; void (*select_cb_)(chat::ChannelId, void*) = nullptr; void* select_cb_user_data_ = nullptr; @@ -64,10 +82,16 @@ private: }; std::vector items_; + std::vector convs_; - void rebuildList(const std::vector& convs); + void rebuildList(); + void updateFilterHighlight(); + void setFilterMode(FilterMode mode); static void item_event_cb(lv_event_t* e); + static void list_back_event_cb(lv_event_t* e); + static void filter_focus_cb(lv_event_t* e); + static void filter_click_cb(lv_event_t* e); static void handle_back(void* user_data); }; diff --git a/src/ui/screens/chat/chat_message_list_input.cpp b/src/ui/screens/chat/chat_message_list_input.cpp index 61f44768..9bdaae84 100644 --- a/src/ui/screens/chat/chat_message_list_input.cpp +++ b/src/ui/screens/chat/chat_message_list_input.cpp @@ -2,20 +2,17 @@ * @file chat_message_list_input.cpp * @brief Input handling for ChatMessageListScreen (explicit layer) * - * Current policy (v0): - * - Keep behavior identical to the original implementation. - * - No event interception or focus changes. - * - * Future extensions: - * - Rotary: up/down to change selected item - * - Left/right: optional column switching if more focus zones appear - * - Press: trigger current item / open conversation - * - Long press: open context menu + * Current policy: + * - Rotary focuses filter buttons (Direct/Broadcast) or list items. + * - Press in filter column moves to list (or back button exits). + * - Press in list column returns to filter when Back is focused. + * - Back/ESC returns to filter column. */ #include // keep Arduino first if your build requires it #include "chat_message_list_input.h" #include "chat_message_list_components.h" +#include "../ui_common.h" #define CHAT_INPUT_DEBUG 1 #if CHAT_INPUT_DEBUG @@ -26,28 +23,234 @@ namespace chat::ui::message_list::input { +namespace { + +enum class FocusColumn { + Filter = 0, + List = 1 +}; + static ChatMessageListScreen* s_screen = nullptr; +static lv_group_t* s_group = nullptr; +static lv_group_t* s_prev_group = nullptr; +static FocusColumn s_col = FocusColumn::Filter; + +static bool is_encoder_active() +{ + lv_indev_t* indev = lv_indev_get_act(); + return indev && lv_indev_get_type(indev) == LV_INDEV_TYPE_ENCODER; +} + +static void group_clear_all(lv_group_t* g) +{ + if (!g) return; + lv_group_remove_all_objs(g); +} + +static void focus_first_valid(lv_obj_t* obj) +{ + if (!s_group || !obj || !lv_obj_is_valid(obj)) return; + lv_group_focus_obj(obj); +} + +static void group_add_if_valid(lv_obj_t* obj) +{ + if (!s_group || !obj || !lv_obj_is_valid(obj)) return; + lv_group_add_obj(s_group, obj); +} + +static void clear_list_focus_states() +{ + if (!s_screen) return; + lv_state_t clear_mask = (lv_state_t)(LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY); + size_t count = s_screen->getItemCount(); + for (size_t i = 0; i < count; ++i) { + if (lv_obj_t* btn = s_screen->getItemButton(i)) { + if (lv_obj_is_valid(btn)) { + lv_obj_clear_state(btn, clear_mask); + } + } + } + if (lv_obj_t* back = s_screen->getListBackButton()) { + if (lv_obj_is_valid(back)) { + lv_obj_clear_state(back, clear_mask); + } + } +} + +static void bind_filter_column() +{ + if (!s_group || !s_screen) return; + lv_group_focus_freeze(s_group, true); + group_clear_all(s_group); + clear_list_focus_states(); + + if (lv_obj_t* back = s_screen->getBackButton()) { + group_add_if_valid(back); + } + if (lv_obj_t* direct = s_screen->getDirectButton()) { + group_add_if_valid(direct); + } + if (lv_obj_t* broadcast = s_screen->getBroadcastButton()) { + group_add_if_valid(broadcast); + } + + lv_group_focus_freeze(s_group, false); + + if (lv_obj_t* direct = s_screen->getDirectButton()) { + if (lv_obj_has_state(direct, LV_STATE_CHECKED)) { + focus_first_valid(direct); + return; + } + } + if (lv_obj_t* broadcast = s_screen->getBroadcastButton()) { + if (lv_obj_has_state(broadcast, LV_STATE_CHECKED)) { + focus_first_valid(broadcast); + return; + } + } + + if (lv_obj_t* direct = s_screen->getDirectButton()) { + focus_first_valid(direct); + } else if (lv_obj_t* broadcast = s_screen->getBroadcastButton()) { + focus_first_valid(broadcast); + } else if (lv_obj_t* back = s_screen->getBackButton()) { + focus_first_valid(back); + } +} + +static void bind_list_column() +{ + if (!s_group || !s_screen) return; + lv_group_focus_freeze(s_group, true); + group_clear_all(s_group); + + size_t count = s_screen->getItemCount(); + for (size_t i = 0; i < count; ++i) { + if (lv_obj_t* btn = s_screen->getItemButton(i)) { + group_add_if_valid(btn); + } + } + if (lv_obj_t* back = s_screen->getListBackButton()) { + group_add_if_valid(back); + } + + lv_group_focus_freeze(s_group, false); + + if (count > 0) { + int selected = s_screen->getSelectedIndex(); + if (selected >= 0 && static_cast(selected) < count) { + focus_first_valid(s_screen->getItemButton(static_cast(selected))); + } else { + focus_first_valid(s_screen->getItemButton(0)); + } + } else if (lv_obj_t* back = s_screen->getListBackButton()) { + focus_first_valid(back); + } else { + s_col = FocusColumn::Filter; + bind_filter_column(); + } +} + +static void rebind_by_column() +{ + if (s_col == FocusColumn::Filter) { + bind_filter_column(); + } else { + bind_list_column(); + } +} + +static void root_key_event_cb(lv_event_t* e) +{ + if (!is_encoder_active()) return; + + uint32_t key = lv_event_get_key(e); + if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) { + s_col = FocusColumn::Filter; + rebind_by_column(); + return; + } + + if (key != LV_KEY_ENTER) return; + + lv_obj_t* focused = s_group ? lv_group_get_focused(s_group) : nullptr; + if (!focused || !s_screen) return; + + if (s_col == FocusColumn::Filter) { + if (focused == s_screen->getBackButton()) { + lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); + return; + } + s_col = FocusColumn::List; + rebind_by_column(); + return; + } + + if (s_col == FocusColumn::List) { + if (focused == s_screen->getListBackButton()) { + s_col = FocusColumn::Filter; + rebind_by_column(); + return; + } + } +} + +} // namespace void init(ChatMessageListScreen* screen) { s_screen = screen; + if (s_group) { + cleanup(); + } + s_group = lv_group_create(); + s_prev_group = lv_group_get_default(); + set_default_group(nullptr); + s_col = FocusColumn::Filter; + rebind_by_column(); + set_default_group(s_group); - // v0: No behavior change. - // We do not attach LVGL event callbacks here yet, - // because the original screen relies on: - // - LV_EVENT_CLICKED on list items - // - TopBar internal back callback - // - // This module is the explicit "input ownership" point for future work. + if (s_screen && s_screen->getObj()) { + lv_obj_add_event_cb(s_screen->getObj(), root_key_event_cb, LV_EVENT_KEY, nullptr); + } - CHAT_INPUT_LOG("[ChatMessageListInput] init (v0 no-op)\n"); + CHAT_INPUT_LOG("[ChatMessageListInput] init\n"); } void cleanup() { - // v0: No behavior change. s_screen = nullptr; + if (s_group) { + set_default_group(nullptr); + lv_group_del(s_group); + s_group = nullptr; + } + if (s_prev_group) { + set_default_group(s_prev_group); + } + s_prev_group = nullptr; CHAT_INPUT_LOG("[ChatMessageListInput] cleanup\n"); } +void on_ui_refreshed() +{ + if (!s_group) return; + rebind_by_column(); +} + +void focus_filter() +{ + if (!s_group) return; + s_col = FocusColumn::Filter; + rebind_by_column(); +} + +void focus_list() +{ + if (!s_group) return; + s_col = FocusColumn::List; + rebind_by_column(); +} + } // namespace chat::ui::input diff --git a/src/ui/screens/chat/chat_message_list_input.h b/src/ui/screens/chat/chat_message_list_input.h index f6c88783..97bfa5a3 100644 --- a/src/ui/screens/chat/chat_message_list_input.h +++ b/src/ui/screens/chat/chat_message_list_input.h @@ -33,5 +33,12 @@ void init(ChatMessageListScreen* screen); */ void cleanup(); +/** + * @brief Rebind focus after UI rebuild + */ +void on_ui_refreshed(); +void focus_filter(); +void focus_list(); + } // namespace message_list::input } // namespace chat::ui diff --git a/src/ui/screens/chat/chat_message_list_layout.cpp b/src/ui/screens/chat/chat_message_list_layout.cpp index e3cf819c..e0ecba71 100644 --- a/src/ui/screens/chat/chat_message_list_layout.cpp +++ b/src/ui/screens/chat/chat_message_list_layout.cpp @@ -66,27 +66,79 @@ static void make_non_scrollable(lv_obj_t* obj) lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); } +namespace { +constexpr int kFilterPanelWidth = 80; +constexpr int kButtonHeight = 32; +constexpr int kPanelGap = 0; +constexpr int kScreenEdgePadding = 0; +} // namespace + lv_obj_t* create_root(lv_obj_t* parent) { lv_obj_t* root = lv_obj_create(parent); lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(root, 0, 0); + lv_obj_set_style_pad_row(root, 3, 0); lv_obj_set_style_pad_all(root, 0, 0); // layout padding only make_non_scrollable(root); return root; } -lv_obj_t* create_panel(lv_obj_t* parent) +static lv_obj_t* create_content(lv_obj_t* parent) +{ + lv_obj_t* content = lv_obj_create(parent); + lv_obj_set_size(content, LV_PCT(100), 0); + lv_obj_set_flex_grow(content, 1); + lv_obj_set_flex_flow(content, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(content, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_left(content, kScreenEdgePadding, 0); + lv_obj_set_style_pad_right(content, kScreenEdgePadding, 0); + lv_obj_set_style_pad_top(content, 0, 0); + lv_obj_set_style_pad_bottom(content, 0, 0); + make_non_scrollable(content); + return content; +} + +static lv_obj_t* create_filter_panel(lv_obj_t* parent, lv_obj_t** direct_btn, lv_obj_t** broadcast_btn) { lv_obj_t* panel = lv_obj_create(parent); - lv_obj_set_size(panel, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_width(panel, kFilterPanelWidth); + lv_obj_set_height(panel, LV_PCT(100)); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(panel, 3, LV_PART_MAIN); + lv_obj_set_style_margin_right(panel, kPanelGap, LV_PART_MAIN); + make_non_scrollable(panel); + + lv_obj_t* direct = lv_btn_create(panel); + lv_obj_set_size(direct, LV_PCT(100), kButtonHeight); + make_non_scrollable(direct); + lv_obj_t* direct_label = lv_label_create(direct); + lv_label_set_text(direct_label, "Direct"); + lv_obj_center(direct_label); + + lv_obj_t* broadcast = lv_btn_create(panel); + lv_obj_set_size(broadcast, LV_PCT(100), kButtonHeight); + make_non_scrollable(broadcast); + lv_obj_t* broadcast_label = lv_label_create(broadcast); + lv_label_set_text(broadcast_label, "Broadcast"); + lv_obj_center(broadcast_label); + + if (direct_btn) *direct_btn = direct; + if (broadcast_btn) *broadcast_btn = broadcast; + return panel; +} + +static lv_obj_t* create_list_panel(lv_obj_t* parent) +{ + lv_obj_t* panel = lv_obj_create(parent); + lv_obj_set_height(panel, LV_PCT(100)); + lv_obj_set_width(panel, 0); lv_obj_set_flex_grow(panel, 1); lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); // layout spacing only - lv_obj_set_style_pad_row(panel, 2, 0); + lv_obj_set_style_pad_row(panel, 3, 0); lv_obj_set_style_pad_left(panel, 5, 0); lv_obj_set_style_pad_right(panel, 5, 0); lv_obj_set_style_pad_all(panel, 3, 0); @@ -95,6 +147,16 @@ lv_obj_t* create_panel(lv_obj_t* parent) return panel; } +MessageListLayout create_layout(lv_obj_t* parent) +{ + MessageListLayout w{}; + w.root = create_root(parent); + w.content = create_content(w.root); + w.filter_panel = create_filter_panel(w.content, &w.direct_btn, &w.broadcast_btn); + w.list_panel = create_list_panel(w.content); + return w; +} + MessageItemWidgets create_message_item(lv_obj_t* parent) { MessageItemWidgets w{}; diff --git a/src/ui/screens/chat/chat_message_list_layout.h b/src/ui/screens/chat/chat_message_list_layout.h index 48fb4ccc..89d7e9d6 100644 --- a/src/ui/screens/chat/chat_message_list_layout.h +++ b/src/ui/screens/chat/chat_message_list_layout.h @@ -4,11 +4,20 @@ namespace chat::ui::layout { +struct MessageListLayout { + lv_obj_t* root; + lv_obj_t* content; + lv_obj_t* filter_panel; + lv_obj_t* list_panel; + lv_obj_t* direct_btn; + lv_obj_t* broadcast_btn; +}; + // root/page lv_obj_t* create_root(lv_obj_t* parent); -// content panel -lv_obj_t* create_panel(lv_obj_t* parent); +// split content (filter + list) +MessageListLayout create_layout(lv_obj_t* parent); // message list item (button with labels) struct MessageItemWidgets { diff --git a/src/ui/screens/chat/chat_message_list_styles.cpp b/src/ui/screens/chat/chat_message_list_styles.cpp index b5c1c1ad..9fa17e8d 100644 --- a/src/ui/screens/chat/chat_message_list_styles.cpp +++ b/src/ui/screens/chat/chat_message_list_styles.cpp @@ -3,13 +3,23 @@ namespace chat::ui::message_list::styles { +static constexpr uint32_t kPrimary = 0xEBA341; +static constexpr uint32_t kPrimaryLight = 0xF1B65A; +static constexpr uint32_t kCardBg = 0xF4C77A; +static constexpr uint32_t kSoftBg = 0xF7DCA8; +static constexpr uint32_t kTextMain = 0x202020; +static constexpr uint32_t kTextMuted = 0x606060; + static bool inited = false; static lv_style_t s_root; static lv_style_t s_panel; +static lv_style_t s_filter_panel; static lv_style_t s_item_btn; static lv_style_t s_item_btn_focused; +static lv_style_t s_filter_btn; +static lv_style_t s_filter_btn_checked; static lv_style_t s_label_name; static lv_style_t s_label_preview; @@ -32,43 +42,65 @@ void init_once() // Panel background lv_style_init(&s_panel); - lv_style_set_bg_color(&s_panel, lv_color_hex(0xF5F5F5)); + lv_style_set_bg_color(&s_panel, lv_color_hex(kSoftBg)); lv_style_set_bg_opa(&s_panel, LV_OPA_COVER); lv_style_set_border_width(&s_panel, 0); lv_style_set_pad_all(&s_panel, 3); lv_style_set_radius(&s_panel, 0); + // Filter panel background + lv_style_init(&s_filter_panel); + lv_style_set_bg_color(&s_filter_panel, lv_color_hex(kSoftBg)); + lv_style_set_bg_opa(&s_filter_panel, LV_OPA_COVER); + lv_style_set_border_width(&s_filter_panel, 0); + lv_style_set_pad_all(&s_filter_panel, 2); + lv_style_set_radius(&s_filter_panel, 0); + // Item button lv_style_init(&s_item_btn); - lv_style_set_bg_color(&s_item_btn, lv_color_hex(0xF4C77A)); + lv_style_set_bg_color(&s_item_btn, lv_color_hex(kCardBg)); lv_style_set_bg_opa(&s_item_btn, LV_OPA_COVER); lv_style_set_border_width(&s_item_btn, 1); - lv_style_set_border_color(&s_item_btn, lv_color_hex(0xEBA341)); + lv_style_set_border_color(&s_item_btn, lv_color_hex(kPrimary)); lv_style_set_radius(&s_item_btn, 6); lv_style_init(&s_item_btn_focused); - lv_style_set_bg_color(&s_item_btn_focused, lv_color_hex(0xF1B65A)); - lv_style_set_outline_width(&s_item_btn_focused, 0); + lv_style_set_bg_opa(&s_item_btn_focused, LV_OPA_COVER); + lv_style_set_bg_color(&s_item_btn_focused, lv_color_hex(kPrimaryLight)); + lv_style_set_outline_width(&s_item_btn_focused, 2); + lv_style_set_outline_color(&s_item_btn_focused, lv_color_hex(kPrimary)); + + // Filter button + lv_style_init(&s_filter_btn); + lv_style_set_bg_color(&s_filter_btn, lv_color_hex(kCardBg)); + lv_style_set_bg_opa(&s_filter_btn, LV_OPA_COVER); + lv_style_set_border_width(&s_filter_btn, 1); + lv_style_set_border_color(&s_filter_btn, lv_color_hex(kPrimary)); + lv_style_set_radius(&s_filter_btn, 6); + + lv_style_init(&s_filter_btn_checked); + lv_style_set_bg_opa(&s_filter_btn_checked, LV_OPA_COVER); + lv_style_set_bg_color(&s_filter_btn_checked, lv_color_hex(kPrimary)); // Labels lv_style_init(&s_label_name); - lv_style_set_text_color(&s_label_name, lv_color_hex(0x202020)); + lv_style_set_text_color(&s_label_name, lv_color_hex(kTextMain)); lv_style_set_text_font(&s_label_name, &lv_font_noto_cjk_16_2bpp); lv_style_init(&s_label_preview); - lv_style_set_text_color(&s_label_preview, lv_color_hex(0x606060)); + lv_style_set_text_color(&s_label_preview, lv_color_hex(kTextMuted)); lv_style_set_text_font(&s_label_preview, &lv_font_noto_cjk_16_2bpp); lv_style_init(&s_label_time); - lv_style_set_text_color(&s_label_time, lv_color_hex(0x808080)); + lv_style_set_text_color(&s_label_time, lv_color_hex(kTextMuted)); lv_style_set_text_font(&s_label_time, &lv_font_noto_cjk_16_2bpp); lv_style_init(&s_label_unread); - lv_style_set_text_color(&s_label_unread, lv_color_hex(0xEBA341)); + lv_style_set_text_color(&s_label_unread, lv_color_hex(kPrimary)); lv_style_set_text_font(&s_label_unread, &lv_font_noto_cjk_16_2bpp); lv_style_init(&s_label_placeholder); - lv_style_set_text_color(&s_label_placeholder, lv_color_hex(0x707070)); + lv_style_set_text_color(&s_label_placeholder, lv_color_hex(kTextMuted)); lv_style_set_text_font(&s_label_placeholder, &lv_font_noto_cjk_16_2bpp); } @@ -84,6 +116,12 @@ void apply_panel(lv_obj_t* obj) lv_obj_add_style(obj, &s_panel, 0); } +void apply_filter_panel(lv_obj_t* obj) +{ + init_once(); + lv_obj_add_style(obj, &s_filter_panel, 0); +} + void apply_item_btn(lv_obj_t* btn) { init_once(); @@ -91,6 +129,13 @@ void apply_item_btn(lv_obj_t* btn) lv_obj_add_style(btn, &s_item_btn_focused, LV_STATE_FOCUSED); } +void apply_filter_btn(lv_obj_t* btn) +{ + init_once(); + lv_obj_add_style(btn, &s_filter_btn, LV_PART_MAIN); + lv_obj_add_style(btn, &s_filter_btn_checked, LV_STATE_CHECKED); +} + void apply_label_name(lv_obj_t* label) { init_once(); lv_obj_add_style(label, &s_label_name, 0); } void apply_label_preview(lv_obj_t* label) { init_once(); lv_obj_add_style(label, &s_label_preview, 0); } void apply_label_time(lv_obj_t* label) { init_once(); lv_obj_add_style(label, &s_label_time, 0); } diff --git a/src/ui/screens/chat/chat_message_list_styles.h b/src/ui/screens/chat/chat_message_list_styles.h index 5e83bcd6..abeac10a 100644 --- a/src/ui/screens/chat/chat_message_list_styles.h +++ b/src/ui/screens/chat/chat_message_list_styles.h @@ -8,9 +8,11 @@ void init_once(); // containers void apply_root_container(lv_obj_t* obj); void apply_panel(lv_obj_t* obj); +void apply_filter_panel(lv_obj_t* obj); // list item void apply_item_btn(lv_obj_t* btn); +void apply_filter_btn(lv_obj_t* btn); // labels void apply_label_name(lv_obj_t* label); diff --git a/src/ui/screens/contacts/contacts_page_components.cpp b/src/ui/screens/contacts/contacts_page_components.cpp index 8a5ad07e..c359e192 100644 --- a/src/ui/screens/contacts/contacts_page_components.cpp +++ b/src/ui/screens/contacts/contacts_page_components.cpp @@ -30,6 +30,8 @@ using namespace contacts::ui; +lv_obj_t* ui_chat_get_container(); + static constexpr int kItemsPerPage = 4; static constexpr int kButtonHeight = 32; static constexpr int kButtonWidth = 80; @@ -37,6 +39,7 @@ static constexpr int kButtonWidth = 80; static lv_group_t* s_compose_group = nullptr; static lv_group_t* s_compose_prev_group = nullptr; static uint32_t s_compose_peer_id = 0; +static chat::ChannelId s_compose_channel = chat::ChannelId::PRIMARY; static bool s_refreshing_ui = false; // --- Forward declarations (same behavior as original) --- @@ -56,6 +59,7 @@ static void on_action_back_clicked(lv_event_t* e); static void ensure_action_buttons(); // 新增:创建/更新第三列按钮 static void on_action_clicked(lv_event_t* e); static const chat::contacts::NodeInfo* get_selected_node(); +static chat::ChannelId get_selected_channel(); static void open_add_edit_modal(bool is_edit); static void open_delete_confirm_modal(); static void open_info_modal(); @@ -156,6 +160,10 @@ void create_filter_panel(lv_obj_t* parent) lv_obj_add_event_cb(g_contacts_state.nearby_btn, on_filter_focused, LV_EVENT_FOCUSED, nullptr); lv_obj_add_event_cb(g_contacts_state.nearby_btn, on_filter_clicked, LV_EVENT_CLICKED, nullptr); } + if (g_contacts_state.broadcast_btn) { + lv_obj_add_event_cb(g_contacts_state.broadcast_btn, on_filter_focused, LV_EVENT_FOCUSED, nullptr); + lv_obj_add_event_cb(g_contacts_state.broadcast_btn, on_filter_clicked, LV_EVENT_CLICKED, nullptr); + } // TopBar back button must be reachable by rotary and also respond to press // (Press should exit page, not enter List column) @@ -165,14 +173,17 @@ void create_filter_panel(lv_obj_t* parent) // Keep highlight consistent with mode using CHECKED state // (visual-only; does not change behavior) - if (g_contacts_state.contacts_btn && g_contacts_state.nearby_btn) { + if (g_contacts_state.contacts_btn && g_contacts_state.nearby_btn && g_contacts_state.broadcast_btn) { lv_obj_clear_state(g_contacts_state.contacts_btn, LV_STATE_CHECKED); lv_obj_clear_state(g_contacts_state.nearby_btn, LV_STATE_CHECKED); + lv_obj_clear_state(g_contacts_state.broadcast_btn, LV_STATE_CHECKED); if (g_contacts_state.current_mode == ContactsMode::Contacts) { lv_obj_add_state(g_contacts_state.contacts_btn, LV_STATE_CHECKED); - } else { + } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { lv_obj_add_state(g_contacts_state.nearby_btn, LV_STATE_CHECKED); + } else { + lv_obj_add_state(g_contacts_state.broadcast_btn, LV_STATE_CHECKED); } } } @@ -218,6 +229,7 @@ static void on_filter_focused(lv_event_t* e) ContactsMode new_mode = g_contacts_state.current_mode; if (tgt == g_contacts_state.contacts_btn) new_mode = ContactsMode::Contacts; else if (tgt == g_contacts_state.nearby_btn) new_mode = ContactsMode::Nearby; + else if (tgt == g_contacts_state.broadcast_btn) new_mode = ContactsMode::Broadcast; else return; if (new_mode != g_contacts_state.current_mode) { @@ -290,6 +302,9 @@ static void on_back_clicked(lv_event_t* /*e*/) static const chat::contacts::NodeInfo* get_selected_node() { + if (g_contacts_state.current_mode == ContactsMode::Broadcast) { + return nullptr; + } if (g_contacts_state.selected_index < 0) { return nullptr; } @@ -302,6 +317,20 @@ static const chat::contacts::NodeInfo* get_selected_node() return &list[g_contacts_state.selected_index]; } +static chat::ChannelId get_selected_channel() +{ + if (g_contacts_state.current_mode != ContactsMode::Broadcast) { + return chat::ChannelId::PRIMARY; + } + if (g_contacts_state.selected_index < 0) { + return chat::ChannelId::PRIMARY; + } + if (g_contacts_state.selected_index == 1) { + return chat::ChannelId::SECONDARY; + } + return chat::ChannelId::PRIMARY; +} + static void modal_prepare_group() { if (!g_contacts_state.modal_group) { @@ -560,13 +589,18 @@ static void open_chat_compose() return; } const auto* node = get_selected_node(); - if (!node) { + if (g_contacts_state.current_mode != ContactsMode::Broadcast && !node) { return; } lv_obj_t* parent = g_contacts_state.root ? lv_obj_get_parent(g_contacts_state.root) : lv_screen_active(); + if (lv_obj_t* chat_parent = ui_chat_get_container()) { + if (lv_obj_is_valid(chat_parent)) { + parent = chat_parent; + } + } s_compose_prev_group = lv_group_get_default(); if (!s_compose_group) { @@ -575,7 +609,25 @@ static void open_chat_compose() lv_group_remove_all_objs(s_compose_group); set_default_group(s_compose_group); - chat::ConversationId conv(chat::ChannelId::PRIMARY, node->node_id); + chat::ChannelId channel = chat::ChannelId::PRIMARY; + uint32_t peer_id = 0; + std::string title; + if (g_contacts_state.current_mode == ContactsMode::Broadcast) { + channel = get_selected_channel(); + peer_id = 0; + title = "Broadcast"; + } else { + channel = chat::ChannelId::PRIMARY; + peer_id = node->node_id; + if (g_contacts_state.contact_service) { + title = g_contacts_state.contact_service->getContactName(node->node_id); + } + if (title.empty()) { + title = node->display_name; + } + } + + chat::ConversationId conv(channel, peer_id); g_contacts_state.compose_screen = new chat::ui::ChatComposeScreen(parent, conv); g_contacts_state.compose_screen->setActionCallback(on_compose_action, nullptr); g_contacts_state.compose_screen->setBackCallback(on_compose_back, nullptr); @@ -593,15 +645,9 @@ static void open_chat_compose() } } - std::string title; - if (g_contacts_state.contact_service) { - title = g_contacts_state.contact_service->getContactName(node->node_id); - } - if (title.empty()) { - title = node->display_name; - } g_contacts_state.compose_screen->setHeaderText(title.c_str(), "RSSI --"); - s_compose_peer_id = node->node_id; + s_compose_peer_id = peer_id; + s_compose_channel = channel; if (g_contacts_state.root) { lv_obj_add_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN); @@ -624,6 +670,7 @@ static void close_chat_compose() delete g_contacts_state.compose_screen; g_contacts_state.compose_screen = nullptr; s_compose_peer_id = 0; + s_compose_channel = chat::ChannelId::PRIMARY; if (g_contacts_state.root) { lv_obj_clear_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN); @@ -645,12 +692,11 @@ static void close_chat_compose() static void on_compose_action(bool send, void* /*user_data*/) { - if (send && g_contacts_state.compose_screen && s_compose_peer_id != 0 && - g_contacts_state.chat_service) { + if (send && g_contacts_state.compose_screen && g_contacts_state.chat_service) { std::string text = g_contacts_state.compose_screen->getText(); if (!text.empty()) { g_contacts_state.chat_service->sendText( - chat::ChannelId::PRIMARY, text, s_compose_peer_id + s_compose_channel, text, s_compose_peer_id ); } } @@ -934,19 +980,28 @@ static void ensure_action_buttons() lv_obj_clear_flag(g_contacts_state.edit_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(g_contacts_state.del_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag (g_contacts_state.add_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(g_contacts_state.info_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_set_style_bg_color(g_contacts_state.chat_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.edit_btn, lv_color_hex(0xF1B65A), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.del_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.info_btn, lv_color_hex(0xF1B65A), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.action_back_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); - } else { + } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { lv_obj_add_flag (g_contacts_state.edit_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag (g_contacts_state.del_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(g_contacts_state.add_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(g_contacts_state.info_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_set_style_bg_color(g_contacts_state.chat_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.add_btn, lv_color_hex(0xF1B65A), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.info_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.action_back_btn, lv_color_hex(0xF1B65A), LV_PART_MAIN); + } else { + lv_obj_add_flag (g_contacts_state.edit_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag (g_contacts_state.del_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag (g_contacts_state.add_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag (g_contacts_state.info_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_style_bg_color(g_contacts_state.chat_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); + lv_obj_set_style_bg_color(g_contacts_state.action_back_btn, lv_color_hex(0xF1B65A), LV_PART_MAIN); } /* ---------- enable / disable ---------- */ @@ -957,12 +1012,14 @@ static void ensure_action_buttons() set_enabled(g_contacts_state.action_back_btn, true); set_enabled(g_contacts_state.chat_btn, has_sel); - set_enabled(g_contacts_state.info_btn, has_sel); + if (g_contacts_state.current_mode != ContactsMode::Broadcast) { + set_enabled(g_contacts_state.info_btn, has_sel); + } if (g_contacts_state.current_mode == ContactsMode::Contacts) { set_enabled(g_contacts_state.edit_btn, has_sel); set_enabled(g_contacts_state.del_btn, has_sel); - } else { + } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { set_enabled(g_contacts_state.add_btn, has_sel); } } @@ -1015,6 +1072,20 @@ void refresh_ui() } s_refreshing_ui = true; + lv_obj_t* active = lv_screen_active(); + if (!active) { + CONTACTS_LOG("[Contacts] WARNING: lv_screen_active() is null\n"); + } else { + CONTACTS_LOG("[Contacts] refresh_ui: active=%p root=%p list_panel=%p\n", + active, g_contacts_state.root, g_contacts_state.list_panel); + } + if (g_contacts_state.root && !lv_obj_is_valid(g_contacts_state.root)) { + CONTACTS_LOG("[Contacts] WARNING: root is invalid\n"); + } + if (g_contacts_state.list_panel && !lv_obj_is_valid(g_contacts_state.list_panel)) { + CONTACTS_LOG("[Contacts] WARNING: list_panel is invalid\n"); + } + lv_obj_clear_flag(g_contacts_state.list_panel, LV_OBJ_FLAG_SCROLLABLE); if (g_contacts_state.sub_container) { lv_obj_clear_flag(g_contacts_state.sub_container, LV_OBJ_FLAG_SCROLLABLE); @@ -1042,11 +1113,23 @@ void refresh_ui() g_contacts_state.list_items.clear(); // Choose list by mode (unchanged) - const auto& current_list = (g_contacts_state.current_mode == ContactsMode::Contacts) - ? g_contacts_state.contacts_list - : g_contacts_state.nearby_list; + std::vector broadcast_list; + const std::vector* current_list = nullptr; + if (g_contacts_state.current_mode == ContactsMode::Contacts) { + current_list = &g_contacts_state.contacts_list; + } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { + current_list = &g_contacts_state.nearby_list; + } else { + chat::contacts::NodeInfo primary{}; + primary.display_name = "Primary"; + chat::contacts::NodeInfo secondary{}; + secondary.display_name = "Secondary"; + broadcast_list.push_back(primary); + broadcast_list.push_back(secondary); + current_list = &broadcast_list; + } - g_contacts_state.total_items = current_list.size(); + g_contacts_state.total_items = current_list->size(); if (g_contacts_state.selected_index >= static_cast(g_contacts_state.total_items)) { g_contacts_state.selected_index = -1; @@ -1071,13 +1154,15 @@ void refresh_ui() // Create list items for current page (structure in layout; status string computed here) for (int i = start_idx; i < end_idx; ++i) { - const auto& node = current_list[i]; + const auto& node = (*current_list)[i]; std::string status_text; if (g_contacts_state.current_mode == ContactsMode::Contacts) { status_text = format_time_status(node.last_seen); - } else { + } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { status_text = format_snr(node.snr); + } else { + status_text = "Channel"; } lv_obj_t* item = contacts::ui::layout::create_list_item( @@ -1143,14 +1228,19 @@ void refresh_ui() lv_obj_clear_state(g_contacts_state.back_btn, LV_STATE_DISABLED); // Update filter highlights (visual-only, using CHECKED state) - if (g_contacts_state.contacts_btn != nullptr && g_contacts_state.nearby_btn != nullptr) { + if (g_contacts_state.contacts_btn != nullptr && + g_contacts_state.nearby_btn != nullptr && + g_contacts_state.broadcast_btn != nullptr) { lv_obj_clear_state(g_contacts_state.contacts_btn, LV_STATE_CHECKED); lv_obj_clear_state(g_contacts_state.nearby_btn, LV_STATE_CHECKED); + lv_obj_clear_state(g_contacts_state.broadcast_btn, LV_STATE_CHECKED); if (g_contacts_state.current_mode == ContactsMode::Contacts) { lv_obj_add_state(g_contacts_state.contacts_btn, LV_STATE_CHECKED); - } else { + } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { lv_obj_add_state(g_contacts_state.nearby_btn, LV_STATE_CHECKED); + } else { + lv_obj_add_state(g_contacts_state.broadcast_btn, LV_STATE_CHECKED); } } @@ -1166,12 +1256,6 @@ void refresh_ui() } s_refreshing_ui = false; - CONTACTS_LOG("[Contacts] UI refreshed: mode=%d, page=%d/%d, items=%zu\n", - (int)g_contacts_state.current_mode, - g_contacts_state.current_page + 1, - (static_cast(g_contacts_state.total_items) + kItemsPerPage - 1) / kItemsPerPage, - g_contacts_state.total_items); - ensure_action_buttons(); contacts_input_on_ui_refreshed(); } diff --git a/src/ui/screens/contacts/contacts_page_input.cpp b/src/ui/screens/contacts/contacts_page_input.cpp index 76c4e74b..ea0beaba 100644 --- a/src/ui/screens/contacts/contacts_page_input.cpp +++ b/src/ui/screens/contacts/contacts_page_input.cpp @@ -83,6 +83,7 @@ static void bind_filter_column(bool keep_mode_focus) // ② Filter buttons if (g_contacts_state.contacts_btn) lv_group_add_obj(s_group, g_contacts_state.contacts_btn); if (g_contacts_state.nearby_btn) lv_group_add_obj(s_group, g_contacts_state.nearby_btn); + if (g_contacts_state.broadcast_btn) lv_group_add_obj(s_group, g_contacts_state.broadcast_btn); // Focus preference: // - keep_mode_focus: focus current mode button (better for rotate-to-switch-mode UX) @@ -92,12 +93,16 @@ static void bind_filter_column(bool keep_mode_focus) focus_first_valid(g_contacts_state.contacts_btn); } else if (g_contacts_state.current_mode == ContactsMode::Nearby && g_contacts_state.nearby_btn) { focus_first_valid(g_contacts_state.nearby_btn); + } else if (g_contacts_state.current_mode == ContactsMode::Broadcast && g_contacts_state.broadcast_btn) { + focus_first_valid(g_contacts_state.broadcast_btn); } else if (g_contacts_state.top_bar.back_btn) { focus_first_valid(g_contacts_state.top_bar.back_btn); } else if (g_contacts_state.contacts_btn) { focus_first_valid(g_contacts_state.contacts_btn); } else if (g_contacts_state.nearby_btn) { focus_first_valid(g_contacts_state.nearby_btn); + } else if (g_contacts_state.broadcast_btn) { + focus_first_valid(g_contacts_state.broadcast_btn); } } else { // first entry: prefer current mode button @@ -105,16 +110,19 @@ static void bind_filter_column(bool keep_mode_focus) focus_first_valid(g_contacts_state.contacts_btn); } else if (g_contacts_state.current_mode == ContactsMode::Nearby && g_contacts_state.nearby_btn) { focus_first_valid(g_contacts_state.nearby_btn); + } else if (g_contacts_state.current_mode == ContactsMode::Broadcast && g_contacts_state.broadcast_btn) { + focus_first_valid(g_contacts_state.broadcast_btn); } else if (g_contacts_state.contacts_btn) { focus_first_valid(g_contacts_state.contacts_btn); } else if (g_contacts_state.nearby_btn) { focus_first_valid(g_contacts_state.nearby_btn); + } else if (g_contacts_state.broadcast_btn) { + focus_first_valid(g_contacts_state.broadcast_btn); } else if (g_contacts_state.top_bar.back_btn) { focus_first_valid(g_contacts_state.top_bar.back_btn); } } - CONTACTS_LOG("[Contacts][Input] bind_filter_column (with topbar back)\n"); } @@ -159,11 +167,13 @@ static void bind_action_column() if (g_contacts_state.current_mode == ContactsMode::Contacts) { if (g_contacts_state.edit_btn) { lv_group_add_obj(s_group, g_contacts_state.edit_btn); any = true; } if (g_contacts_state.del_btn) { lv_group_add_obj(s_group, g_contacts_state.del_btn); any = true; } - } else { + } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { if (g_contacts_state.add_btn) { lv_group_add_obj(s_group, g_contacts_state.add_btn); any = true; } } - if (g_contacts_state.info_btn) { lv_group_add_obj(s_group, g_contacts_state.info_btn); any = true; } + if (g_contacts_state.current_mode != ContactsMode::Broadcast) { + if (g_contacts_state.info_btn) { lv_group_add_obj(s_group, g_contacts_state.info_btn); any = true; } + } // ② Back (added last, not the default focus) if (g_contacts_state.action_back_btn) { diff --git a/src/ui/screens/contacts/contacts_page_layout.cpp b/src/ui/screens/contacts/contacts_page_layout.cpp index b3c13421..59215a5a 100644 --- a/src/ui/screens/contacts/contacts_page_layout.cpp +++ b/src/ui/screens/contacts/contacts_page_layout.cpp @@ -218,6 +218,14 @@ void create_filter_panel(lv_obj_t* parent) lv_obj_t* nearby_label = lv_label_create(g_contacts_state.nearby_btn); lv_label_set_text(nearby_label, "Nearby"); lv_obj_center(nearby_label); + + g_contacts_state.broadcast_btn = lv_btn_create(g_contacts_state.filter_panel); + make_non_scrollable(g_contacts_state.broadcast_btn); + style::apply_btn_filter(g_contacts_state.broadcast_btn); + lv_obj_set_size(g_contacts_state.broadcast_btn, LV_PCT(100), kButtonHeight); + lv_obj_t* broadcast_label = lv_label_create(g_contacts_state.broadcast_btn); + lv_label_set_text(broadcast_label, "Broadcast"); + lv_obj_center(broadcast_label); } void create_list_panel(lv_obj_t* parent) diff --git a/src/ui/screens/contacts/contacts_state.h b/src/ui/screens/contacts/contacts_state.h index a7252a2b..834b206a 100644 --- a/src/ui/screens/contacts/contacts_state.h +++ b/src/ui/screens/contacts/contacts_state.h @@ -32,7 +32,8 @@ namespace ui { enum class ContactsMode { Contacts, // Show contacts (nodes with nicknames) - Nearby // Show nearby nodes (nodes without nicknames) + Nearby, // Show nearby nodes (nodes without nicknames) + Broadcast // Show broadcast channels }; struct ContactsPageState { @@ -45,6 +46,7 @@ struct ContactsPageState { lv_obj_t* filter_panel = nullptr; lv_obj_t* contacts_btn = nullptr; lv_obj_t* nearby_btn = nullptr; + lv_obj_t* broadcast_btn = nullptr; // Second column: Node list lv_obj_t* list_panel = nullptr; diff --git a/src/ui/screens/settings/settings_page_components.cpp b/src/ui/screens/settings/settings_page_components.cpp index c5f76a54..ac8859ab 100644 --- a/src/ui/screens/settings/settings_page_components.cpp +++ b/src/ui/screens/settings/settings_page_components.cpp @@ -9,7 +9,6 @@ #include #include "../../../app/app_context.h" -#include "../../../board/rtc_utils.h" #include "../../../chat/domain/chat_types.h" #include "../../../chat/infra/meshtastic/generated/meshtastic/config.pb.h" #include "../../../chat/infra/meshtastic/mt_region.h" @@ -596,11 +595,9 @@ static void on_option_clicked(lv_event_t* e) } if (payload->item->pref_key && strcmp(payload->item->pref_key, "timezone_offset") == 0) { - int delta = payload->value - previous_value; - if (delta != 0) - { - board_adjust_rtc_by_offset_minutes(delta); - } + ui_set_timezone_offset_min(payload->value); + (void)previous_value; + restart_now = true; } modal_close(); if (restart_now) diff --git a/src/ui/screens/team/team_page_components.cpp b/src/ui/screens/team/team_page_components.cpp new file mode 100644 index 00000000..8d406613 --- /dev/null +++ b/src/ui/screens/team/team_page_components.cpp @@ -0,0 +1,220 @@ +/** + * @file team_page_components.cpp + * @brief Team page components + */ + +#include "team_page_components.h" +#include "team_page_layout.h" +#include "team_page_styles.h" +#include "team_page_input.h" +#include "team_state.h" +#include "../../ui_common.h" +#include "../../../app/app_context.h" +#include "../../../team/protocol/team_mgmt.h" + +namespace team +{ +namespace ui +{ +namespace +{ +void update_top_bar_title(const char* title) +{ + if (!title) + { + return; + } + ::ui::widgets::top_bar_set_title(g_team_state.top_bar_widget, title); +} + +void reset_team_ui_state() +{ + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + controller->resetUiState(); + } +} + +void top_bar_back(void*) +{ + reset_team_ui_state(); + team_page_destroy(); + menu_show(); +} + +void label_set_text(lv_obj_t* btn, const char* text) +{ + lv_obj_t* label = lv_label_create(btn); + lv_label_set_text(label, text); + lv_obj_center(label); +} + +void handle_create(lv_event_t*) +{ + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (!controller) + { + return; + } + team::proto::TeamAdvertise advertise; + controller->onCreateTeam(advertise, chat::ChannelId::PRIMARY); + g_team_state.title_override = "Create Team"; + update_top_bar_title(g_team_state.title_override); + team_page_refresh(); +} + +void handle_join(lv_event_t*) +{ + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (!controller) + { + return; + } + team::proto::TeamJoinRequest join_request; + controller->onJoinTeam(join_request, chat::ChannelId::PRIMARY, 0); + g_team_state.title_override = "Join Team"; + update_top_bar_title(g_team_state.title_override); + team_page_refresh(); +} + +void handle_placeholder(lv_event_t*) +{ + team_page_refresh(); +} + +} // namespace + +void team_page_create(lv_obj_t* parent) +{ + if (g_team_state.root) + { + lv_obj_del(g_team_state.root); + g_team_state.root = nullptr; + } + + style::init_once(); + + g_team_state.root = layout::create_root(parent); + g_team_state.header = layout::create_header(g_team_state.root); + g_team_state.content = layout::create_content(g_team_state.root); + g_team_state.state_container = layout::create_state_container(g_team_state.content); + g_team_state.idle_container = layout::create_idle_container(g_team_state.state_container); + g_team_state.active_container = layout::create_active_container(g_team_state.state_container); + + style::apply_root(g_team_state.root); + style::apply_header(g_team_state.header); + style::apply_content(g_team_state.content); + style::apply_state_container(g_team_state.state_container); + style::apply_idle_container(g_team_state.idle_container); + style::apply_active_container(g_team_state.active_container); + style::apply_status_label(g_team_state.idle_status_label); + style::apply_status_label(g_team_state.active_status_label); + + style::apply_button_primary(g_team_state.create_btn); + style::apply_button_secondary(g_team_state.join_btn); + style::apply_button_secondary(g_team_state.share_btn); + style::apply_button_secondary(g_team_state.leave_btn); + style::apply_button_secondary(g_team_state.disband_btn); + + ::ui::widgets::TopBarConfig cfg; + cfg.height = ::ui::widgets::kTopBarHeight; + ::ui::widgets::top_bar_init(g_team_state.top_bar_widget, g_team_state.header, cfg); + update_top_bar_title("Team"); + ::ui::widgets::top_bar_set_back_callback(g_team_state.top_bar_widget, top_bar_back, nullptr); + ui_update_top_bar_battery(g_team_state.top_bar_widget); + + label_set_text(g_team_state.create_btn, "Create Team"); + label_set_text(g_team_state.join_btn, "Join Team"); + label_set_text(g_team_state.share_btn, "Share Team"); + label_set_text(g_team_state.leave_btn, "Leave Team"); + label_set_text(g_team_state.disband_btn, "Disband Team"); + + lv_obj_add_event_cb(g_team_state.create_btn, handle_create, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(g_team_state.join_btn, handle_join, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(g_team_state.share_btn, handle_placeholder, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(g_team_state.leave_btn, handle_placeholder, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(g_team_state.disband_btn, handle_placeholder, LV_EVENT_CLICKED, nullptr); + + init_team_input(); + team_page_refresh(); +} + +void team_page_destroy() +{ + cleanup_team_input(); + + if (g_team_state.root) + { + lv_obj_del(g_team_state.root); + g_team_state.root = nullptr; + } + g_team_state = TeamPageState{}; +} + +void team_page_refresh() +{ + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + team::TeamUiState state = controller ? controller->getState() : team::TeamUiState::Idle; + const char* title = "Team"; + + bool idle = (state == team::TeamUiState::Idle); + + if (idle) + { + lv_obj_clear_flag(g_team_state.idle_container, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(g_team_state.idle_container, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_add_flag(g_team_state.active_container, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(g_team_state.active_container, LV_OBJ_FLAG_IGNORE_LAYOUT); + } + else + { + lv_obj_add_flag(g_team_state.idle_container, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(g_team_state.idle_container, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_clear_flag(g_team_state.active_container, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(g_team_state.active_container, LV_OBJ_FLAG_IGNORE_LAYOUT); + } + + if (idle) + { + lv_label_set_text(g_team_state.idle_status_label, "You are not in a Team"); + } + else + { + lv_label_set_text(g_team_state.active_status_label, "Team Active"); + } + + if (state == team::TeamUiState::PendingJoin) + { + title = "Join Team"; + } + else if (state == team::TeamUiState::Active && g_team_state.title_override) + { + title = g_team_state.title_override; + } + update_top_bar_title(title); + + refresh_team_input(); +} + +} // namespace ui +} // namespace team + +void team_page_create(lv_obj_t* parent) +{ + team::ui::team_page_create(parent); +} + +void team_page_destroy() +{ + team::ui::team_page_destroy(); +} + +void team_page_refresh() +{ + team::ui::team_page_refresh(); +} diff --git a/src/ui/screens/team/team_page_components.h b/src/ui/screens/team/team_page_components.h new file mode 100644 index 00000000..3ef7666e --- /dev/null +++ b/src/ui/screens/team/team_page_components.h @@ -0,0 +1,12 @@ +/** + * @file team_page_components.h + * @brief Team page components (public interface) + */ + +#pragma once + +#include "lvgl.h" + +void team_page_create(lv_obj_t* parent); +void team_page_destroy(); +void team_page_refresh(); diff --git a/src/ui/screens/team/team_page_input.cpp b/src/ui/screens/team/team_page_input.cpp new file mode 100644 index 00000000..aa8b5903 --- /dev/null +++ b/src/ui/screens/team/team_page_input.cpp @@ -0,0 +1,117 @@ +/** + * @file team_page_input.cpp + * @brief Team page input handling + */ + +#include +#include "team_page_input.h" +#include "team_state.h" +#include "../../ui_common.h" +#include "../../../app/app_context.h" + +extern lv_group_t* app_g; + +namespace team +{ +namespace ui +{ +namespace +{ +lv_group_t* s_group = nullptr; + +void group_clear_all(lv_group_t* group) +{ + if (!group) + { + return; + } + lv_group_remove_all_objs(group); +} + +void add_if(lv_obj_t* obj) +{ + if (obj && s_group) + { + lv_group_add_obj(s_group, obj); + } +} +} // namespace + +void init_team_input() +{ + extern lv_group_t* app_g; + if (!::app_g) + { + return; + } + + s_group = ::app_g; + set_default_group(s_group); + refresh_team_input(); +} + +void refresh_team_input() +{ + if (!s_group) + { + return; + } + group_clear_all(s_group); + + if (g_team_state.top_bar_widget.back_btn) + { + lv_group_add_obj(s_group, g_team_state.top_bar_widget.back_btn); + } + + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + team::TeamUiState state = controller ? controller->getState() : team::TeamUiState::Idle; + + if (state == team::TeamUiState::Idle) + { + add_if(g_team_state.create_btn); + add_if(g_team_state.join_btn); + if (g_team_state.create_btn) + { + lv_group_focus_obj(g_team_state.create_btn); + } + } + else + { + add_if(g_team_state.share_btn); + add_if(g_team_state.leave_btn); + add_if(g_team_state.disband_btn); + if (g_team_state.share_btn) + { + lv_group_focus_obj(g_team_state.share_btn); + } + } +} + +void cleanup_team_input() +{ + if (!s_group) + { + return; + } + group_clear_all(s_group); + s_group = nullptr; +} + +} // namespace ui +} // namespace team + +void init_team_input() +{ + team::ui::init_team_input(); +} + +void refresh_team_input() +{ + team::ui::refresh_team_input(); +} + +void cleanup_team_input() +{ + team::ui::cleanup_team_input(); +} diff --git a/src/ui/screens/team/team_page_input.h b/src/ui/screens/team/team_page_input.h new file mode 100644 index 00000000..afab754f --- /dev/null +++ b/src/ui/screens/team/team_page_input.h @@ -0,0 +1,10 @@ +/** + * @file team_page_input.h + * @brief Team page input handling + */ + +#pragma once + +void init_team_input(); +void refresh_team_input(); +void cleanup_team_input(); diff --git a/src/ui/screens/team/team_page_layout.cpp b/src/ui/screens/team/team_page_layout.cpp new file mode 100644 index 00000000..6a173a5c --- /dev/null +++ b/src/ui/screens/team/team_page_layout.cpp @@ -0,0 +1,168 @@ +/** + * UI Wireframe / Layout Tree + * -------------------------------------------------------------------- + * + * Root (COLUMN) + * ├─ Header (TopBar) + * └─ Content (flex-grow = 1) + * └─ StateContainer (COLUMN, centered) + * ├─ IdleContainer + * │ ├─ IdleStatusLabel + * │ ├─ CreateBtn + * │ └─ JoinBtn + * └─ ActiveContainer + * ├─ ActiveStatusLabel + * ├─ ViewMapBtn + * ├─ ShareBtn + * ├─ LeaveBtn + * └─ DisbandBtn + * + * Tree view: + * Root(COL) + * ├─ Header + * └─ Content(COL) + * └─ StateContainer(COL) + * ├─ IdleContainer(COL) -> IdleStatusLabel, CreateBtn, JoinBtn + * └─ ActiveContainer(COL) -> ActiveStatusLabel, ViewMapBtn, ShareBtn, LeaveBtn, DisbandBtn + * + * ASCII wireframe (layout only): + * +----------------------------------------------------------+ + * | Header (TopBar) | + * +----------------------------------------------------------+ + * | | + * | +----------------------------------------------------+ | + * | | StateContainer (column, centered) | | + * | | | | + * | | +------------------------------+ | | + * | | | IdleContainer (column) | | | + * | | | [StatusLabel] | | | + * | | | [CreateBtn] | | | + * | | | [JoinBtn] | | | + * | | +------------------------------+ | | + * | | | | + * | | +------------------------------+ | | + * | | | ActiveContainer (column) | | | + * | | | [StatusLabel] | | | + * | | | [ViewMapBtn] | | | + * | | | [ShareBtn] | | | + * | | | [LeaveBtn] | | | + * | | | [DisbandBtn] | | | + * | | +------------------------------+ | | + * | +----------------------------------------------------+ | + * | | + * +----------------------------------------------------------+ */ + +/** + * @file team_page_layout.cpp + * @brief Team page layout + */ + +#include "team_page_layout.h" +#include "team_state.h" + +namespace team +{ +namespace ui +{ +namespace layout +{ + +namespace +{ +constexpr int kButtonWidth = 220; +constexpr int kButtonHeight = 32; + +void make_non_scrollable(lv_obj_t* obj) +{ + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); +} +} // namespace + +lv_obj_t* create_root(lv_obj_t* parent) +{ + lv_obj_t* root = lv_obj_create(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + make_non_scrollable(root); + return root; +} + +lv_obj_t* create_header(lv_obj_t* root) +{ + lv_obj_t* header = lv_obj_create(root); + lv_obj_set_size(header, LV_PCT(100), ::ui::widgets::kTopBarHeight); + make_non_scrollable(header); + return header; +} + +lv_obj_t* create_content(lv_obj_t* root) +{ + lv_obj_t* content = lv_obj_create(root); + lv_obj_set_size(content, LV_PCT(100), 0); + lv_obj_set_flex_grow(content, 1); + lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(content, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + make_non_scrollable(content); + return content; +} + +lv_obj_t* create_state_container(lv_obj_t* content) +{ + lv_obj_t* container = lv_obj_create(content); + lv_obj_set_size(container, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + make_non_scrollable(container); + return container; +} + +lv_obj_t* create_idle_container(lv_obj_t* parent) +{ + lv_obj_t* container = lv_obj_create(parent); + lv_obj_set_width(container, LV_PCT(100)); + lv_obj_set_height(container, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); + make_non_scrollable(container); + + g_team_state.idle_status_label = lv_label_create(container); + g_team_state.create_btn = lv_btn_create(container); + g_team_state.join_btn = lv_btn_create(container); + + lv_obj_set_width(g_team_state.idle_status_label, kButtonWidth); + lv_obj_set_width(g_team_state.create_btn, kButtonWidth); + lv_obj_set_width(g_team_state.join_btn, kButtonWidth); + lv_obj_set_height(g_team_state.create_btn, kButtonHeight); + lv_obj_set_height(g_team_state.join_btn, kButtonHeight); + + return container; +} + +lv_obj_t* create_active_container(lv_obj_t* parent) +{ + lv_obj_t* container = lv_obj_create(parent); + lv_obj_set_width(container, LV_PCT(100)); + lv_obj_set_height(container, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); + make_non_scrollable(container); + + g_team_state.active_status_label = lv_label_create(container); + g_team_state.share_btn = lv_btn_create(container); + g_team_state.leave_btn = lv_btn_create(container); + g_team_state.disband_btn = lv_btn_create(container); + + lv_obj_set_width(g_team_state.active_status_label, kButtonWidth); + lv_obj_set_width(g_team_state.share_btn, kButtonWidth); + lv_obj_set_width(g_team_state.leave_btn, kButtonWidth); + lv_obj_set_width(g_team_state.disband_btn, kButtonWidth); + lv_obj_set_height(g_team_state.share_btn, kButtonHeight); + lv_obj_set_height(g_team_state.leave_btn, kButtonHeight); + lv_obj_set_height(g_team_state.disband_btn, kButtonHeight); + + return container; +} + +} // namespace layout +} // namespace ui +} // namespace team + diff --git a/src/ui/screens/team/team_page_layout.h b/src/ui/screens/team/team_page_layout.h new file mode 100644 index 00000000..48469dce --- /dev/null +++ b/src/ui/screens/team/team_page_layout.h @@ -0,0 +1,26 @@ +/** + * @file team_page_layout.h + * @brief Team page layout (structure only) + */ + +#pragma once + +#include "lvgl.h" + +namespace team +{ +namespace ui +{ +namespace layout +{ + +lv_obj_t* create_root(lv_obj_t* parent); +lv_obj_t* create_header(lv_obj_t* root); +lv_obj_t* create_content(lv_obj_t* root); +lv_obj_t* create_state_container(lv_obj_t* content); +lv_obj_t* create_idle_container(lv_obj_t* parent); +lv_obj_t* create_active_container(lv_obj_t* parent); + +} // namespace layout +} // namespace ui +} // namespace team diff --git a/src/ui/screens/team/team_page_styles.cpp b/src/ui/screens/team/team_page_styles.cpp new file mode 100644 index 00000000..8afbe497 --- /dev/null +++ b/src/ui/screens/team/team_page_styles.cpp @@ -0,0 +1,137 @@ +/** + * @file team_page_styles.cpp + * @brief Team page styles + */ + +#include "team_page_styles.h" + +namespace team +{ +namespace ui +{ +namespace style +{ +namespace +{ +static constexpr uint32_t kBtnBg = 0xF4C77A; +static constexpr uint32_t kBtnBgFoc = 0xEBA341; +static constexpr uint32_t kBtnBorder = 0xEBA341; +static constexpr uint32_t kTextMain = 0x202020; + +static lv_style_t s_root; +static lv_style_t s_header; +static lv_style_t s_content; +static lv_style_t s_state_container; +static lv_style_t s_section; +static lv_style_t s_status; +static lv_style_t s_btn_basic; +static lv_style_t s_btn_focused; +static bool s_inited = false; +} // namespace + +void init_once() +{ + if (s_inited) + { + return; + } + s_inited = true; + + lv_style_init(&s_root); + lv_style_set_bg_opa(&s_root, LV_OPA_TRANSP); + lv_style_set_border_width(&s_root, 0); + lv_style_set_pad_all(&s_root, 0); + + lv_style_init(&s_header); + lv_style_set_bg_color(&s_header, lv_color_white()); + lv_style_set_bg_opa(&s_header, LV_OPA_COVER); + lv_style_set_border_width(&s_header, 0); + lv_style_set_pad_all(&s_header, 0); + + lv_style_init(&s_content); + lv_style_set_bg_opa(&s_content, LV_OPA_TRANSP); + lv_style_set_border_width(&s_content, 0); + lv_style_set_pad_all(&s_content, 0); + lv_style_set_pad_top(&s_content, 3); + + lv_style_init(&s_state_container); + lv_style_set_bg_opa(&s_state_container, LV_OPA_TRANSP); + lv_style_set_border_width(&s_state_container, 0); + lv_style_set_pad_all(&s_state_container, 0); + lv_style_set_pad_row(&s_state_container, 3); + lv_style_set_pad_column(&s_state_container, 0); + + lv_style_init(&s_section); + lv_style_set_bg_opa(&s_section, LV_OPA_TRANSP); + lv_style_set_border_width(&s_section, 0); + lv_style_set_pad_all(&s_section, 0); + lv_style_set_pad_row(&s_section, 3); + lv_style_set_pad_column(&s_section, 0); + + lv_style_init(&s_status); + lv_style_set_text_color(&s_status, lv_color_hex(kTextMain)); + lv_style_set_text_align(&s_status, LV_TEXT_ALIGN_CENTER); + + lv_style_init(&s_btn_basic); + lv_style_set_bg_color(&s_btn_basic, lv_color_hex(kBtnBg)); + lv_style_set_bg_opa(&s_btn_basic, LV_OPA_COVER); + lv_style_set_text_color(&s_btn_basic, lv_color_hex(kTextMain)); + lv_style_set_border_width(&s_btn_basic, 1); + lv_style_set_border_color(&s_btn_basic, lv_color_hex(kBtnBorder)); + lv_style_set_radius(&s_btn_basic, 12); + + lv_style_init(&s_btn_focused); + lv_style_set_bg_color(&s_btn_focused, lv_color_hex(kBtnBgFoc)); + lv_style_set_bg_opa(&s_btn_focused, LV_OPA_COVER); +} + +void apply_root(lv_obj_t* obj) +{ + lv_obj_add_style(obj, &s_root, 0); +} + +void apply_header(lv_obj_t* obj) +{ + lv_obj_add_style(obj, &s_header, 0); +} + +void apply_content(lv_obj_t* obj) +{ + lv_obj_add_style(obj, &s_content, 0); +} + +void apply_state_container(lv_obj_t* obj) +{ + lv_obj_add_style(obj, &s_state_container, 0); +} + +void apply_idle_container(lv_obj_t* obj) +{ + lv_obj_add_style(obj, &s_section, 0); +} + +void apply_active_container(lv_obj_t* obj) +{ + lv_obj_add_style(obj, &s_section, 0); +} + +void apply_status_label(lv_obj_t* obj) +{ + lv_obj_add_style(obj, &s_status, 0); +} + +void apply_button_primary(lv_obj_t* obj) +{ + lv_obj_add_style(obj, &s_btn_basic, LV_PART_MAIN); + lv_obj_add_style(obj, &s_btn_focused, LV_PART_MAIN | LV_STATE_FOCUSED); +} + +void apply_button_secondary(lv_obj_t* obj) +{ + lv_obj_add_style(obj, &s_btn_basic, LV_PART_MAIN); + lv_obj_add_style(obj, &s_btn_focused, LV_PART_MAIN | LV_STATE_FOCUSED); +} + +} // namespace style +} // namespace ui +} // namespace team diff --git a/src/ui/screens/team/team_page_styles.h b/src/ui/screens/team/team_page_styles.h new file mode 100644 index 00000000..e6b29a0e --- /dev/null +++ b/src/ui/screens/team/team_page_styles.h @@ -0,0 +1,31 @@ +/** + * @file team_page_styles.h + * @brief Team page styles + */ + +#pragma once + +#include "lvgl.h" + +namespace team +{ +namespace ui +{ +namespace style +{ + +void init_once(); + +void apply_root(lv_obj_t* obj); +void apply_header(lv_obj_t* obj); +void apply_content(lv_obj_t* obj); +void apply_state_container(lv_obj_t* obj); +void apply_idle_container(lv_obj_t* obj); +void apply_active_container(lv_obj_t* obj); +void apply_status_label(lv_obj_t* obj); +void apply_button_primary(lv_obj_t* obj); +void apply_button_secondary(lv_obj_t* obj); + +} // namespace style +} // namespace ui +} // namespace team diff --git a/src/ui/screens/team/team_state.h b/src/ui/screens/team/team_state.h new file mode 100644 index 00000000..44879cea --- /dev/null +++ b/src/ui/screens/team/team_state.h @@ -0,0 +1,44 @@ +/** + * @file team_state.h + * @brief Team page UI state + */ + +#pragma once + +#include "lvgl.h" +#include "../../widgets/top_bar.h" + +namespace team +{ +namespace ui +{ + +struct TeamPageState +{ + lv_obj_t* root = nullptr; + lv_obj_t* page = nullptr; + lv_obj_t* header = nullptr; + lv_obj_t* content = nullptr; + + lv_obj_t* state_container = nullptr; + lv_obj_t* idle_container = nullptr; + lv_obj_t* active_container = nullptr; + + lv_obj_t* idle_status_label = nullptr; + lv_obj_t* create_btn = nullptr; + lv_obj_t* join_btn = nullptr; + + lv_obj_t* active_status_label = nullptr; + lv_obj_t* share_btn = nullptr; + lv_obj_t* leave_btn = nullptr; + lv_obj_t* disband_btn = nullptr; + const char* title_override = nullptr; + + ::ui::widgets::TopBar top_bar_widget; + lv_group_t* group = nullptr; +}; + +extern TeamPageState g_team_state; + +} // namespace ui +} // namespace team diff --git a/src/ui/ui_chat.cpp b/src/ui/ui_chat.cpp index 46dba54c..024e7fe9 100644 --- a/src/ui/ui_chat.cpp +++ b/src/ui/ui_chat.cpp @@ -11,6 +11,11 @@ static lv_obj_t *chat_container = NULL; static std::unique_ptr ui_controller = nullptr; +lv_obj_t* ui_chat_get_container() +{ + return chat_container; +} + static void back_event_handler(lv_event_t *e) { lv_obj_t *obj = (lv_obj_t *)lv_event_get_target(e); @@ -36,6 +41,13 @@ void ui_chat_enter(lv_obj_t *parent) set_default_group(app_g); } + Serial.printf("[UI Chat] enter: parent=%p active=%p\n", + parent, lv_screen_active()); + if (lv_obj_t* active = lv_screen_active()) { + Serial.printf("[UI Chat] active child count=%u\n", + (unsigned)lv_obj_get_child_cnt(active)); + } + // Create container chat_container = lv_obj_create(parent); lv_obj_set_size(chat_container, LV_PCT(100), LV_PCT(100)); @@ -45,6 +57,14 @@ void ui_chat_enter(lv_obj_t *parent) lv_obj_set_style_pad_all(chat_container, 0, 0); lv_obj_set_style_radius(chat_container, 0, 0); + Serial.printf("[UI Chat] container=%p valid=%d\n", + chat_container, + chat_container ? (lv_obj_is_valid(chat_container) ? 1 : 0) : 0); + if (chat_container) { + Serial.printf("[UI Chat] container child count=%u\n", + (unsigned)lv_obj_get_child_cnt(chat_container)); + } + // Create UI controller ui_controller = std::unique_ptr( new chat::ui::UiController(chat_container, ctx.getChatService())); @@ -57,6 +77,19 @@ void ui_chat_enter(lv_obj_t *parent) void ui_chat_exit(lv_obj_t *parent) { + Serial.printf("[UI Chat] exit: parent=%p container=%p\n", + parent, chat_container); + if (lv_obj_t* active = lv_screen_active()) { + Serial.printf("[UI Chat] exit active child count=%u\n", + (unsigned)lv_obj_get_child_cnt(active)); + } + if (chat_container && lv_obj_is_valid(chat_container)) { + Serial.printf("[UI Chat] exit container child count=%u\n", + (unsigned)lv_obj_get_child_cnt(chat_container)); + } + if (chat_container && !lv_obj_is_valid(chat_container)) { + chat_container = NULL; + } if (chat_container) { lv_obj_clean(chat_container); lv_obj_del(chat_container); diff --git a/src/ui/ui_common.cpp b/src/ui/ui_common.cpp index 89648668..87c67089 100644 --- a/src/ui/ui_common.cpp +++ b/src/ui/ui_common.cpp @@ -14,9 +14,18 @@ extern "C" void lv_draw_buf_destroy(lv_draw_buf_t* draw_buf); #include #include #include +#include extern TLoRaPagerBoard &instance; +namespace { +constexpr const char* kPrefsNs = "settings_v2"; +constexpr const char* kTimezoneKey = "timezone_offset"; + +static bool s_tz_loaded = false; +static int s_tz_offset_min = 0; +} // namespace + void set_default_group(lv_group_t *group) { lv_indev_t *cur_drv = NULL; @@ -40,6 +49,7 @@ void set_default_group(lv_group_t *group) void menu_show() { + ui_clear_active_app(); set_default_group(menu_g); lv_tileview_set_tile_by_index(main_screen, 0, 0, LV_ANIM_ON); } @@ -88,6 +98,33 @@ void ui_update_top_bar_battery(ui::widgets::TopBar& bar) ui::widgets::top_bar_set_right_text(bar, battery_buf); } +int ui_get_timezone_offset_min() +{ + if (!s_tz_loaded) { + Preferences prefs; + prefs.begin(kPrefsNs, true); + s_tz_offset_min = prefs.getInt(kTimezoneKey, 0); + prefs.end(); + s_tz_loaded = true; + } + return s_tz_offset_min; +} + +void ui_set_timezone_offset_min(int offset_min) +{ + s_tz_offset_min = offset_min; + s_tz_loaded = true; +} + +time_t ui_apply_timezone_offset(time_t utc_seconds) +{ + if (utc_seconds <= 0) { + return utc_seconds; + } + int offset_min = ui_get_timezone_offset_min(); + return utc_seconds + static_cast(offset_min) * 60; +} + bool ui_take_screenshot_to_sd() { #if LV_USE_SNAPSHOT @@ -128,7 +165,8 @@ bool ui_take_screenshot_to_sd() char path[64]; time_t now = time(nullptr); - struct tm* info = localtime(&now); + time_t local = ui_apply_timezone_offset(now); + struct tm* info = gmtime(&local); if (info) { char ts[20]; strftime(ts, sizeof(ts), "%Y%m%d_%H%M%S", info); diff --git a/src/ui/ui_common.h b/src/ui/ui_common.h index db01ef0e..cce24a47 100644 --- a/src/ui/ui_common.h +++ b/src/ui/ui_common.h @@ -8,12 +8,14 @@ #include "lvgl.h" #include "widgets/top_bar.h" +#include // Forward declarations extern lv_obj_t *main_screen; extern lv_group_t *menu_g; void menu_show(); +void ui_clear_active_app(); void set_default_group(lv_group_t *group); // Menu creation helper (simplified version of factory's create_menu) @@ -25,6 +27,11 @@ void ui_format_battery(int level, bool charging, char* out, size_t out_len); // Update a shared TopBar's right-side battery text from board state void ui_update_top_bar_battery(ui::widgets::TopBar& bar); +// Timezone offset (minutes) for display-only local time +int ui_get_timezone_offset_min(); +void ui_set_timezone_offset_min(int offset_min); +time_t ui_apply_timezone_offset(time_t utc_seconds); + // Screenshot helper (BMP, RGB565) saved to /sd bool ui_take_screenshot_to_sd(); diff --git a/src/ui/ui_controller.cpp b/src/ui/ui_controller.cpp index 92f7f751..1dbad2de 100644 --- a/src/ui/ui_controller.cpp +++ b/src/ui/ui_controller.cpp @@ -9,6 +9,8 @@ #include "ui_common.h" #include +extern bool isScreenSleeping(); + namespace chat { namespace ui @@ -208,8 +210,17 @@ void UiController::onChatEvent(sys::Event* event) case sys::EventType::ChatSendResult: { sys::ChatSendResultEvent* result_event = (sys::ChatSendResultEvent*)event; - // Update message status in conversation - // (Would need to track message indices) + if (state_ == State::Conversation && conversation_) + { + auto messages = service_.getRecentMessages(current_conv_, 50); + conversation_->clearMessages(); + for (const auto& m : messages) + { + conversation_->addMessage(m); + } + conversation_->scrollToBottom(); + } + (void)result_event; break; } @@ -229,6 +240,16 @@ void UiController::onChatEvent(sys::Event* event) void UiController::switchToChannelList() { state_ = State::ChannelList; + Serial.printf("[UiController] switchToChannelList: parent=%p active=%p sleeping=%d\n", + parent_, lv_screen_active(), isScreenSleeping() ? 1 : 0); + if (lv_obj_t* active = lv_screen_active()) { + Serial.printf("[UiController] switchToChannelList active child count=%u\n", + (unsigned)lv_obj_get_child_cnt(active)); + } + if (parent_) { + Serial.printf("[UiController] switchToChannelList parent child count=%u\n", + (unsigned)lv_obj_get_child_cnt(parent_)); + } if (conversation_) { @@ -255,6 +276,17 @@ void UiController::switchToConversation(chat::ConversationId conv) state_ = State::Conversation; current_channel_ = conv.channel; current_conv_ = conv; + Serial.printf("[UiController] switchToConversation: parent=%p active=%p sleeping=%d conv_peer=%08lX\n", + parent_, lv_screen_active(), isScreenSleeping() ? 1 : 0, + (unsigned long)conv.peer); + if (lv_obj_t* active = lv_screen_active()) { + Serial.printf("[UiController] switchToConversation active child count=%u\n", + (unsigned)lv_obj_get_child_cnt(active)); + } + if (parent_) { + Serial.printf("[UiController] switchToConversation parent child count=%u\n", + (unsigned)lv_obj_get_child_cnt(parent_)); + } if (channel_list_) { @@ -318,6 +350,17 @@ void UiController::switchToCompose(chat::ConversationId conv) state_ = State::Compose; current_channel_ = conv.channel; current_conv_ = conv; + Serial.printf("[UiController] switchToCompose: parent=%p active=%p sleeping=%d conv_peer=%08lX\n", + parent_, lv_screen_active(), isScreenSleeping() ? 1 : 0, + (unsigned long)conv.peer); + if (lv_obj_t* active = lv_screen_active()) { + Serial.printf("[UiController] switchToCompose active child count=%u\n", + (unsigned)lv_obj_get_child_cnt(active)); + } + if (parent_) { + Serial.printf("[UiController] switchToCompose parent child count=%u\n", + (unsigned)lv_obj_get_child_cnt(parent_)); + } if (channel_list_) { @@ -422,14 +465,7 @@ void UiController::refreshUnreadCounts() } channel_list_->setConversations(convs); - for (size_t i = 0; i < convs.size(); ++i) - { - if (convs[i].id == current_conv_) - { - channel_list_->setSelected(static_cast(i)); - break; - } - } + channel_list_->setSelectedConversation(current_conv_); // Update header status (battery only, with icon) channel_list_->updateBatteryFromBoard(); diff --git a/src/ui/ui_team.cpp b/src/ui/ui_team.cpp new file mode 100644 index 00000000..8fe3ead1 --- /dev/null +++ b/src/ui/ui_team.cpp @@ -0,0 +1,35 @@ +/** + * @file ui_team.cpp + * @brief Team page implementation + */ + +#include "ui_team.h" +#include "screens/team/team_page_components.h" +#include "screens/team/team_state.h" +#include "ui_common.h" +#include "../app/app_context.h" + +namespace team +{ +namespace ui +{ +TeamPageState g_team_state; +} // namespace ui +} // namespace team + +void ui_team_enter(lv_obj_t* parent) +{ + team_page_create(parent); +} + +void ui_team_exit(lv_obj_t* parent) +{ + (void)parent; + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + controller->resetUiState(); + } + team_page_destroy(); +} diff --git a/src/ui/ui_team.h b/src/ui/ui_team.h new file mode 100644 index 00000000..6351e9e8 --- /dev/null +++ b/src/ui/ui_team.h @@ -0,0 +1,20 @@ +/** + * @file ui_team.h + * @brief Team page entry point + */ + +#pragma once + +#include "lvgl.h" + +/** + * @brief Enter Team page + * @param parent Parent object (usually main screen) + */ +void ui_team_enter(lv_obj_t* parent); + +/** + * @brief Exit Team page + * @param parent Parent object (can be nullptr) + */ +void ui_team_exit(lv_obj_t* parent); diff --git a/src/ui/widgets/ime/ime_widget.cpp b/src/ui/widgets/ime/ime_widget.cpp index b70c6cf2..27674003 100644 --- a/src/ui/widgets/ime/ime_widget.cpp +++ b/src/ui/widgets/ime/ime_widget.cpp @@ -97,11 +97,6 @@ void ImeWidget::init(lv_obj_t* parent, lv_obj_t* textarea) { self->handle_key(e); }, LV_EVENT_KEY, this); - buffer_label_ = lv_label_create(container_); - lv_label_set_text(buffer_label_, "EN"); - lv_obj_set_style_text_font(buffer_label_, &lv_font_noto_cjk_16_2bpp, 0); - lv_obj_set_style_text_color(buffer_label_, lv_color_hex(0x404040), 0); - candidates_label_ = lv_label_create(container_); lv_label_set_text(candidates_label_, ""); lv_obj_set_style_text_font(candidates_label_, &lv_font_noto_cjk_16_2bpp, 0); @@ -117,7 +112,6 @@ void ImeWidget::detach() { toggle_btn_ = nullptr; toggle_label_ = nullptr; focus_proxy_ = nullptr; - buffer_label_ = nullptr; candidates_label_ = nullptr; textarea_ = nullptr; if (s_active_ime == this) { @@ -169,12 +163,12 @@ bool ImeWidget::handle_key(lv_event_t* e) { } } else if (key == LV_KEY_UP || key == LV_KEY_LEFT) { if (ime_.hasBuffer()) { - ime_.moveCandidate(-kCandidatesPerPage); + ime_.moveCandidate(-1); consumed = true; } } else if (key == LV_KEY_DOWN || key == LV_KEY_RIGHT) { if (ime_.hasBuffer()) { - ime_.moveCandidate(kCandidatesPerPage); + ime_.moveCandidate(1); consumed = true; } } else if (key == LV_KEY_ENTER) { @@ -188,13 +182,11 @@ bool ImeWidget::handle_key(lv_event_t* e) { } } else if (key == ' ') { if (ime_.hasBuffer()) { - ime_.moveCandidate(1); - consumed = true; - } else { - committed = true; - committed_text = " "; - consumed = true; + ime_.reset(); } + committed = true; + committed_text = " "; + consumed = true; } else if (key >= 32 && key <= 126) { char c = static_cast(key); if (c >= 'A' && c <= 'Z') { @@ -223,7 +215,7 @@ bool ImeWidget::handle_key(lv_event_t* e) { } void ImeWidget::refresh_labels() { - if (!toggle_label_ || !buffer_label_ || !candidates_label_) return; + if (!toggle_label_ || !candidates_label_) return; if (textarea_) { if (mode_ == Mode::CN && ime_.hasBuffer()) { @@ -243,24 +235,16 @@ void ImeWidget::refresh_labels() { if (mode_ == Mode::EN) { lv_label_set_text(toggle_label_, "EN"); - lv_label_set_text(buffer_label_, "EN"); lv_label_set_text(candidates_label_, ""); return; } if (mode_ == Mode::NUM) { lv_label_set_text(toggle_label_, "123"); - lv_label_set_text(buffer_label_, "123"); lv_label_set_text(candidates_label_, ""); return; } lv_label_set_text(toggle_label_, "CN"); - if (ime_.hasBuffer()) { - std::string buf = "CN: " + ime_.buffer(); - lv_label_set_text(buffer_label_, buf.c_str()); - } else { - lv_label_set_text(buffer_label_, "CN"); - } refresh_candidates(); } diff --git a/src/ui/widgets/ime/ime_widget.h b/src/ui/widgets/ime/ime_widget.h index 10a48243..c7a2de23 100644 --- a/src/ui/widgets/ime/ime_widget.h +++ b/src/ui/widgets/ime/ime_widget.h @@ -43,7 +43,6 @@ private: lv_obj_t* toggle_btn_ = nullptr; lv_obj_t* toggle_label_ = nullptr; lv_obj_t* focus_proxy_ = nullptr; - lv_obj_t* buffer_label_ = nullptr; lv_obj_t* candidates_label_ = nullptr; lv_obj_t* textarea_ = nullptr; Mode mode_ = Mode::EN; diff --git a/src/ui/widgets/system_notification.h b/src/ui/widgets/system_notification.h index 3dd912c2..c9f068bf 100644 --- a/src/ui/widgets/system_notification.h +++ b/src/ui/widgets/system_notification.h @@ -24,9 +24,9 @@ public: /** * @brief Show a notification * @param text Message text (will be truncated to 15 characters) - * @param duration_ms Display duration in milliseconds (default 5000ms) + * @param duration_ms Display duration in milliseconds (default 3000ms) */ - static void show(const char* text, uint32_t duration_ms = 5000); + static void show(const char* text, uint32_t duration_ms = 3000); /** * @brief Hide the current notification