Files
stc32g128k/work-order/wo-04

196 lines
21 KiB
Plaintext
Raw Normal View History

2026-07-31 13:57:06 +08:00
# 📋 工单 wo-04: 多国语言支持(法/英)与 RF 接收中断化改造
**工单编号**: WO-04
**创建时间**: 2026-07-31
**模块组件**: 菜单/UI (app_menu.c, ui.c) / 数据持久化 (database.c) / 射频驱动 (rf.c) / 定时器 (Timer0)
**优先级**: 中
**状态**: 进行中(阶段 1 已实机验证通过;阶段 2 代码已完成,待编译烧录验证)
---
## 零、阶段 2 开发过程中顺带发现并修复的既有 bug
在设计 RF 中断化的看门狗超时判断(需要用 `ms_tick` 判断是否卡在半成品帧状态太久)时,发现 **`ms_tick` 全局变量从未被递增过**,永远等于 0
* `App/main.c` 声明 `volatile u16 ms_tick = 0;`,全项目除此之外没有任何地方对它做 `++`。
* 用 `git log -S "ms_tick++"` 定位到 commit `c0d1fe7`(App 框架重构):重构前 `Timer1_Isr` 里确实有 `ms_tick++;` 及配套的 1000 归零逻辑,重构时把时钟相关代码抽到 `Clock_IncMS()`(用它自己的私有计数器 `ms_cnt`),误删了 `ms_tick++` 这一行,此后再未补回。
* 影响范围广:`app_menu.c` 呼吸灯与 IPC 绑定帧重发、`app_pair.c` 对码等待动画与 30 秒超时、`app_alarm.c`/`app_sos.c` 灯光闪烁与马达节拍、`app_clock.c` 时间设置光标闪烁、`event.c` 报警红框闪烁,全部依赖 `ms_tick` 计时,此前实际上都是冻结/不生效的。
* **修复**:在 `App/event.c` 的 `Timer1_Isr` 中补回 `ms_tick++;`(0~999 循环,与原逻辑一致)。已完成,不在此工单原定范围内,但顺带修复。
---
## 一、需求概述
本工单包含两项互相独立的改造,按顺序分阶段实施,每阶段单独提交、单独验证:
1. **阶段 1 — 多国语言支持**:菜单与界面文案支持法语(FR)/英语(EN)切换,断电重启后保留上次选择。
2. **阶段 2 — RF 接收中断化**:把现在阻塞轮询的 `EV1527_Decode()` 改造成外部中断驱动 + Timer0 时间戳测量,消除主循环阻塞。
---
## 二、阶段 1: 多国语言支持(法/英)
### 1.1 背景与结论
- 目标语言为法语 + 英语,均为纯 ASCII 字符,现有 `FONT_8x16` 字库已完全覆盖,**不需要新增任何字模资源**。
- 参考了 `100_smart_boat` 项目 `xtell_remote_control/language` 的 X-Macro 字符串表模式,去掉了其中中文字库相关的部分(本工单不涉及中文)。
### 1.2 设计要点(已与用户确认)
| 决策项 | 结论 |
|---|---|
| 语言选择是否持久化 | 需要。断电重启后恢复上次选择 |
| 持久化存储位置 | **不单开新 Flash 扇区**,追加存入 `sensor_list` 所在的同一扇区(该扇区 512 字节仅用了 352 字节,剩余空间足够),复用现有 `Load_Database`/`Save_Database` 读写流程 |
| 菜单位置与顺序 | 语言切换项放在**主菜单第 1 项**,原有 3 项("HORLOGE 时间/APPAIRAGE 对码/LIAISON 绑定")依次后移一位 |
| 交互方式 | **不做二级选择子菜单**。菜单高亮在语言项时,直接按■(确认键)在 FR/EN 间原地切换并立即保存,同时用新语言重绘当前菜单 |
### 1.3 实现内容
1. 新增 `App/language.h` + `App/language.c`:
- `typedef enum { LANG_FR = 0, LANG_EN, LANG_COUNT } Language;`
- `void Lang_Init(Language lang);` / `void Lang_Set(Language lang);` / `Language Lang_Current(void);`
- `const char* Lang_Get(StringID id);`
2. 新增 `App/strings.h`:用 X-Macro (`WRISTBAND_STRING_LIST`)登记 `ui.c` 中全部界面文案、`database.c` 的 `sensor_names_fr[5]`、`app_menu.c` 的 `menu_items[5]`,每条包含 FR/EN 两列译文。
3. `App/ui.c`:所有 `LCD_ShowStringCentered(y, "字面量", ...)` / `LCD_ShowString(...)` 改为经 `Lang_Get(STR_XXX)` 取值。
4. `App/app_menu.c`:
- 主菜单项数从 3 改为 4,`menu_select` 上限从 2 改为 3。
- 序号重排:0-语言切换,1-HORLOGE,2-APPAIRAGE,3-LIAISON。
- `menu_level == 0` 分支中,`menu_select == 0` 时按 CONFIRM_CLICK 直接切换语言 + 保存 + 重绘,不进入新的 `menu_level`。
5. `App/ui.c` 的 `UI_ShowMainMenu()`:新增语言项对应的图标与文案(法语环境显示 "LANGUE",英语环境显示 "LANGUAGE"),原有 3 个图标绘制的 `selected_index` 判断值整体 +1。
6. `App/database.c`:
- `Load_Database()`/`Save_Database()` 扩展,在 `sensor_list` 数组之后追加 1 字节语言标志的读写(同一扇区、同一次擦写周期)。
- 开机时 `Load_Database()` 读到的语言字节传给 `Lang_Init()`。
### 1.4 验收标准
- [ ] 开机默认语言为法语(或 Flash 中已保存的上次选择)。
- [ ] 主菜单第 1 项显示"语言"选项,上下键可以正常移动到该项(不影响其余 3 项的原有功能)。
- [ ] 在语言项上按确认键,菜单文案立即从法语切换为英语(或反之),无需退出重进菜单。
- [ ] 切换语言后,退出菜单进入其余各页面(时钟页、对码流程、报警页、SOS页)文案均正确跟随当前语言显示。
- [ ] 切换语言后断电重启,开机后菜单语言与断电前一致。
- [ ] 现有传感器数据库(已绑定的传感器列表)在语言切换与断电重启后不受影响、不丢失。
---
## 三、阶段 2: RF 接收中断化改造
### 2.1 背景
- 现状:`EV1527_Decode()`([Drivers/rf.c](Drivers/rf.c))为阻塞轮询实现,同步头捕获阶段最坏情况阻塞达 30ms,由后台 `RfMonitorApp_onRun()` 在主循环中无条件调用,可能干扰按键长按计时的准确性(按键计数按"调用次数"而非"真实时间"累加)。
- 确认结论:STC32G12K128(至少本项目 `stc32g.h` 覆盖的寄存器范围)**没有硬件捕获(CAP/PCA)寄存器**,只能用"外部中断 + 自由运行定时器时间戳"的软件方案实现等效功能。
### 2.2 设计要点
| 决策项 | 结论 |
|---|---|
| 边沿检测 | 复用现有 P3.6(`RF_RX_DATA`)端口中断(`interrupt 16`),从"只在休眠时开启"改为"常态双边沿触发常驻开启" |
| 时间戳来源 | 复用现有 Timer0(已用于 `RF_Delay_us`/`GetPulseDuration`),精度 0.5us/tick(24MHz 晶振 12T 模式下),来自晶振本身,精度足够,不需要改 1T 模式 |
| 计时范围 | Timer0 为 16 位,0.5us/tick 约 32.768ms 溢出一次,而同步头低电平上限要求测到 60ms —— 需要加 Timer0 溢出中断,把 16 位硬件计数扩展为软件维护的更宽时间戳,避免溢出截断 |
| 解码逻辑归属 | 沿用现有的同步头判定 + 24 位比值解调算法,只是从"主循环阻塞轮询"搬到"中断内增量式状态机推进";中断中不直接操作事件队列,只写标志位/结果变量,由主循环取用后生成 `SystemEvent` |
### 2.3 顺带修复
- `GetPulseDuration()`([Drivers/rf.c:160-172](Drivers/rf.c#L160-L172))中 `timeout_ticks` 用 `u16` 存储,60ms 超时换算成 tick(120000)超出 `u16` 上限被截断为约 27.2ms,与注释/调用方预期不符。本次改造一并修正(改用能容纳完整量程的类型)。
### 2.4 实现内容
1. Timer0 改为开机后常驻自由运行(不再是 `RF_Delay_us`/`GetPulseDuration` 里那种每次启停复位的一次性用法),新增 Timer0 溢出中断以扩展时间戳位宽。
2. P3.6 端口中断改为常态开启双边沿触发;`Port3_Isr` 内根据系统是否处于休眠切换态,分别处理"仅唤醒"和"正常解码"两种职责。
3. 中断内维护一个小状态机(等待同步头 / 累计第几位 / 暂存 addr 和 data),每次边沿中断推进一步,solved 24 位后把结果写入类似 `p0_wakeup_flag` 模式的标志变量。
4. `RfMonitorApp_onRun()` 及 `PairApp_onRun()` 改为检查该标志变量,而不是直接调用阻塞的 `EV1527_Decode()`。
5. 增加"半成品帧超时看门狗":同步头之后若在预期时间内未凑够 24 位,自动放弃并复位状态机,防止被干扰卡死。
### 2.5 验收标准
- [ ] 主循环轮询 RF 不再有阻塞现象(可用示波器/计时对比主循环单次迭代耗时)。
- [ ] 对码(`PairApp`)、后台报警监听(`RfMonitorApp`)功能行为与改造前一致,能正确捕获/解码射频信号。
- [ ] 按键长按计时(3 秒判定)在有射频信号持续输入时不再受干扰、计时准确。
- [ ] 休眠状态下 P3.6 收到射频信号仍能正常唤醒,唤醒功能不受本次改造影响。
- [ ] 干扰噪声下(无有效信号)状态机不会卡死在"半成品帧"状态。
---
## 四、代码修改记录
| 阶段 | 文件 | 修改内容 | 状态 |
|---|---|---|---|
| 1 | App/language.h / App/language.c | 新建,语言状态管理与取字符串接口(`Lang_Init/Lang_Set/Lang_Current/Lang_Get`) | ✔ 已完成 |
| 1 | App/strings.h | 新建,`WRISTBAND_STRING_LIST` X-Macro 字符串表(法/英) | ✔ 已完成 |
| 1 | App/config.h | 移除旧的 `sensor_names_fr` extern 声明 | ✔ 已完成 |
| 1 | App/database.h / App/database.c | 新增 `sensor_type_name_ids[5]` 字符串 ID 表;`Load_Database`/`Save_Database` 追加读写语言字节(与 `sensor_list` 共用同一扇区,偏移地址 `sizeof(sensor_list)`);`Add_Sensor_With_Zone` 改用 `Lang_Get()` 生成名称 | ✔ 已完成 |
| 1 | App/app_menu.c | 主菜单改 4 项并重排序号(0-语言/1-HORLOGE/2-APPAIRAGE/3-LIAISON);语言项按确认键原地切换 FR/EN 并调用 `Save_Database()` 持久化;`menu_items` 改名并改造为 `device_menu_ids` 字符串 ID 表 | ✔ 已完成 |
| 1 | App/ui.c | 全部界面文案字面量替换为 `Lang_Get(STR_XXX)`;`UI_ShowMainMenu` 新增第 0 项语言图标(直接显示当前语言 "FR"/"EN" 缩写) | ✔ 已完成 |
| 1 | wristband.uvproj | 新增 `App/language.c` 参与 Keil 工程编译 | ✔ 已完成 |
| 2 | Drivers/rf.c | 新增 Timer0 常驻自由运行 + 溢出中断 (`Timer0_Isr`) 扩展为 32 位时间戳;新增 `RF_HandleEdgeInterrupt()` 边沿增量解码状态机;`RF_Delay_us` 改为对运行中 Timer0 的快照式等待 (不再启停复位)`EV1527_Decode()` 改为非阻塞检查中断解码结果 (签名不变);移除死代码 `mock_rf_*` 与旧版 `GetPulseDuration` | ✔ 已完成 |
| 2 | Drivers/rf.h | 移除 `GetPulseDuration` 声明,新增 `RF_HandleEdgeInterrupt` 声明,更新 `EV1527_Decode` 注释 | ✔ 已完成 |
| 2 | App/main.c | `Port3_Isr` 去掉旧版"触发一次即自禁用 P3INTE"的逻辑,改为每次都调用 `RF_HandleEdgeInterrupt()`,身兼休眠唤醒与常态解码两职 | ✔ 已完成 |
| 2 | App/system.c | `Enter_Low_Power_Sleep`P3.6 双边沿触发与中断允许改为常态 (在 `RF_Init` 一次性配置),此处只保留重新武装 `P3WKUE`;新增休眠前禁用 `ET0`。`Wakeup_Restore``P3INTE` 唤醒后保留 P3.6 位 (不再清零),新增唤醒后恢复 `ET0` | ✔ 已完成 |
| 2 | App/app_rf_monitor.c / App/app_pair.c | 无需改动 —— `EV1527_Decode()` 函数签名与调用方式保持不变 | 不涉及 |
---
## 五之一、后续结构调整:新增 Drivers/timer.c 统一存放定时器驱动代码
用户指出 `Timer0_Isr`/`Timer1_Isr` 属于底层驱动代码,不应该分散在 `Drivers/rf.c` 和 `App/event.c` 里,要求集中放到 `Drivers/timer.c`。据此做了以下调整(纯代码搬家,行为不变)
* 新建 `Drivers/timer.h` / `Drivers/timer.c`,收纳:
- `Timer0_Init()` / `Timer0_Isr()` / `Timer0_GetTimestamp()`(原来内联在 `RF_Init()` 里 + `rf.c` 的 `Timer0_Isr`/`RF_GetTimestamp`)
- `Timer1_Init()` / `Timer1_Isr()` / `key_scan_flag` 的定义(原来在 `App/event.c`)
* `Drivers/rf.c``RF_Init()` 改为调用 `Timer0_Init()``RF_Delay_us`/`RF_HandleEdgeInterrupt` 里的 `RF_GetTimestamp()` 改调用 `Timer0_GetTimestamp()`。
* `App/event.c`:删除 `Timer1_Init`/`Timer1_Isr`/`key_scan_flag` 定义与不再需要的 `clock.h` 引入;`Event_KeyScan_Poll` 等其余逻辑不变。
* `App/event.h`:移除 `Timer1_Init` 声明(改由 `Drivers/timer.h` 提供)`key_scan_flag` 的 extern 声明保留(定义位置变了,对外接口不变)。
* `App/main.c`:新增 `#include "../Drivers/timer.h"` 以取得 `Timer1_Init` 声明。
* `wristband.uvproj`:新增 `Drivers/timer.c` 参与编译。
## 五、阶段 2 实现备注
* Timer0 改为开机后 (`RF_Init()`) 启动一次、永不停止的自由运行计数器,配合 `Timer0_Isr` 溢出中断把 16 位硬件计数扩展为 32 位软件时间戳 (`RF_GetTimestamp()`),解决了原 `GetPulseDuration()` 里 `u16 timeout_ticks` 存不下 60ms 对应的 120000 tick、被截断成约 27.2ms 的既有 bug。
* `EV1527_TxFrame()` 用到的 `RF_Delay_us()` 原本会启停复位 Timer0与"常驻自由运行"直接冲突 (会导致发射一次后 RX 时间戳永久停摆),已改为对运行中的 Timer0 做起止快照差值等待,纯只读,不影响其自由运行状态。
* `EV1527_Transmit()` 整个发射过程是 `EA=0` 屏蔽全局中断执行的 (原有设计,未改动),期间 Timer0 硬件仍在计数但溢出中断进不来,`rf_timer_ovf_count` 会短暂漏计;发射结束后的第一条 RX 边沿会因为时间差异常大而被判定超出合法区间、直接丢弃,随后自动恢复正常——这是状态机自愈的正常代价,不需要额外处理。开机阶段 `Self_Test()`/`Load_Database()` 耗时较长同理,也只会导致开机后第一条边沿被丢弃一次。
* 看门狗超时阈值取 200ms (`RF_DECODE_TIMEOUT_MS`),明显大于一帧最坏情况的理论总耗时 (~95ms同步头最长 61.5ms + 24 位数据最长约 33.6ms),避免正常长帧被误杀。
* P3.6 双边沿中断现在身兼两职:休眠期间做唤醒源 (`p3_wakeup_flag`),正常运行期间驱动 `RF_HandleEdgeInterrupt()` 解码;`Enter_Low_Power_Sleep`/`Wakeup_Restore` 中对应调整为"不再清零 P3.6 的中断允许位",避免唤醒后 RX 解码功能失效。
* 尚未实机编译烧录验证,第三节验收标准仍需在真实硬件上逐项确认 (尤其是干扰环境下的解码成功率、休眠唤醒功能是否受影响)。
### 2.7 实机验证发现的真正根因Port3 中断向量重定向缺失 + isr_jump.asm 从未参与编译
在 2.6 节的排查过程中,通过逐步二分法(先屏蔽 `RF_HandleEdgeInterrupt` 函数体、再屏蔽 `P3INTE`)最终定位到**真正的病根**2.6 节"P3.6 悬空"的诊断被推翻,记录如下供后续参考:
* `Drivers/stc32g.h` 里的官方向量表显示P3 端口中断的真实硬件向量号是 **40**(地址 `0x000143``P3INT_VECTOR`),而 [App/main.c](App/main.c) 里 `Port3_Isr` 声明的是 `interrupt 16`,对应的其实是向量 16 号 `INT4_VECTOR`(地址 `0x000083`"外部中断 4",与 P3 端口毫无关系)。
* 向量号 40 超出 Keil C251 `interrupt` 关键字原生支持的 0~31 范围,跟 Port0(37 号)、Port2(39 号)一样,必须靠 [App/isr_jump.asm](App/isr_jump.asm) 里的汇编跳转表把硬件真实向量地址重定向到 C 代码能声明的低位向量——但这个文件里**只有 Port0、Port2 的重定向Port3 这一条从建立以来就一直缺失**。
* 更严重的是,进一步检查发现 **`isr_jump.asm` 这个文件本身从未被加入 `wristband.uvproj` 工程**,也就是说不止 Port3Port0/Port2 的重定向指令这么多年也从未被真正汇编链接进过固件——这是这份代码库里一直存在、此前从未暴露的问题。
* 之所以此前测试(尤其是低功耗休眠唤醒功能)没有暴露这个问题,推测是因为 P0/P2/P3 的中断使能窗口一直很短(仅在睡眠前临时打开、触发一次就自行关闭),硬件的"从掉电模式被中断请求唤醒"这个动作可能不需要真正完成向量跳转就能生效,狭窄的时间窗口侥幸没有触发跳转到未写入指令的地址。本次工单把 P3.6 的中断改为开机后永久常开、且芯片持续真实接收产生大量边沿,第一次让这个跳转被稳定触发,问题才暴露出来。
* **修复**(两处缺一不可)
1. 在 `App/isr_jump.asm` 里补上 Port3 的重定向条目:`CSEG AT 000143H` → `LJMP 000083H`(跳转到 `interrupt 16` 即 `Port3_Isr` 的入口)。
2. 把 `App/isr_jump.asm` 加入 `wristband.uvproj` 工程文件(`FileType=2`,汇编源文件),确保它真正参与编译链接。
* 修复后2.6 节里为排查而加的调试代码(`RF_HandleEdgeInterrupt` 开头的 `return;`、注释掉的 `P3INTE |= 0x40;`)均已撤销,恢复正常逻辑。
### 2.6 实机验证过程记录P3.6 悬空猜想(已被 2.7 节推翻,仅作排查过程存档)
2026-07-31 13:57:06 +08:00
用户实测反馈:改动后开机背光闪一下就灭,按住开机键不放背光反复闪烁。排查确认根因:
* LR690L 芯片在 `SHUT=1`(休眠/发射时也会关断接收,见 `RF_SetMode(0)`/`RF_SetMode(2)`)期间,数据脚 (P3.6) 为高阻输出。原来的 P3.6 配置是纯高阻输入、**没有内部上拉**。
* 开机时射频芯片默认关闭 (`RF_Init()` 里 `SHUT=1`),要到 `AppManager_StartApp(APP_ID_CLOCK)` 里才会调用 `RF_SetMode(1)` 真正打开接收——这段时间 P3.6 处于悬空状态,容易受旁边射频电路干扰而电平乱跳。
* 旧版设计里 P3.6 中断只在临睡前临时开启且触发一次自动关闭,悬空乱跳最多误触发一次,代价很小;本次改造把它变成开机后永久常开、不再自动关闭,悬空噪声在 `EA=1` 之后会引发中断风暴CPU 完全无法执行主循环,导致开机画面卡在初始化那一下就再也画不出时钟界面。
* **修复**:给 P3.6 加内部上拉 (`P3PU |= 0x40;`),在悬空 (芯片输出高阻) 时把电平钳在稳定的高电平,不再乱跳;芯片主动驱动数据时上拉阻值足够弱,不影响正常接收。
- `Drivers/rf.c` 的 `RF_Init()` 中新增。
- `App/system.c` 的 `Wakeup_Restore()` 中新增恢复 (因为 `Enter_Low_Power_Sleep()` 休眠前为了省电特意把这个上拉关掉了,唤醒后要重新打开)。
### 2.8 对照实验:轮询解码 + 中断纯计数,验证毛刺/降噪分析是否成立
背景:中断增量解码状态机(2.2 节设计)接入真机后,`sync_seen` 长期为 0 或解码一直失败;调整"毛刺过滤下限" (`RF_GLITCH_MIN_TICKS`) 从 10us 逐步提高到 100us 的过程中,`ss_dur_max`/`ss_high_max` 等诊断量时而逼近理论值、时而超出合法区间,未能收敛到稳定结论。用户明确要求停止继续盲目调参,并提出一个可以直接证伪/证实"毛刺导致解码状态机误判"这一假设的对照实验:
* 解码逻辑临时改回 commit `d8df5d4` 已验证长期稳定工作的阻塞轮询实现 (`GetPulseDuration()` + `EV1527_Decode()`,算法/阈值原样保留)。
* P3.6 边沿中断 (`RF_HandleEdgeInterrupt()`) 同时保持使能,但只做纯计数 (`dbg_isr_edge_count++`),不参与任何解码判断。
* 轮询解码一旦成功,立即打印本次解码尝试开始前、成功那一刻的中断边沿计数快照及差值 (`[RF-ISR-CHECK] edges_before=.. edges_after=.. delta=..`),与 EV1527 协议一帧的理论边沿数 (同步头 2 个 + 24 位数据每位 2 个 = 50) 比对。
* 若 `delta` 稳定等于 50 (或在合理误差范围内):说明真实硬件产生的边沿是干净、可信的,此前中断状态机解码失败的原因不在"信号本身有毛刺",而在状态机代码逻辑本身;应转向复查状态机实现而非继续调整过滤阈值。
* 若 `delta` 明显偏离 50 (尤其是远大于 50):则支持此前"存在真实高频毛刺/回响,被边沿中断如实捕获,但被轮询的每次循环开销意外滤除"的分析,说明中断驱动方案需要额外做硬件或软件去抖,才能达到轮询版本的可靠性。
改动仅限 `Drivers/rf.c`(`Drivers/rf.h` 同步更新函数注释)
* `GetPulseDuration()`:算法/阈值与原始版本一致,但测量方式改为对 `Drivers/timer.c` 提供的自由运行 `Timer0_GetTimestamp()` 做快照差值,而不是原始版本那样直接停表/清零/重启 `TR0`——因为现在 Timer0 已经是 `RF_Delay_us()`(TX 发射计时) 共用的自由运行时间戳源,若在这里停表清零会导致解码尝试之后的 TX 发射永远等不到时间戳前进而卡死。这是相对原始实现唯一的必要改动,不影响被验证的解码算法本身。
* `EV1527_Decode()`:算法与原始版本一致,新增开始前/成功后的边沿计数快照打印。
* `RF_HandleEdgeInterrupt()`:从三态状态机简化为纯计数器 (`dbg_isr_edge_count++`)。
* `RF_PrintDebugCounters()`:简化为只打印 `isr_edge_count`。
* 本实验阶段之前为诊断中断状态机新增的大量 `dbg_*` 计数器/`RF_CheckDecodeWatchdog`/状态机相关代码已随之整体移除;若后续证实需要回到中断驱动方案,需要从 git 历史重新引入或重新设计,不建议直接依赖本次删除前的调参结果。
状态:代码已完成,待实机烧录,触发一次真实发射并观察 `[RF-ISR-CHECK]` 日志的 `delta` 是否稳定等于 50。
2026-07-31 13:57:06 +08:00
## 六、阶段 1 实现备注
* 语言字节与传感器数据库共用同一 Flash 扇区(偏移地址为 `sizeof(sensor_list)`,当前为 352,扇区剩余空间足够),复用现有 `Load_Database`/`Save_Database`,未新增独立扇区。
* 未做二级选择子菜单:主菜单第 1 项选中"语言"时直接按■原地切换 FR/EN,立即保存并用新语言重绘当前菜单。
* 首次开机(Flash 从未写入过语言字节,读到擦除态 `0xFF`)时 `Lang_Init` 会自动回退为 `LANG_FR`,不会出现非法枚举值。
* 本阶段仅替换界面文案与新增语言状态管理,未改动任何按键状态机、事件队列或射频协议相关代码逻辑。
* 尚未实机编译烧录验证,第四节验收标准仍需在真实硬件上逐项确认。