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
This commit is contained in:
@@ -8,10 +8,10 @@
|
||||
"profession": "嵌入式固件工程师",
|
||||
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/EmbeddedFirmwareEngineer/EmbeddedFirmwareEngineer.png",
|
||||
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/EmbeddedFirmwareEngineer/EmbeddedFirmwareEngineer_zh.md",
|
||||
"usedAt": 1774720870071,
|
||||
"usedAt": 1774782976490,
|
||||
"industryId": "all"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lastUpdated": 1774745350742
|
||||
"lastUpdated": 1774789490425
|
||||
}
|
||||
@@ -136,3 +136,61 @@
|
||||
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)` 立即锁住供电
|
||||
|
||||
@@ -101,3 +101,14 @@
|
||||
- 排除了大量 RadioLib 不用的协议(AX25/LoRaWAN/APRS 等)以节省 flash
|
||||
- MAX_THREADS=40
|
||||
- 默认 env:tbeam(需要切换到 esp32c3_moonshine 系列时要指定 env)
|
||||
- **Flash 占用**(esp32c3_moonshine_travelers):text≈1.92MB, data≈0.63MB, 总≈2.57MB / 4MB
|
||||
- **分区表**(partition-table.csv):app=0x2C0000(2.75MB), OTA=0x030000(192KB), spiffs=0x100000(1MB)
|
||||
- **中文 12×12 字库**:`src/graphics/fonts/ChineseFont12x12.h`,21075 字形,535KB flash
|
||||
- 生成工具:`tools/gen_chinese_font.mjs`(需 npm install @napi-rs/canvas)
|
||||
- 覆盖:U+4E00-U+9FFF(CJK 统一汉字全集,含 GB2312 6763 字)
|
||||
- 像素判定:透明背景 + 黑色前景,alpha > 128 阈值(去掉抗锯齿毛边,字形锐利)
|
||||
- ⚠️ 阈值 40 太低→字体膨胀变粗;128 合适→清晰锐利
|
||||
- ⚠️ `imageSmoothingEnabled` 对 canvas fillText 无效
|
||||
- 渲染函数:`cfont12_find()` / `cfont12_draw()` / `cfont12_utf8()` / `cfont12_drawStr()`
|
||||
- 集成点:CannedMessageModule drawFrame FREETEXT 部分(混合 ASCII+CJK 渲染)
|
||||
- MessageRenderer(收到消息显示)也已支持:generateLines + drawStringWithEmotes + calculateLineHeights 均改为 CJK 感知
|
||||
|
||||
+19
-6
@@ -36,8 +36,7 @@
|
||||
- `tca9535PowerEn(bool on)` — read-modify-write P1.2,static inline
|
||||
- P1.3 = POWER_BOOT 输入,低电平有效(按键按下接地)
|
||||
- `tca9535ReadPowerBoot()` — 读取 P1.3 状态,static inline
|
||||
- 开机流程:物理按键 → MOS 导通 → ESP32 得电 → main.cpp 立即锁 POWER_EN → 等待 P1.3 持续按住 2 秒确认 → 启动系统
|
||||
- 3 秒内未按满 2 秒 → POWER_EN 拉低 → MOS 断开 → 自动断电
|
||||
- 开机流程:物理按键 → MOS 导通 → ESP32 得电 → `Wire.begin()` 后立即 `tca9535PowerEn(true)` 锁住供电 → 启动系统
|
||||
- 关机流程:运行中 P1.3 持续按住 2 秒 → 清空屏幕 → POWER_EN 拉低 → 用户松手后 MOS 断开断电
|
||||
- 电源状态机:`BOOT_PENDING` → `RUNNING` → `SHUTDOWN_PENDING`
|
||||
- P1 口配置:`0x8B`(P1.2=输出, P1.3=输入, P1.4=输出, P1.5=输出, P1.6=输出, P1.7=输出)
|
||||
@@ -63,10 +62,21 @@
|
||||
|
||||
### Changed
|
||||
|
||||
#### 开机流程改为 early-lock + 确认窗口
|
||||
#### 中文 12×12 点阵字库(esp32c3_moonshine_travelers)
|
||||
- 新增 `src/graphics/fonts/ChineseFont12x12.h`:21075 字形,~535 KB flash
|
||||
- 生成工具:`tools/gen_chinese_font.mjs`(`@napi-rs/canvas` 光栅化 + 位图提取)
|
||||
- Unicode 覆盖:U+4E00–U+9FFF(CJK 统一汉字全集,含 GB2312 6763 字)+ CJK 标点 + 全角 ASCII
|
||||
- 像素判定:透明背景 + 黑色前景,alpha > 80 阈值
|
||||
- `MessageRenderer.cpp`(收到的消息显示)支持 CJK 渲染:
|
||||
- `generateLines()` UTF-8 感知逐字符分词,CJK 字符作为独立 word
|
||||
- `drawStringWithEmotes()` 文本段混合渲染(ASCII 用内置字体,CJK 用 `cfont12_draw`)
|
||||
- `calculateLineHeights()` CJK 感知行高计算
|
||||
- `CannedMessageModule.cpp`(快捷回复 FREETEXT 编辑)支持 CJK 渲染:
|
||||
- FREETEXT 区域用 `cfont12_drawStr()` 混合渲染 ASCII + CJK
|
||||
|
||||
#### 开机流程改为 early-lock
|
||||
- `main.cpp`:`Wire.begin()` 后立即 `tca9535PowerEn(true)` 锁住供电,防止初始化途中掉电
|
||||
- 新增开机确认窗口:等待 P1.3 持续按住 2 秒确认开机,最多等 3 秒,超时则断电关机
|
||||
- `TCA9535ButtonThread::init()` 不再负责开机确认,只设置状态机为 RUNNING
|
||||
- 无需额外确认步骤,用户按下按键即开机
|
||||
|
||||
#### 快捷回复 ↔ 九宫格输入导航(esp32c3_moonshine_travelers)
|
||||
- **INACTIVE**:UP/DOWN 进入快捷回复列表(恢复原始行为)
|
||||
@@ -98,7 +108,10 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **矩阵扫描 cols 整数提升 bug**:`~` 运算符对 `uint8_t` 提升为 `int`,导致 `cols` 高 4 位被污染,key4~key15 永远无法触发
|
||||
- **开机确认窗口导致无法启动**:`Wire.begin()` 后要求 P1.3 持续按住 2 秒确认开机,但物理开机流程中用户按下按键后很快松手,3 秒窗口内无法满足 2 秒持续按住条件,导致每次触发 `Boot not confirmed, shutting down` 并断电
|
||||
- 修复:删除开机确认窗口,只保留 `tca9535PowerEn(true)` 立即锁住供电
|
||||
- **TCA9535 P1 口配置误改导致 MOS 断电**(历史记录):加 CHARGE_DET 后将 P1 config 从正确值改为 `0x8D`,P1.2(POWER_EN) 被误配成输入(高阻),MOS 失控断电
|
||||
- 正确值:`0x0A`(P1.1=输入 CHARGE_DET, P1.3=输入 POWER_BOOT, 其余输出):`~` 运算符对 `uint8_t` 提升为 `int`,导致 `cols` 高 4 位被污染,key4~key15 永远无法触发
|
||||
- 修复:`((~(p0In & 0xF0)) >> 4) & 0x0F` — 显式截断到 4 bit
|
||||
- **LTO 链接错误**:编译时 `-flto` 导致 `undefined reference to TCA9535ButtonThread::*`
|
||||
- 原因:.h/.cpp 中的 `#if defined(HAS_TCA9535_BUTTON)` 守卫导致部分编译单元中符号被丢弃
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x009000, 0x005000,
|
||||
otadata, data, ota, 0x00e000, 0x002000,
|
||||
app, app, ota_0, 0x010000, 0x250000,
|
||||
flashApp, app, ota_1, 0x260000, 0x0A0000,
|
||||
app, app, ota_0, 0x010000, 0x2C0000,
|
||||
flashApp, app, ota_1, 0x2D0000, 0x030000,
|
||||
spiffs, data, spiffs, 0x300000, 0x100000,
|
||||
|
@@ -30,6 +30,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "configuration.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/fonts/ChineseFont12x12.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/emotes.h"
|
||||
#include "main.h"
|
||||
@@ -61,6 +62,46 @@ static size_t cachedKey = 0;
|
||||
static std::vector<std::string> cachedLines;
|
||||
static std::vector<int> cachedHeights;
|
||||
|
||||
// Helper: measure mixed ASCII+CJK string width
|
||||
static int measureMixedWidth(OLEDDisplay *display, const std::string &s)
|
||||
{
|
||||
int w = 0;
|
||||
for (size_t i = 0; i < s.length();) {
|
||||
int utf8len = 1;
|
||||
uint16_t cp = cfont12_utf8(s.c_str() + i, &utf8len);
|
||||
if (cp >= 0x80 && cfont12_find(cp)) {
|
||||
w += CFONT_W;
|
||||
} else {
|
||||
std::string oneChar = s.substr(i, utf8len);
|
||||
w += display->getStringWidth(oneChar.c_str());
|
||||
}
|
||||
i += utf8len;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
// Helper: render a mixed ASCII+CJK string at (x, y), return total width
|
||||
static int drawMixedString(OLEDDisplay *display, int x, int y, const std::string &s, bool inBold)
|
||||
{
|
||||
int cursorX = x;
|
||||
for (size_t i = 0; i < s.length();) {
|
||||
int utf8len = 1;
|
||||
uint16_t cp = cfont12_utf8(s.c_str() + i, &utf8len);
|
||||
if (cp >= 0x80 && cfont12_find(cp)) {
|
||||
cursorX += cfont12_draw(display, cursorX, y, cp);
|
||||
} else {
|
||||
std::string oneChar = s.substr(i, utf8len);
|
||||
if (inBold) {
|
||||
display->drawString(cursorX + 1, y, oneChar.c_str());
|
||||
}
|
||||
display->drawString(cursorX, y, oneChar.c_str());
|
||||
cursorX += display->getStringWidth(oneChar.c_str());
|
||||
}
|
||||
i += utf8len;
|
||||
}
|
||||
return cursorX - x;
|
||||
}
|
||||
|
||||
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount)
|
||||
{
|
||||
int cursorX = x;
|
||||
@@ -132,16 +173,7 @@ void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string
|
||||
|
||||
if (nextControl > i) {
|
||||
std::string textChunk = line.substr(i, nextControl - i);
|
||||
if (inBold) {
|
||||
// Faux bold: draw twice, offset by 1px
|
||||
display->drawString(cursorX + 1, fontY, textChunk.c_str());
|
||||
}
|
||||
display->drawString(cursorX, fontY, textChunk.c_str());
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
cursorX += display->getStringWidth(textChunk.c_str(), textChunk.length(), true);
|
||||
#else
|
||||
cursorX += display->getStringWidth(textChunk.c_str());
|
||||
#endif
|
||||
cursorX += drawMixedString(display, cursorX, fontY, textChunk, inBold);
|
||||
i = nextControl;
|
||||
continue;
|
||||
}
|
||||
@@ -155,16 +187,7 @@ void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string
|
||||
} else {
|
||||
// No more emotes — render the rest of the line
|
||||
std::string remaining = line.substr(i);
|
||||
if (inBold) {
|
||||
display->drawString(cursorX + 1, fontY, remaining.c_str());
|
||||
}
|
||||
display->drawString(cursorX, fontY, remaining.c_str());
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
cursorX += display->getStringWidth(remaining.c_str(), remaining.length(), true);
|
||||
#else
|
||||
cursorX += display->getStringWidth(remaining.c_str());
|
||||
#endif
|
||||
|
||||
cursorX += drawMixedString(display, cursorX, fontY, remaining, inBold);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -433,41 +456,69 @@ std::vector<std::string> generateLines(OLEDDisplay *display, const char *headerS
|
||||
lines.push_back(std::string(headerStr)); // Header line is always first
|
||||
|
||||
std::string line, word;
|
||||
for (int i = 0; messageBuf[i]; ++i) {
|
||||
char ch = messageBuf[i];
|
||||
int i = 0;
|
||||
while (messageBuf[i]) {
|
||||
// Check for smart quote U+2019 (E2 80 99)
|
||||
if ((unsigned char)messageBuf[i] == 0xE2 && (unsigned char)messageBuf[i + 1] == 0x80 &&
|
||||
(unsigned char)messageBuf[i + 2] == 0x99) {
|
||||
ch = '\''; // plain apostrophe
|
||||
i += 2; // skip over the extra UTF-8 bytes
|
||||
}
|
||||
if (ch == '\n') {
|
||||
word += '\'';
|
||||
i += 3;
|
||||
} else if ((unsigned char)messageBuf[i] == '\n') {
|
||||
if (!word.empty())
|
||||
line += word;
|
||||
if (!line.empty())
|
||||
lines.push_back(line);
|
||||
line.clear();
|
||||
word.clear();
|
||||
} else if (ch == ' ') {
|
||||
i++;
|
||||
} else if ((unsigned char)messageBuf[i] == ' ') {
|
||||
line += word + ' ';
|
||||
word.clear();
|
||||
i++;
|
||||
} else {
|
||||
word += ch;
|
||||
std::string test = line + word;
|
||||
// Keep these lines for diagnostics
|
||||
// LOG_INFO("Char: '%c' (0x%02X)", ch, (unsigned char)ch);
|
||||
// LOG_INFO("Current String: %s", test.c_str());
|
||||
// Note: there are boolean comparison uint16 (getStringWidth) with int (textWidth), hope textWidth is always positive :)
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
uint16_t strWidth = display->getStringWidth(test.c_str(), test.length(), true);
|
||||
#else
|
||||
uint16_t strWidth = display->getStringWidth(test.c_str());
|
||||
#endif
|
||||
if (strWidth > textWidth) {
|
||||
if (!line.empty())
|
||||
// Decode UTF-8 character
|
||||
int utf8len = 1;
|
||||
uint16_t cp = cfont12_utf8(messageBuf + i, &utf8len);
|
||||
std::string chStr(messageBuf + i, utf8len);
|
||||
i += utf8len;
|
||||
|
||||
bool isCJK = (cp >= 0x80 && cfont12_find(cp));
|
||||
|
||||
// CJK characters are treated as individual "words" (break before/after them)
|
||||
if (isCJK && !word.empty()) {
|
||||
// Flush current ASCII word first
|
||||
std::string test = line + word;
|
||||
if (measureMixedWidth(display, test) > textWidth && !line.empty()) {
|
||||
lines.push_back(line);
|
||||
line = word;
|
||||
line = word;
|
||||
} else {
|
||||
line = test;
|
||||
}
|
||||
word.clear();
|
||||
}
|
||||
|
||||
word += chStr;
|
||||
|
||||
if (isCJK) {
|
||||
// CJK char is its own word — immediately check if it fits
|
||||
std::string test = line + word;
|
||||
if (measureMixedWidth(display, test) > textWidth && !line.empty()) {
|
||||
lines.push_back(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = test;
|
||||
}
|
||||
word.clear();
|
||||
} else {
|
||||
// ASCII char — accumulate in word, check width
|
||||
std::string test = line + word;
|
||||
if (measureMixedWidth(display, test) > textWidth) {
|
||||
if (!line.empty())
|
||||
lines.push_back(line);
|
||||
line = word;
|
||||
word.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,7 +537,9 @@ std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, con
|
||||
for (const auto &_line : lines) {
|
||||
int lineHeight = FONT_HEIGHT_SMALL;
|
||||
bool hasEmote = false;
|
||||
bool hasCJK = false;
|
||||
|
||||
// Check for emotes
|
||||
for (int i = 0; i < numEmotes; ++i) {
|
||||
const Emote &e = emotes[i];
|
||||
if (_line.find(e.label) != std::string::npos) {
|
||||
@@ -495,8 +548,20 @@ std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, con
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tighter spacing if no emotes on this line
|
||||
if (!hasEmote) {
|
||||
// Check for CJK characters
|
||||
for (size_t ci = 0; ci < _line.length();) {
|
||||
int utf8len = 1;
|
||||
uint16_t cp = cfont12_utf8(_line.c_str() + ci, &utf8len);
|
||||
if (cp >= 0x80 && cfont12_find(cp)) {
|
||||
hasCJK = true;
|
||||
lineHeight = std::max(lineHeight, CFONT_H);
|
||||
break;
|
||||
}
|
||||
ci += utf8len;
|
||||
}
|
||||
|
||||
// Apply tighter spacing if no emotes/CJK on this line
|
||||
if (!hasEmote && !hasCJK) {
|
||||
lineHeight -= 2; // reduce by 2px for tighter spacing
|
||||
if (lineHeight < 8)
|
||||
lineHeight = 8; // minimum safety
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -589,40 +589,6 @@ void setup()
|
||||
// MOS 断电,系统在 setup() 中途就会掉电。
|
||||
tca9535PowerEn(true);
|
||||
LOG_INFO("TCA9535: POWER_EN latched HIGH (early boot)");
|
||||
|
||||
// 开机确认窗口:检测 P1.3 是否持续按住 2 秒,防止意外通电卡死
|
||||
// 最多等待 3 秒,3 秒内未连续按满 2 秒则断电关机
|
||||
{
|
||||
bool bootConfirmed = false;
|
||||
uint32_t pressStart = 0;
|
||||
uint32_t deadline = millis() + 3000;
|
||||
LOG_INFO("TCA9535: Waiting for 2s button hold to confirm boot (timeout 3s)...");
|
||||
|
||||
while (millis() < deadline) {
|
||||
bool pressed = tca9535ReadPowerBoot();
|
||||
if (pressed && pressStart == 0) {
|
||||
pressStart = millis();
|
||||
} else if (pressed && pressStart != 0) {
|
||||
if (millis() - pressStart >= 2000) {
|
||||
bootConfirmed = true;
|
||||
LOG_INFO("TCA9535: Boot confirmed (button held 2s)");
|
||||
break;
|
||||
}
|
||||
} else if (!pressed && pressStart != 0) {
|
||||
// 松手重置计时
|
||||
pressStart = 0;
|
||||
}
|
||||
delay(50); // 50ms 轮询
|
||||
}
|
||||
|
||||
if (!bootConfirmed) {
|
||||
LOG_WARN("TCA9535: Boot not confirmed, shutting down");
|
||||
tca9535PowerEn(false);
|
||||
// 等待 MOS 断开
|
||||
delay(500);
|
||||
doDeepSleep(0, false, false);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
if (portduino_config.i2cdev != "") {
|
||||
|
||||
@@ -54,6 +54,7 @@ const char *const CannedMessageModule::t9LetterMap[][5] = {
|
||||
#endif
|
||||
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/fonts/ChineseFont12x12.h"
|
||||
#include <Throttle.h>
|
||||
|
||||
// Remove Canned message screen if no action is taken for some milliseconds
|
||||
@@ -2061,27 +2062,47 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
|
||||
int spacePos = text.indexOf(' ', pos);
|
||||
int endPos = (spacePos == -1) ? text.length() : spacePos + 1; // Include space
|
||||
String word = text.substring(pos, endPos);
|
||||
int wordWidth = display->getStringWidth(word);
|
||||
|
||||
// Calculate word width: ASCII chars use getStringWidth, CJK use CFONT_W
|
||||
int wordWidth = 0;
|
||||
for (int ci = 0; ci < word.length();) {
|
||||
int utf8len = 1;
|
||||
uint16_t cp = cfont12_utf8(word.c_str() + ci, &utf8len);
|
||||
if (cp >= 0x80 && cfont12_find(cp)) {
|
||||
wordWidth += CFONT_W;
|
||||
} else {
|
||||
String oneChar = word.substring(ci, ci + utf8len);
|
||||
wordWidth += display->getStringWidth(oneChar);
|
||||
}
|
||||
ci += utf8len;
|
||||
}
|
||||
|
||||
if (lineWidth + wordWidth > maxWidth && lineWidth > 0) {
|
||||
lines.push_back(currentLine);
|
||||
currentLine.clear();
|
||||
lineWidth = 0;
|
||||
}
|
||||
// If word itself too big, split by character
|
||||
// If word itself too big, split by character (CJK-aware)
|
||||
if (wordWidth > maxWidth) {
|
||||
uint16_t charPos = 0;
|
||||
int charPos = 0;
|
||||
while (charPos < word.length()) {
|
||||
String oneChar = word.substring(charPos, charPos + 1);
|
||||
int charWidth = display->getStringWidth(oneChar);
|
||||
int utf8len = 1;
|
||||
uint16_t cp = cfont12_utf8(word.c_str() + charPos, &utf8len);
|
||||
int charWidth;
|
||||
if (cp >= 0x80 && cfont12_find(cp)) {
|
||||
charWidth = CFONT_W;
|
||||
} else {
|
||||
String oneChar = word.substring(charPos, charPos + utf8len);
|
||||
charWidth = display->getStringWidth(oneChar);
|
||||
}
|
||||
if (lineWidth + charWidth > maxWidth && lineWidth > 0) {
|
||||
lines.push_back(currentLine);
|
||||
currentLine.clear();
|
||||
lineWidth = 0;
|
||||
}
|
||||
currentLine.push_back({false, oneChar});
|
||||
currentLine.push_back({false, word.substring(charPos, charPos + utf8len)});
|
||||
lineWidth += charWidth;
|
||||
charPos++;
|
||||
charPos += utf8len;
|
||||
}
|
||||
} else {
|
||||
currentLine.push_back({false, word});
|
||||
@@ -2114,8 +2135,23 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
|
||||
nextX += emote->width + 2;
|
||||
}
|
||||
} else {
|
||||
display->drawString(nextX, yLine, token.second);
|
||||
nextX += display->getStringWidth(token.second);
|
||||
// Mixed ASCII + Chinese rendering
|
||||
const char *str = token.second.c_str();
|
||||
const char *p = str;
|
||||
while (*p) {
|
||||
int utf8len = 1;
|
||||
uint16_t cp = cfont12_utf8(p, &utf8len);
|
||||
if (cp >= 0x80 && cfont12_find(cp)) {
|
||||
// CJK character: draw using Chinese bitmap font
|
||||
nextX += cfont12_draw(display, nextX, yLine, cp);
|
||||
} else {
|
||||
// ASCII / non-CJK: render single char via drawString
|
||||
String oneChar = String(p).substring(0, utf8len);
|
||||
display->drawString(nextX, yLine, oneChar);
|
||||
nextX += display->getStringWidth(oneChar);
|
||||
}
|
||||
p += utf8len;
|
||||
}
|
||||
}
|
||||
}
|
||||
yLine += rowHeight;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Chinese 12x12 Bitmap Font Generator for OLED (ESP32-C3 / SH1106)
|
||||
*
|
||||
* Generates C header with XBM-format bitmaps for GB2312 Level 1 (3755 chars)
|
||||
* + common punctuation + fullwidth ASCII.
|
||||
*
|
||||
* Usage: node gen_chinese_font.mjs
|
||||
* Output: src/graphics/fonts/ChineseFont12x12.h
|
||||
*
|
||||
* Font source: Windows built-in TTF (msyh.ttc / simhei.ttf / simsun.ttc)
|
||||
* Rasterizer: @napi-rs/canvas (native, fast)
|
||||
*/
|
||||
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import { existsSync, writeFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ========== Config ==========
|
||||
const W = 12, H = 12;
|
||||
const BPR = Math.ceil(W / 8); // 2 bytes per row
|
||||
const BPG = BPR * H; // 24 bytes per glyph
|
||||
const GLYPH_SIZE = 2 + BPG; // 26 bytes total (uint16 + bitmap)
|
||||
|
||||
// ========== Find Chinese font ==========
|
||||
const FONT_PATHS = [
|
||||
'C:\\Windows\\Fonts\\msyh.ttc',
|
||||
'C:\\Windows\\Fonts\\msyhbd.ttc',
|
||||
'C:\\Windows\\Fonts\\simhei.ttf',
|
||||
'C:\\Windows\\Fonts\\simsun.ttc',
|
||||
'C:\\Windows\\Fonts\\simfang.ttf',
|
||||
'C:\\Windows\\Fonts\\simkai.ttf',
|
||||
];
|
||||
let fontPath = FONT_PATHS.find(p => existsSync(p));
|
||||
if (!fontPath) { console.error('No Chinese TTF found!'); process.exit(1); }
|
||||
console.log(`Font: ${fontPath}`);
|
||||
|
||||
// ========== Build character set ==========
|
||||
function buildCharSet() {
|
||||
const set = new Set();
|
||||
|
||||
// 1. Chinese punctuation (CJK Symbols and Punctuation block)
|
||||
const punct = "\uFF0C\u3002\uFF01\uFF1F\u3001\uFF1A\uFF1B\u201C\u201D\u2018\u2019\uFF08\uFF09\u300A\u300B\u3010\u3011\u00B7\uFF5E\u2014\u2026";
|
||||
for (const c of punct) set.add(c);
|
||||
|
||||
// 2. Fullwidth digits, letters
|
||||
for (let i = 0xFF10; i <= 0xFF19; i++) set.add(String.fromCodePoint(i));
|
||||
for (let i = 0xFF21; i <= 0xFF3A; i++) set.add(String.fromCodePoint(i));
|
||||
for (let i = 0xFF41; i <= 0xFF5A; i++) set.add(String.fromCodePoint(i));
|
||||
|
||||
// 3. GB2312 Level 1: U+4E00..U+5573 region + extras
|
||||
// GB2312 Level 1 = 3755 most common characters
|
||||
// We include the full CJK Unified Ideographs block up to U+9FFF
|
||||
// but only those that exist in GB2312.
|
||||
// For simplicity and maximum coverage, we include U+4E00-U+9FA5
|
||||
// (this is the "CJK Unified Ideographs" block, ~20,902 chars)
|
||||
// BUT we limit to ~6000 to fit in flash budget.
|
||||
//
|
||||
// Practical approach: include U+4E00-U+7FFF (8192 codepoints, ~6800 assigned)
|
||||
// This covers GB2312 Level 1 + Level 2 + many common extensions.
|
||||
// At 26 bytes/glyph = ~177 KB. Our budget is ~1700 KB. Plenty of room.
|
||||
|
||||
// Let's include U+4E00 to U+9FFF (full CJK block) - that's about 20992 codepoints
|
||||
// but many are unassigned. We'll filter to only assigned ones.
|
||||
// Actually, all codepoints in U+4E00-U+9FFF range ARE assigned CJK characters.
|
||||
// 0x9FFF - 0x4E00 = 0x51FF = 20991 codepoints
|
||||
// 20991 × 26 = 545,766 bytes ≈ 533 KB
|
||||
// This is too much. Let's cap at a reasonable number.
|
||||
|
||||
// GB2312 total: 6763 chars. Let's aim for ~7000 chars max.
|
||||
// U+4E00 to U+73FF covers most GB2312 chars:
|
||||
// 0x73FF - 0x4E00 = 0x25FF = 9727 codepoints
|
||||
// 9727 × 26 = 252,902 bytes ≈ 247 KB
|
||||
// Still a lot. Let's be more selective.
|
||||
|
||||
// Optimal: GB2312 Level 1 only (3755 chars) + punctuation + fullwidth
|
||||
// Level 1 chars are in the range U+4E00 to U+5573 (but not contiguous)
|
||||
// The actual GB2312-to-Unicode mapping has the Level 1 chars spread across
|
||||
// U+4E00-U+9FA5. For simplicity, let's just include the densest CJK blocks:
|
||||
// U+4E00-U+6FFF = 8448 codepoints = 220 KB. Good coverage.
|
||||
|
||||
// CJK Unified Ideographs: U+4E00 to U+9FFF
|
||||
// Covers all GB2312 (6763 chars) + CJK Extension A/B common chars
|
||||
// ~20992 codepoints × 26 bytes = ~533 KB flash
|
||||
// Fits within app partition budget (2.75 MB, firmware ~2.65 MB with full font)
|
||||
for (let cp = 0x4E00; cp <= 0x9FFF; cp++) {
|
||||
set.add(String.fromCodePoint(cp));
|
||||
}
|
||||
|
||||
return [...set].sort((a, b) => a.codePointAt(0) - b.codePointAt(0));
|
||||
}
|
||||
|
||||
const chars = buildCharSet();
|
||||
const estKB = (chars.length * GLYPH_SIZE / 1024).toFixed(0);
|
||||
console.log(`Characters: ${chars.length} (est. ${estKB} KB flash)`);
|
||||
|
||||
if (chars.length * GLYPH_SIZE > 1700 * 1024) {
|
||||
console.error('ERROR: exceeds flash budget!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ========== Rasterize ==========
|
||||
function rasterize(ch) {
|
||||
const canvas = createCanvas(W, H);
|
||||
const ctx = canvas.getContext('2d');
|
||||
// Transparent background + black text → alpha is the only signal we need
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = `${H - 1}px "Microsoft YaHei","SimHei","SimSun",sans-serif`;
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(ch, W / 2, 0);
|
||||
|
||||
const { data } = ctx.getImageData(0, 0, W, H);
|
||||
const bytes = new Uint8Array(BPG);
|
||||
for (let r = 0; r < H; r++) {
|
||||
for (let c = 0; c < W; c++) {
|
||||
// Transparent bg + black text: alpha > threshold = glyph pixel
|
||||
// Threshold 80: keep thin strokes (alpha 80-128) while cutting outer fringe (<80)
|
||||
// 40 = too bold (includes fringe), 128 = too thin (loses thin strokes)
|
||||
if (data[(r * W + c) * 4 + 3] > 80) {
|
||||
bytes[r * BPR + (c >> 3)] |= 0x80 >> (c & 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
console.log('Rasterizing...');
|
||||
const bitmaps = [];
|
||||
const t0 = Date.now();
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
bitmaps.push(rasterize(chars[i]));
|
||||
if ((i + 1) % 500 === 0 || i === chars.length - 1) {
|
||||
const pct = ((i + 1) / chars.length * 100).toFixed(0);
|
||||
console.log(` ${pct}% (${i + 1}/${chars.length}) ${((Date.now() - t0) / 1000).toFixed(0)}s`);
|
||||
}
|
||||
}
|
||||
console.log(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||||
|
||||
// ========== Generate C header ==========
|
||||
function genHeader() {
|
||||
const L = [];
|
||||
L.push('/**');
|
||||
L.push(` * Chinese 12x12 Bitmap Font - ${chars.length} glyphs`);
|
||||
L.push(` * Auto-generated ${new Date().toISOString().split('T')[0]}`);
|
||||
L.push(` * Flash: ~${estKB} KB`);
|
||||
L.push(' */');
|
||||
L.push('#pragma once');
|
||||
L.push('#include <Arduino.h>');
|
||||
L.push('#include <stdint.h>');
|
||||
L.push('');
|
||||
L.push('#define CFONT_W 12');
|
||||
L.push('#define CFONT_H 12');
|
||||
L.push('#define CFONT_BPR 2');
|
||||
L.push('#define CFONT_BPG 24');
|
||||
L.push(`#define CFONT_COUNT ${chars.length}`);
|
||||
L.push('');
|
||||
L.push('typedef struct { uint16_t cp; uint8_t bmp[24]; } CFontGlyph;');
|
||||
L.push('');
|
||||
L.push('class OLEDDisplay;');
|
||||
L.push('');
|
||||
L.push('const CFontGlyph cfont12_data[] PROGMEM = {');
|
||||
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const cp = chars[i].codePointAt(0);
|
||||
const hex = Array.from(bitmaps[i]).map(b => '0x' + b.toString(16).padStart(2, '0')).join(',');
|
||||
L.push(` {0x${cp.toString(16).padStart(4,'0')},{${hex}}},`);
|
||||
}
|
||||
|
||||
L.push('};');
|
||||
L.push('');
|
||||
// Binary search lookup
|
||||
L.push('inline const CFontGlyph* cfont12_find(uint16_t cp) {');
|
||||
L.push(' int lo=0, hi=CFONT_COUNT-1;');
|
||||
L.push(' while(lo<=hi){');
|
||||
L.push(' int m=(lo+hi)>>1;');
|
||||
L.push(' uint16_t v=pgm_read_word(&cfont12_data[m].cp);');
|
||||
L.push(' if(v==cp) return &cfont12_data[m];');
|
||||
L.push(' if(v<cp) lo=m+1; else hi=m-1;');
|
||||
L.push(' }');
|
||||
L.push(' return nullptr;');
|
||||
L.push('}');
|
||||
L.push('');
|
||||
// Draw char using setPixel
|
||||
L.push('inline int cfont12_draw(OLEDDisplay*d,int x,int y,uint16_t cp){');
|
||||
L.push(' const CFontGlyph*g=cfont12_find(cp);');
|
||||
L.push(' if(!g)return 0;');
|
||||
L.push(' for(int r=0;r<CFONT_H;r++)for(int c=0;c<CFONT_W;c++){');
|
||||
L.push(' uint8_t b=pgm_read_byte(&g->bmp[r*CFONT_BPR+(c>>3)]);');
|
||||
L.push(' if(b&(0x80>>(c&7)))d->setPixel(x+c,y+r);');
|
||||
L.push(' }');
|
||||
L.push(' return CFONT_W;');
|
||||
L.push('}');
|
||||
L.push('');
|
||||
// UTF-8 decode
|
||||
L.push('inline uint16_t cfont12_utf8(const char*p,int*len){');
|
||||
L.push(' uint8_t b=(uint8_t)*p;');
|
||||
L.push(' if(b<0x80){*len=1;return b;}');
|
||||
L.push(' if((b&0xE0)==0xC0){*len=2;return((b&0x1F)<<6)|(((uint8_t)p[1])&0x3F);}');
|
||||
L.push(' if((b&0xF0)==0xE0){*len=3;return((b&0x0F)<<12)|(((uint8_t)p[1])&0x3F)<<6|((uint8_t)p[2])&0x3F;}');
|
||||
L.push(' *len=1;return 0;');
|
||||
L.push('}');
|
||||
L.push('');
|
||||
// Draw mixed string
|
||||
L.push('inline uint16_t cfont12_drawStr(OLEDDisplay*d,int x,int y,const char*s){');
|
||||
L.push(' int cx=x;int len;');
|
||||
L.push(' while(*s){uint16_t cp=cfont12_utf8(s,&len);');
|
||||
L.push(' if(cp>=0x80&&cfont12_find(cp)){cx+=cfont12_draw(d,cx,y,cp);}');
|
||||
L.push(' s+=len;');
|
||||
L.push(' }');
|
||||
L.push(' return cx-x;');
|
||||
L.push('}');
|
||||
L.push('');
|
||||
L.push('inline uint16_t cfont12_strWidth(const char*s){');
|
||||
L.push(' int w=0;int len;');
|
||||
L.push(' while(*s){uint16_t cp=cfont12_utf8(s,&len);');
|
||||
L.push(' if(cp>=0x80&&cfont12_find(cp))w+=CFONT_W;');
|
||||
L.push(' s+=len;');
|
||||
L.push(' }');
|
||||
L.push(' return w;');
|
||||
L.push('}');
|
||||
L.push('');
|
||||
return L.join('\n');
|
||||
}
|
||||
|
||||
const outPath = join(__dirname, '..', 'src', 'graphics', 'fonts', 'ChineseFont12x12.h');
|
||||
writeFileSync(outPath, genHeader(), 'utf-8');
|
||||
console.log(`\n✅ ${outPath}`);
|
||||
console.log(` ${chars.length} glyphs, ~${estKB} KB flash`);
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "tools",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.97"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user