# 2026-03-29 工作日志

## 项目初始扫描
- 首次读取 firmware-2.7.15.567b8ea 代码结构
- 确认项目为 Meshtastic 官方固件的自定义 fork
- 发现 4 个自研板卡变体：esp32c3_moonshine / moonshine（带ADC/USB CDC）/ moonshine_mv（全功能）/ moonshine_travelers
- 详细记录写入 MEMORY.md

## TCA9535PWR 矩阵键盘驱动实现
- 目标板卡：esp32c3_moonshine_travelers
- 新建驱动文件：
  - `src/input/TCA9535ButtonThread.h` - 驱动类声明，含寄存器宏、extern 指针
  - `src/input/TCA9535ButtonThread.cpp` - 4×4 矩阵扫描实现
- 修改 `variants/esp32c3/diy/esp32c3_moonshine_travelers/variant.h`：
  - 添加 `HAS_TCA9535_BUTTON` 宏
  - 添加 `TCA9535_KEY_MAP`（4×4 矩阵行优先映射）
  - `TCA9535_INT_PIN=5`（GPIO5，低电平有效，下降沿触发）
- 修改 `src/main.cpp`：include TCA9535ButtonThread.h，在 setupModules() 后初始化线程
- I²C 地址：0x20（A0=A1=A2=0），与 SH1106 屏幕共用 Wire（SDA=0, SCL=1）
- 矩阵接法：P0.0~P0.3 行输出，P0.4~P0.7 列输入，逐行拉低扫描，50µs 行间延时

## LTO 链接错误修复
- 编译报 `undefined reference to TCA9535ButtonThread::*`（LTO -flto 导致）
- 原因：.h/.cpp 中的 `#if defined(HAS_TCA9535_BUTTON)` 守卫导致在部分编译单元中符号被丢弃
- 修复：去掉 .h/.cpp 中的条件守卫，让类定义和实现始终编译
  - main.cpp 中的实例化和初始化仍由 `#ifdef HAS_TCA9535_BUTTON` 控制
  - extern 指针声明保留在 `#ifdef` 中，非目标板卡不会引用

## RadioLib LoRa RST 通过 TCA9535 P1.4 控制
- 自定义 HAL 子类 `TCA9535GpioHal` 拦截虚拟引脚 200，转发到 I²C
- `tca9535LoraReset(bool high)` 静态函数，read-modify-write P1.4

## 电源管理（P1.2 + P1.3）
- P1.2 = POWER_EN 输出，高电平有效，驱动 MOS 管维持供电
- P1.3 = POWER_BOOT 输入，低电平有效（按键按下接地）
- 开机流程：物理按键 → MOS 导通 → ESP32 得电 → init() 等 P1.3 持续按住 2 秒 → POWER_EN 拉高
  - 未按够 2 秒松开 → 不拉高 POWER_EN → 断电
- 关机流程：运行中 P1.3 持续按住 2 秒 → POWER_EN 拉低 → 断电
- 电源状态机：BOOT_PENDING → RUNNING → SHUTDOWN_PENDING
- init() 中 P1 config = 0xEB（P1.2=输出, P1.3=输入, P1.4=输出）
- 新增 `tca9535ReadPowerBoot()` 静态函数，读取 P1.3 输入状态
- 新增 `tca9535PowerEn(bool on)` 静态函数，read-modify-write P1.2

## GPS 启用（GP-02 模块）
- 问题：`tca9535GpsEn(false)` 在 TCA9535 init 中关闭了 GPS 电源，而 GPS::setup()->probe() 时 powerState=GPS_OFF，writePinEN(true) 只在 GPS_ACTIVE 时才调用 → probe 时模块没电
- 修复：在 main.cpp 的 `createGps()` + enablePin 桥接之后，立即 `tca9535GpsEn(true)` + `delay(1000)` 给 GP-02 冷启动上电时间
- GP-02 接线：VCC→3.3V, GND→GND, TX→GPIO20(ESP RX), RX→GPIO21(ESP TX)，EN/RST/PPS/VRTC 悬空即可
- 编译验证通过（esp32c3_moonshine_travelers SUCCESS）

## GPS 修复：P1.6/P1.7 默认高电平 + RX/TX 交换
- 用户要求 GPS_RST(P1.6) 和 GPS_EN(P1.7) 通电后直接设为高电平
  - TCA9535ButtonThread.cpp init() 中改为 `tca9535GpsEn(true)` 和 `tca9535GpsReset(true)`
  - 移除 main.cpp 中的 delay workaround
