Files
lora_meshtastic_project/.workbuddy/memory/2026-03-29.md
T
kevin 069630999e feat: add Chinese 12x12 bitmap font (21075 glyphs) and fix boot gate
- Add ChineseFont12x12.h: U+4E00-U+9FFF CJK coverage, 535KB flash

- Add gen_chinese_font.mjs: @napi-rs/canvas based font generator tool

- Enable CJK rendering in MessageRenderer and CannedMessageModule

- Remove boot confirmation gate (required 2s button hold, caused shutdown loop)

- Update partition table: app 2.75MB, OTA 192KB, spiffs 1MB

- Update CHANGELOG
2026-03-29 21:08:34 +08:00

14 KiB
Raw Blame History

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_MAP4×4 矩阵行优先映射)
    • TCA9535_INT_PIN=5GPIO5,低电平有效,下降沿触发)
  • 修改 src/main.cppinclude TCA9535ButtonThread.h,在 setupModules() 后初始化线程
  • I²C 地址:0x20A0=A1=A2=0),与 SH1106 屏幕共用 WireSDA=0, SCL=1
  • 矩阵接法:P0.0P0.3 行输出,P0.4P0.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 = 0xEBP1.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_OFFwritePinEN(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,提交 311232cpush

九宫格键盘映射

  • 添加 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),但 CannedMessageModulekbchar 当 1-based 索引用(currentMessageIndex = event->kbchar - 1)。ASCII '0'=48 → index=47 → 数组越界 → 野指针 → 崩溃
  • 参考 RAK14004 实现:kbI2cBase.cppPrintDataBuf = 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 状态进入 FREETEXTFREETEXT 状态追加字符
      • 如果 kbchar 是 1-based 索引(如 RAK14004):继续走 canned message 选择路径
      • 加边界检查 idx < 0 || idx >= messagesCount 防御性忽略

P1.1 充电检测 (CHARGE_DET)

  • P1.1 = CHARGE_DET 输入,高电平=正在充电
  • P1 config 从 0x8B 改为 0x8DP1.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

快捷回复 ↔ 九宫格输入导航

  • INACTIVEUP/DOWN 进入快捷回复列表
  • ACTIVE(列表):LEFT/RIGHT 进入九宫格 FREETEXT
  • FREETEXTLEFT/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=0x40383d3esystimer 中间件),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() 是一次性的——调用一次就清零 _requestingFocussetFrames(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 字符插入到 displayTextdisplayCursor 没有 +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.mjsNode.js + @napi-rs/canvas
    • 字体源:Windows msyh.ttc(微软雅黑)
    • 字符集:U+4E00-U+74FFCJK 常用汉字)+ 中文标点 + 全角数字/字母 = 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 用 drawStringCJK 用 cfont12
  • 集成点CannedMessageModule.cpp drawFrame FREETEXT 部分
    • text token 渲染:逐字符判断是否 CJKCJK 用 cfont12_drawASCII 用 drawString
    • word wrap 宽度计算:CJK 字符宽度 = CFONT_W(12)ASCII 用 getStringWidth
  • Flash 占用+256 KBdata 段),剩余 ~1.59 MB4MB 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.cppgenerateLines() 逐字节处理 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) 立即锁住供电