- GPS 仍然检测不到 → 交换 GPS_RX_PIN(21→20) 和 GPS_TX_PIN(20→21)
- 更新 CHANGELOG.md，提交 311232c，push

## 九宫格键盘映射
- 添加 4×4 矩阵中的数字键：key0-2=1-3, key4-6=4-6, key8-10=7-9, key12=*, key13=0, key14=#
- 方向键保留：key3=UP, key7=DOWN, key11=LEFT, key15=RIGHT
- 新增 `TCA9535_KEY_CHAR_MAP` 数组，`dispatchEvent()` 传 `kbchar` 参数

## ⚠️ TCA9535 MATRIXKEY 崩溃修复 + 键盘输入修复
- 崩溃现象：按下矩阵键后立即 Load access fault at 0x40058766
- 根因：`TCA9535_KEY_CHAR_MAP` 传的是 ASCII 字符（如 '0'=0x30, '9'=0x39），但 `CannedMessageModule` 把 `kbchar` 当 1-based 索引用（`currentMessageIndex = event->kbchar - 1`）。ASCII '0'=48 → index=47 → 数组越界 → 野指针 → 崩溃
- 参考 RAK14004 实现：`kbI2cBase.cpp` 中 `PrintDataBuf = aCount*4 + bCount + 1` 传的是 1-16 的索引
- 第一版修复（错误方向）：把 TCA9535_KEY_CHAR_MAP 改为传索引 1-12
  - 问题：这样矩阵键只能选 canned message，无法进入文本输入
- 正确修复：
  1. `TCA9535_KEY_CHAR_MAP` 恢复为 ASCII 字符（'0'-'9', '*', '#'）
  2. `CannedMessageModule::handleInputEvent()` 修改 MATRIXKEY 拦截逻辑：
     - 如果 `kbchar` 是可打印 ASCII（32-126）：不拦截，让事件 fall through 到正常输入路径
       → INACTIVE 状态进入 FREETEXT，FREETEXT 状态追加字符
     - 如果 `kbchar` 是 1-based 索引（如 RAK14004）：继续走 canned message 选择路径
     - 加边界检查 `idx < 0 || idx >= messagesCount` 防御性忽略

## P1.1 充电检测 (CHARGE_DET)
- P1.1 = CHARGE_DET 输入，高电平=正在充电
- P1 config 从 0x8B 改为 0x8D（P1.1 配置为输入）
- variant.h 新增 `TCA9535_CHARGE_DET_PIN (1u << 1)`
- TCA9535ButtonThread.h 新增 `tca9535ReadChargeDet()` 静态内联函数
- TCA9535ButtonThread.cpp 的 runOnce() 每 2 秒轮询 P1.1，更新全局 `tca9535IsCharging`
- Power.cpp 的 `AnalogBatteryLevel::isCharging()` 读取 `tca9535IsCharging`（`#ifdef TCA9535_CHARGE_DET_PIN`）
- Power.cpp include 条件从 `TCA9535_LORA_RST_VIRTUAL_PIN` 改为 `HAS_TCA9535_BUTTON`

## 快捷回复 ↔ 九宫格输入导航
- INACTIVE：UP/DOWN 进入快捷回复列表
- ACTIVE（列表）：LEFT/RIGHT 进入九宫格 FREETEXT
- FREETEXT：LEFT/RIGHT 回列表，保留输入文字
- `*` = 退格，`#` = 切换输入模式
- `isUpEvent()`/`isDownEvent()` 移除 ACTIVE 状态对 LEFT/RIGHT 的映射

## T9 Multi-tap 输入法实现
- 三种模式：abc(小写) / ABC(大写) / 123(数字)，按 `#` 循环切换
- 数字键 2-9 multi-tap 选字母，800ms 超时自动确认
- 数字键 0 = 空格，数字键 1 = 标点循环（`. , ! ?`）
- 屏幕左下角显示当前输入模式标签
- 光标位置实时预览 multi-tap 字符

## ⚠️ T9 commitMultiTap 递归崩溃修复
- 崩溃：`commitMultiTap()` 内调用 `runOnce()` → `runOnce()` 检测超时又调用 `commitMultiTap()` → 无限递归 → 栈溢出 → Store access fault
- 反汇编确认 MEPC=0x40383d3e（systimer 中间件），S11=0x42066020 → `CannedMessageModule::runOnce()`
- 修复：添加 `committingMultiTap` 重入保护标志，`runOnce()` 中检查 `!committingMultiTap` 再调用 `commitMultiTap()`

## ⚠️ T9 payload 残留 + 显示修复
- 连续按两次同一按键回到主页面：疑似 payload 未清零导致 runOnce 非预期行为 + showMultiTapPreview 无用 String 分配导致堆碎片
- 修复：
  1. 所有 `runOnce()` 调用后立即 `payload = 0`（`*` 退格、可打印字符、INPUT_BROKER_BACK 退格、commitMultiTap）
  2. `runOnce()` FREETEXT 分支末尾加 `payload = 0` 作为最后防线
  3. 移除 `showMultiTapPreview()` 中无用 `previewText` String 分配（预览字符由 drawFrame 渲染）
- 输入法模式标签（abc/ABC/123）从左上角移到屏幕右下角（TEXT_ALIGN_RIGHT）

## ⚠️ 任意按两个按键回到主页面 — REGENERATE_FRAMESET 重复触发 focus 丢失
- **根因**：`commitMultiTap()` → `runOnce()` → `notifyObservers(REGENERATE_FRAMESET)` → `Screen::setFrames(FOCUS_MODULE)` 消费了 `requestFocus()` → `_requestingFocus=false`
  然后同一调用链中后续的 `notifyObservers(REGENERATE_FRAMESET)` → `setFrames(FOCUS_MODULE)` → `focusedModule=255`（默认值）→ `ui->switchToFrame(255)` → 跳到主页面
- **关键机制**：`MeshModule::isRequestingFocus()` 是一次性的——调用一次就清零 `_requestingFocus`。`setFrames(FOCUS_MODULE)` 中 `focusedModule` 默认 255，没有 focus request 就跳第一帧
- 修复（4处）：
  1. `commitMultiTap()` 末尾移除多余的 `notifyObservers(REGENERATE_FRAMESET)` + `forceDisplay()`（`runOnce()` 已触发）
  2. `showMultiTapPreview()` 改用 `REDRAW_ONLY` 代替 `REGENERATE_FRAMESET`
  3. `#` 键（切换输入模式）`commitMultiTap()` 后改用 `REDRAW_ONLY`
  4. `runOnce()` 中 multi-tap 超时自动 `commitMultiTap()` 后直接 return，跳过后续 `notifyObservers`
  5. LEFT/RIGHT 返回 ACTIVE 列表时补加 `requestFocus()`（因为 `commitMultiTap` 已消费前一个 focus）
- `commitMultiTap()` 改为返回 `bool` 表示是否实际提交了字符
- 编译验证通过（SUCCESS 33s）

## ⚠️ T9 输入法逻辑修正 — 光标+大小写+数字模式
- **问题1：光标不跳转**：`drawFrame` 中 preview 字符插入到 `displayText` 但 `displayCursor` 没有 +1，导致光标 `_` 画在 preview 字符之前而不是之后。修复：有 preview 时 `displayCursor = cursor + 1`
- **问题2：大小写模式下出现数字**：旧 `t9Map` 每个 key 的 index 0 是数字本身，在 UPPER/LOWER 模式下 multiTapIndex=0 时显示的是数字
- **问题3：0=空格、1=标点、数字只在数字模式输入**：用户要求的行为
- **修复**：
  1. `t9Map` 重命名为 `t9LetterMap`，去掉 index 0 的数字，每个 key 只包含字母模式下的字符
     - 0: `{" ", nullptr}`（空格）
     - 1: `{".", ",", "!", "?", nullptr}`（标点）
     - 2-9: 纯字母
  2. DIGIT 模式下数字键直接输出数字，不走 multi-tap 循环（`payload = '0' + key; runOnce()`）
  3. UPPER/LOWER 模式下使用 `t9LetterMap` + multi-tap 循环，应用大小写转换
  4. `commitMultiTap()` 简化：不再有 index 0 是数字的特殊情况
  5. `drawFrame` preview 逻辑同步简化 + 光标 +1
- 编译验证通过（SUCCESS 36s）

## UTF8 常用中文字库
- **需求**：ESP32-C3 + SH1106 OLED 上显示中文文本
- **方案**：12×12 点阵位图字库，XBM 格式（row-major, MSB first）
- **生成工具**：`tools/gen_chinese_font.mjs`（Node.js + @napi-rs/canvas）
  - 字体源：Windows msyh.ttc（微软雅黑）
  - 字符集：U+4E00-U+74FF（CJK 常用汉字）+ 中文标点 + 全角数字/字母 = 10067 字形
  - 每字形 2 bytes codepoint + 24 bytes bitmap = 26 bytes
- **输出文件**：`src/graphics/fonts/ChineseFont12x12.h`
  - 数据数组：`cfont12_data[]`（PROGMEM，按 codepoint 升序排列）
  - 查找函数：`cfont12_find(cp)` — 二分搜索，O(log n)
  - 渲染函数：`cfont12_draw(display, x, y, cp)` — 用 setPixel 逐像素绘制
  - UTF-8 解码：`cfont12_utf8(p, &len)` — 返回 codepoint 和字节长度
  - 混合绘制：`cfont12_drawStr(display, x, y, s)` — ASCII 用 drawString，CJK 用 cfont12
- **集成点**：`CannedMessageModule.cpp` drawFrame FREETEXT 部分
  - text token 渲染：逐字符判断是否 CJK，CJK 用 cfont12_draw，ASCII 用 drawString
  - word wrap 宽度计算：CJK 字符宽度 = CFONT_W(12)，ASCII 用 getStringWidth
- **Flash 占用**：+256 KB（data 段），剩余 ~1.59 MB（4MB flash）
- 编译验证通过（SUCCESS 51s）

## ⚠️ 分区表修复 — 固件超出 app 分区无法启动
- **现象**：烧录后 ESP32-C3 反复重启，卡在 ROM bootloader，无用户程序日志
- **根因**：256KB 中文字库使固件从 ~2.31MB 增长到 ~2.57MB，超出原 app 分区 0x250000 (2.375MB)
- **修复**：调整 `partition-table.csv`
  - app: 0x250000 → 0x2C0000 (2.75MB)
  - flashApp (OTA): 0x0A0000 → 0x030000 (192KB)
  - spiffs: 0x100000 不变 (1MB)
- 编译验证通过（SUCCESS 34s）

## ⚠️ 中文字形像素错乱修复（完整历程）
- **现象**：中文字形隐约可见但局部像素缺失/多余 → 改 alpha 后变全白方块 → 修复后字体太粗
- **根因**：字库生成判断像素的逻辑有误
  - 第一版用 R 通道 `< 128`（白色背景 + 黑色前景）：抗锯齿边缘灰度 ≈128 在阈值边界，不稳定
  - 第二版用 alpha `> 40`（透明背景 + 黑色前景）：alpha 40 太低，抗锯齿边缘全被纳入 → 字体太粗
- **最终修复**：透明背景 + 黑色前景，alpha 阈值从 40 提高到 **128**
  - 透明区域 alpha=0 → 不标记
  - 黑色字形 alpha=255 → 标记
  - 抗锯齿边缘 alpha<128 → 不标记（去掉毛边，字形更锐利）
- ⚠️ 关键：`imageSmoothingEnabled` 对 canvas fillText 无效，不能用它关抗锯齿
- **Unicode 覆盖扩展**：U+4E00-U+74FF(10067字) → U+4E00-U+9FFF(21075字)
  - 修复"鬼"(U+9B3C)、"界"(U+754C)等常用字缺失
  - 覆盖 GB2312 全集 6763 字 + CJK 统一汉字扩展
  - Flash: 535KB, 固件总大小 2.65MB / 2.75MB (92%)
- **问题**：收到的消息包含中文时无法正常显示
- **根因**：`MessageRenderer.cpp` 的 `generateLines()` 逐字节处理 UTF-8，CJK 三字节字符被拆散；`drawStringWithEmotes()` 用 `drawString()` 渲染，不支持 CJK
- **修复**（3处改动）：
  1. 添加辅助函数 `measureMixedWidth()`（混合宽度计算）和 `drawMixedString()`（混合渲染）
  2. `generateLines()` 改为 UTF-8 感知的逐字符循环：CJK 字符作为独立 word 处理，宽度用 `measureMixedWidth()`
  3. `drawStringWithEmotes()` 文本段渲染改用 `drawMixedString()`
  4. `calculateLineHeights()` 检测 CJK 字符，行高取 max(FONT_HEIGHT_SMALL, CFONT_H)
- 编译通过，flash 仅增 ~1KB（固件 2.57MB < app 分区 2.75MB）

## ⚠️ 开机确认逻辑导致无法启动
- **现象**：`TCA9535: Boot not confirmed, shutting down` → deep sleep → 开不了机
- **根因**：main.cpp 中 Wire.begin() 后的"开机确认窗口"要求用户在 3 秒内持续按住按键 2 秒，否则主动关机
  - 这违背了物理开机设计：按下按键 → MOS 得电 → ESP32 启动 → 用户很快松手
  - 3 秒窗口内几乎不可能持续按住 2 秒
- **修复**：删除开机确认窗口代码，只保留 `tca9535PowerEn(true)` 立即锁住供电
