diff --git a/AC701N.cbp b/AC701N.cbp
index f570e7a..e9bfcdf 100644
--- a/AC701N.cbp
+++ b/AC701N.cbp
@@ -176,6 +176,7 @@
+
@@ -778,10 +779,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Doc/电子开关-加热垫_固件需求规格书.md b/Doc/电子开关-加热垫_固件需求规格书.md
index 2c4caec..258a99a 100644
--- a/Doc/电子开关-加热垫_固件需求规格书.md
+++ b/Doc/电子开关-加热垫_固件需求规格书.md
@@ -7,13 +7,15 @@
| 工程 | `电子开关-加热垫_soc_JieLi-JL7016` |
| 产品码 | ProdtCode=`CBR1`,productCode=`PRCBR1` |
| 协议 | 见宝 SOC 55AA,V1.0.0 |
-| 文档版本 | V1.0.0 |
+| 文档版本 | V1.0.1 |
| 日期 | 2026.09.04 |
| 作者 | cyWu |
| 读者 | WorkBuddy / 固件开发 |
| 参考工程 | `D:\Jianbao\Project\产品\爬宠\温湿度风扇\温湿度风扇_soc_JieLi-JL7016`(框架 / 按键分级 / 联动仲裁 / 定时 / 运行记录) |
| 灯效参考 | `D:\Jianbao\Project\产品\宠物\门夹弹力绳\门夹弹力绳_soc_JieLi-JL7016`(红绿双通道、OTA 红绿交替) |
+> **V1.0.1(2026-09-04):** 增补第 0.1 节编码约束:模块状态收进一个 `s_xxx` 上下文结构体;注释写「为什么」不写排障史;禁止只写不读的诊断变量。P0 过零/继电器代码已按此整理。
+>
> **V1.0.0(2026-09-04):** 首版共同理解。220V 常电继电器加热垫;过零切继电器;短按开关;开红关绿;5s 配对 / 12s OTA;DP0 加热开关;DP1–5 本版忽略;DP6 由 RO 改为 **WO**(连着时服务器下发当前温度);断连 500ms 重连广播 / 500ms 扫描风扇 `JBao-{缸体号}` 温湿度广播;联动只看温度回差;定时看使能/类型/星期/时分(Byte1 模式忽略);运行记录计实际吸合秒。加热垫本体恒温,闭合后不会过热。
本文是 **已拍板的共同理解**,不是草稿。实现时以本文为准;DP 表以 `dp_definition.json` + 本文第 10.2 / 11.1 / 13.1 / 15 章为准。
@@ -34,6 +36,21 @@
10. 编码风格对齐风扇 + 工程师规范:文件小写+下划线,`g_` / `s_` 前缀,枚举 `Pascal_t`,接口 `xxx_init` / `xxx_run` 或 tick / `xxx_get_status`。
11. **现有工程是协议骨架。** 没有 `apps/usr_app`、没有 `board_pin.h`。`app_main.c` 里上电强制 `usr_goto_pair=1` **必须删掉**,改为上电进工作态(已配对走重连广播)。
12. 调试 UART TX = **PC5**(本机无 AHT20,PC5 空闲)。关掉会与 PA1 / PA7 / PA8 / PB1 / PC3 冲突的 demo 外设。
+13. **遵守第 0.1 节编码与注释。** 散落 static、把排障过程复制进每个函数、只写不读的计数,一律视为未完成。
+
+---
+
+## 0.1 编码与注释(禁止再犯)
+
+新产品模块(`apps/usr_app/*`、`apps/usr_periph/*`、`usr_jb_main.c`)必须遵守。从风扇/门夹 **原样拷贝** 的 `key.c` / `led.c` 框架不要借机大改。
+
+1. **一个模块一个上下文。** 两个及以上的模块状态变量必须收进 `XxxCtx_t`,文件内只留 `static XxxCtx_t s_xxx`。ISR 与主循环共享的成员在结构体里标 `volatile`。单个标志可以单独 `static`(如 `app_defer` 的位图)。
+2. **禁止一排裸 static。** 反例:`s_edge_cb`、`s_edge_cnt`、`s_rise_cnt`、`s_fall_cnt`、`s_last_edge_ms`、`s_last_level`、`s_initialized` 并列。正例:`static BspZcCtx_t s_zc`。
+3. **不要只写不读的诊断变量。** 需要看沿是否在跑,用已有 getter + 一条日志(`bsp_zc_edge_count`)。不要 `s_rise_cnt` / `s_fall_cnt` 这种从来没人读的计数。
+4. **文件头三件事:做什么、一两行硬约束、history。** 同一条约束只写一次。禁止把「V1.0.0 空循环漏沿」复制到 `.c` / `.h` / `usr_jb_main.c` / `board_pin.h` / 每个 `@note`。
+5. **函数注释短。** `@brief` 一行;`@param` / `@return` 按需;`@note` 只写本函数独特约束(例如「FPIN ISR,禁止 printf」)。不要复述代码(`s_zc.edge_cnt++` 旁边不要写「累计沿次数」)。
+6. **注释解释为什么,不写排障日记。** 「必须等过零再切继电器」是为什么;「我改了三次 ISR 才好」不要进源码。
+7. **P0 路径不堆死字段。** 没有 getter、没有日志、P2 也没用上的 `zc_ok_cnt` / `last_change_ms` 不要先占着。需要时再加。
---
@@ -622,6 +639,7 @@ apps/usr_app/
| 生成协议再导回 DP6=RO / LEN=128 | 禁止;以本文 + 当前源码为准 |
| `app_main` 上电清配对进绑定 | 必须删 |
| 20ms ISR 写 VM / 切 BLE | 禁止(风扇已踩过栈溢出) |
+| 一排裸 static / 排障史注释 | 禁止。≥2 个状态收进一个 `s_xxx`;单标志可单独 static。详见第 0.1 节 |
| 协议 OTA stub 返回 OK 当已升级 | 注释标明未升级 |
| 扫描时改 GATT 名 | 不要改 0x2A00;只解析对方 adv |
| 门夹 USB 插入直通开机 / 电池逻辑 | 禁止移植 |
@@ -638,7 +656,7 @@ apps/usr_app/
5. P1:VM 恢复加热、DP1–5 忽略、定时只存。
6. P2:`app_th_scan`、`app_linkage`、`app_timer`、`app_runrec`、仲裁用例。
-每一步都保持对应 `BOARD_xxx_ENABLE` 可关。
+每一步都保持对应 `BOARD_xxx_ENABLE` 可关。提交前对照第 0.1 节:一个模块一个 `s_xxx`,注释写为什么,不要排障日记。
---
diff --git a/Makefile b/Makefile
index 3b8cbd5..51d5545 100644
--- a/Makefile
+++ b/Makefile
@@ -262,6 +262,7 @@ INCLUDES := \
-Iapps/usr_le_code/thirdpart/mjson \
-Iapps/usr_jb_proto/Protocol \
-Iapps/usr_jb_proto/Utils \
+ -Iapps/usr_app \
-Iapps/usr_periph \
-I$(SYS_INC_DIR) \
@@ -397,7 +398,25 @@ c_SRC_FILES := \
apps/usr_jb_proto/Utils/jb_common.c \
apps/usr_jb_proto/Utils/jb_ringbuffer.c \
apps/usr_jb_proto/usr_jb_main.c \
+ apps/usr_periph/bsp_hw.c \
+ apps/usr_periph/bsp_zc.c \
apps/usr_periph/usr_rtc.c \
+ apps/usr_app/app_arbiter.c \
+ apps/usr_app/app_defer.c \
+ apps/usr_app/app_function.c \
+ apps/usr_app/app_led.c \
+ apps/usr_app/app_linkage.c \
+ apps/usr_app/app_nvm.c \
+ apps/usr_app/app_pair.c \
+ apps/usr_app/app_relay.c \
+ apps/usr_app/app_runrec.c \
+ apps/usr_app/app_th_scan.c \
+ apps/usr_app/app_timer_task.c \
+ apps/usr_app/app_zc.c \
+ apps/usr_app/key.c \
+ apps/usr_app/key_manager.c \
+ apps/usr_app/led.c \
+ apps/usr_app/led_manager.c \
apps/usr_le_code/ble_proto.c \
apps/usr_le_code/bleproto.c \
apps/usr_le_code/bleproto_packer.c \
diff --git a/apps/earphone/app_main.c b/apps/earphone/app_main.c
index 4aca9ac..25f13da 100644
--- a/apps/earphone/app_main.c
+++ b/apps/earphone/app_main.c
@@ -326,15 +326,8 @@ void app_main()
usr_var.usr_power_charge_flag=2;
}
- /*
- * 上电强制进入配对模式:
- * 1) usr_goto_pair=1:后续 usr_ble_adv_init() 走绑定广播(encode4bind),不走重连广播
- * 2) usr_clear_pair_info_noreset():清 VM 中三元组,保证 paired_flag=0
- * 注意:须在 BLE 协议栈调用 usr_ble_adv_init() 之前设置;此处不清复位,避免上电死循环复位
- */
- usr_var.usr_goto_pair = 1;
- usr_clear_pair_info_noreset();
-
+ /* 配对只由运行态长按 5s(key_manager 分级)置 usr_goto_pair;禁止上电清三元组
+ * (规格书 5.1/5.2/14 章:220V 常电上电即开机,已配对走重连广播) */
ui_manage_init();
ui_update_status(STATUS_POWERON);
diff --git a/apps/usr_app/app_arbiter.c b/apps/usr_app/app_arbiter.c
new file mode 100644
index 0000000..e7ec907
--- /dev/null
+++ b/apps/usr_app/app_arbiter.c
@@ -0,0 +1,113 @@
+/******************************************************************************
+ * @file app_arbiter.c
+ * @brief 加热占用仲裁:手动 > 定时 > 联动
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ * @history
+ * - V1.1.0, 2026.09.04, cyWu, 精简注释(单标志不包结构体)
+ * - V1.0.0, 2026.09.04, cyWu, 由风扇仲裁器改为继电器开关
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "app_arbiter.h"
+#include "app_linkage.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[ARB]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+static uint8_t s_owner;
+
+/**
+ * @brief 初始化仲裁器
+ * @return 无
+ */
+void app_arbiter_init(void)
+{
+ s_owner = ARB_OWNER_NONE;
+ log_info("arbiter init\n");
+}
+
+/**
+ * @brief 当前加热占用者
+ * @return ArbOwner_t
+ */
+uint8_t app_arbiter_get_owner(void)
+{
+ return s_owner;
+}
+
+/**
+ * @brief 短按 / DP0:清占用并作废本轮联动
+ * @return 无
+ */
+void app_arbiter_manual_intervene(void)
+{
+ if (s_owner != ARB_OWNER_NONE) {
+ log_info("manual intervene, owner %d -> none\n", s_owner);
+ }
+ s_owner = ARB_OWNER_NONE;
+ app_linkage_void();
+}
+
+/**
+ * @brief 联动引擎尝试占用
+ * @return 1=成功;0=定时占用中(12 章:定时窗内联动不插嘴)
+ */
+uint8_t app_arbiter_linkage_take(void)
+{
+ if (s_owner == ARB_OWNER_TIMER) {
+ return 0;
+ }
+ if (s_owner != ARB_OWNER_LINKAGE) {
+ log_info("linkage take\n");
+ }
+ s_owner = ARB_OWNER_LINKAGE;
+ return 1;
+}
+
+/**
+ * @brief 联动是否仍在占用
+ * @return 1=owner==LINKAGE
+ */
+uint8_t app_arbiter_linkage_active(void)
+{
+ return (s_owner == ARB_OWNER_LINKAGE) ? 1 : 0;
+}
+
+/**
+ * @brief 联动自然失效:让出占用(请求关由调用方发)
+ * @return 无
+ */
+void app_arbiter_linkage_release(void)
+{
+ if (s_owner == ARB_OWNER_LINKAGE) {
+ s_owner = ARB_OWNER_NONE;
+ }
+}
+
+/**
+ * @brief 定时窗开始占用(优先级高于联动)
+ * @return 1=成功
+ */
+uint8_t app_arbiter_timer_take(void)
+{
+ if (s_owner != ARB_OWNER_TIMER) {
+ log_info("timer take\n");
+ }
+ s_owner = ARB_OWNER_TIMER;
+ return 1;
+}
+
+/**
+ * @brief 定时窗结束释放(调用方负责收口:请求关 + 立刻按 10.3 补判联动)
+ * @return 无
+ */
+void app_arbiter_timer_release(void)
+{
+ if (s_owner == ARB_OWNER_TIMER) {
+ s_owner = ARB_OWNER_NONE;
+ }
+}
diff --git a/apps/usr_app/app_arbiter.h b/apps/usr_app/app_arbiter.h
new file mode 100644
index 0000000..ee5ab43
--- /dev/null
+++ b/apps/usr_app/app_arbiter.h
@@ -0,0 +1,68 @@
+/******************************************************************************
+ * @file app_arbiter.h
+ * @brief 加热占用仲裁:手动 > 定时 > 联动(规格书第 12 章)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ ******************************************************************************/
+
+#ifndef __APP_ARBITER_H__
+#define __APP_ARBITER_H__
+
+#include
+
+typedef enum {
+ ARB_OWNER_NONE = 0, /**< 空闲 / 手动接管 */
+ ARB_OWNER_LINKAGE = 1, /**< 联动占用 */
+ ARB_OWNER_TIMER = 2 /**< 定时占用 */
+} ArbOwner_t;
+
+/**
+ * @brief 初始化仲裁器
+ * @return 无
+ */
+void app_arbiter_init(void);
+
+/**
+ * @brief 当前加热占用者
+ * @return ArbOwner_t
+ */
+uint8_t app_arbiter_get_owner(void);
+
+/**
+ * @brief 短按 / DP0:清占用并作废本轮联动
+ * @return 无
+ */
+void app_arbiter_manual_intervene(void);
+
+/**
+ * @brief 联动尝试占用
+ * @return 1=成功;0=定时占用中
+ */
+uint8_t app_arbiter_linkage_take(void);
+
+/**
+ * @brief 联动是否仍在占用
+ * @return 1=是
+ */
+uint8_t app_arbiter_linkage_active(void);
+
+/**
+ * @brief 联动自然失效,让出占用
+ * @return 无
+ */
+void app_arbiter_linkage_release(void);
+
+/**
+ * @brief 定时窗开始占用
+ * @return 1=成功
+ */
+uint8_t app_arbiter_timer_take(void);
+
+/**
+ * @brief 定时窗结束释放
+ * @return 无
+ */
+void app_arbiter_timer_release(void);
+
+#endif /* __APP_ARBITER_H__ */
diff --git a/apps/usr_app/app_defer.c b/apps/usr_app/app_defer.c
new file mode 100644
index 0000000..fd04b09
--- /dev/null
+++ b/apps/usr_app/app_defer.c
@@ -0,0 +1,57 @@
+/******************************************************************************
+ * @file app_defer.c
+ * @brief ISR→任务上下文的延后执行标志位实现
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.08.31
+ * @version V1.0.0,首次发布
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "app_defer.h"
+
+/** 位掩码,volatile:request() 在中断上下文写,take()/is_pending()/cancel()
+ * 在任务上下文读写,两侧都用 local_irq_disable/enable 包一下读改写,
+ * 避免"任务侧正在清 A 位时中断侧设 B 位"这种读改写竞态把 B 位冲丢。 */
+static volatile uint8_t s_flags;
+
+void app_defer_request(AppDeferBit_t bit)
+{
+ if (bit >= APP_DEFER_MAX) {
+ return;
+ }
+ local_irq_disable();
+ s_flags |= (uint8_t)(1u << bit);
+ local_irq_enable();
+}
+
+uint8_t app_defer_take(AppDeferBit_t bit)
+{
+ uint8_t was_set;
+
+ if (bit >= APP_DEFER_MAX) {
+ return 0;
+ }
+ local_irq_disable();
+ was_set = (s_flags & (uint8_t)(1u << bit)) ? 1 : 0;
+ s_flags &= (uint8_t)~(1u << bit);
+ local_irq_enable();
+ return was_set;
+}
+
+uint8_t app_defer_is_pending(AppDeferBit_t bit)
+{
+ if (bit >= APP_DEFER_MAX) {
+ return 0;
+ }
+ return (s_flags & (uint8_t)(1u << bit)) ? 1 : 0;
+}
+
+void app_defer_cancel(AppDeferBit_t bit)
+{
+ if (bit >= APP_DEFER_MAX) {
+ return;
+ }
+ local_irq_disable();
+ s_flags &= (uint8_t)~(1u << bit);
+ local_irq_enable();
+}
diff --git a/apps/usr_app/app_defer.h b/apps/usr_app/app_defer.h
new file mode 100644
index 0000000..8094035
--- /dev/null
+++ b/apps/usr_app/app_defer.h
@@ -0,0 +1,59 @@
+/******************************************************************************
+ * @file app_defer.h
+ * @brief ISR→任务上下文的延后执行标志位(统一收口 2026-08-31 复位事故教训)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.08.31
+ * @version V1.0.0,首次发布
+ *
+ * @note ⚠ 硬约束:flash 擦写(syscfg_write)、BLE 广播切换、关机收口等"重活"
+ * 禁止在 usr_timer_add 的中断上下文回调(本产品 20ms usr_timer_tick /
+ * 按键长按回调)里直接执行,只能在 usr_timer_loop(BLE 线程,任务
+ * 上下文,栈充足)里执行。之前每个模块各自发明一个 pending 标志
+ * (off_pending/pair_pending/ota_pending/nvm 的 s_need_save),
+ * 容易被后来的功能遗漏这条红线——本模块把"打标志"这一步收成统一
+ * 接口:ISR 只准调 app_defer_request(),真正执行永远在
+ * app_function_loop() 里用 app_defer_take() 消费。
+ ******************************************************************************/
+
+#ifndef __APP_DEFER_H__
+#define __APP_DEFER_H__
+
+#include
+
+/** 延后动作位,新增一种"需要写flash/切广播/关机"的动作就加一项 */
+typedef enum {
+ APP_DEFER_NVM_SAVE = 0, /**< VM 防抖到点,需要落盘 */
+ APP_DEFER_PAIR, /**< 长按 5s 松手,需要清绑定进配对 */
+ APP_DEFER_OTA, /**< 长按 12s 到点,需要进 OTA */
+ APP_DEFER_MAX
+} AppDeferBit_t;
+
+/**
+ * @brief 中断上下文调用:只置位,不做任何实际动作
+ * @param bit 要延后的动作
+ * @return 无
+ */
+void app_defer_request(AppDeferBit_t bit);
+
+/**
+ * @brief 任务上下文调用:查询并清除标志(取出即消费,最多处理一次)
+ * @param bit 要消费的动作
+ * @return 1=之前挂起过(现在可以执行真正动作),0=没挂起
+ */
+uint8_t app_defer_take(AppDeferBit_t bit);
+
+/**
+ * @brief 任务上下文调用:只查询是否挂起,不清除(多个动作互斥判断用)
+ * @param bit 要查询的动作
+ * @return 1=挂起中,0=未挂起
+ */
+uint8_t app_defer_is_pending(AppDeferBit_t bit);
+
+/**
+ * @brief 任务上下文调用:撤销挂起(如 OTA 触发时要撤销配对)
+ * @param bit 要撤销的动作
+ * @return 无
+ */
+void app_defer_cancel(AppDeferBit_t bit);
+
+#endif /* __APP_DEFER_H__ */
diff --git a/apps/usr_app/app_function.c b/apps/usr_app/app_function.c
new file mode 100644
index 0000000..1e74b25
--- /dev/null
+++ b/apps/usr_app/app_function.c
@@ -0,0 +1,160 @@
+/******************************************************************************
+ * @file app_function.c
+ * @brief 功能总装:短按切加热、任务上下文落地配对/OTA
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ * @history
+ * - V1.1.0, 2026.09.04, cyWu, 精简注释
+ * - V1.0.0, 2026.09.04, cyWu, 首次发布
+ ******************************************************************************/
+
+#include "system/includes.h" /* 必须先于 app_main.h:u8/u16/u32 等类型由 SDK 定义 */
+#include "app_function.h"
+#include "app_pair.h"
+#include "app_relay.h"
+#include "app_zc.h"
+#include "app_arbiter.h"
+#include "app_nvm.h"
+#include "app_linkage.h"
+#include "app_timer_task.h"
+#include "app_runrec.h"
+#include "app_defer.h"
+#include "app_led.h"
+#include "app_main.h"
+#include "usr_le_api.h"
+#include "jb_product.h"
+#include "board_pin.h"
+#include
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[APP_FUNC]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+/**
+ * @brief 初始化功能模块
+ * @return 无
+ */
+void app_function_init(void)
+{
+}
+
+/**
+ * @brief 任务上下文周期处理(配对 / OTA / 写 VM 只准在此落地)
+ * @return 无
+ */
+void app_function_loop(void)
+{
+ uint8_t zc_evt = app_zc_consume_timeout();
+
+ if (zc_evt == 1) {
+ log_info("zc timeout 100ms: open request dropped, stay off, fault=2\n");
+ } else if (zc_evt == 2) {
+ log_info("zc timeout 100ms: force relay off, fault=2\n");
+ }
+
+ app_nvm_flush();
+
+ if (app_defer_take(APP_DEFER_OTA)) {
+ app_defer_cancel(APP_DEFER_PAIR);
+ usr_app_ota_init();
+ app_led_request_ota();
+ log_info("key hold 12s -> ota\n");
+ } else if (app_defer_take(APP_DEFER_PAIR)) {
+ usr_goto_pair_mode();
+ app_pair_start();
+ app_led_clear_pair_hint();
+ log_info("key hold 5s -> pair\n");
+ }
+}
+
+/**
+ * @brief 同步 DP0 = 继电器实际态
+ * @return 无
+ */
+void app_function_poll(void)
+{
+ gDevData.heat_switch = app_relay_get_actual();
+}
+
+/**
+ * @brief 短按切换加热请求(OTA 中忽略)
+ * @return 无
+ */
+void usr_on_key_short(void)
+{
+ uint8_t req;
+
+ if (app_led_is_ota()) {
+ return;
+ }
+
+ app_arbiter_manual_intervene();
+ req = app_relay_get_request() ? 0 : 1;
+ app_relay_request(req);
+ log_info("key short heat request=%d\n", req);
+}
+
+/**
+ * @brief 按住满 5s:配对提示灯(ISR 只动 RAM)
+ * @return 无
+ */
+void usr_on_key_pair_hint(void)
+{
+ if (app_led_is_ota()) {
+ return;
+ }
+ app_led_hint_pair();
+ log_info("key hold 5s, pair hint (release to pair, keep to 12s for ota)\n");
+}
+
+/**
+ * @brief 按满 5s 后松手:置配对标志(写 VM 在 loop)
+ * @return 无
+ */
+void usr_on_key_pair_start(void)
+{
+ if (app_led_is_ota()) {
+ return;
+ }
+ app_defer_request(APP_DEFER_PAIR);
+ log_info("key hold 5s released, pair pending\n");
+}
+
+/**
+ * @brief 按住满 12s:置 OTA 标志(BLE 切换在 loop)
+ * @return 无
+ */
+void usr_on_key_ota_start(void)
+{
+ if (app_led_is_ota()) {
+ return;
+ }
+ app_defer_request(APP_DEFER_OTA);
+ log_info("key hold 12s, ota pending\n");
+}
+
+/**
+ * @brief 出厂重置:清业务 VM/RAM,保留 BLE 配对
+ * @return 无
+ */
+void app_function_factory_reset(void)
+{
+ app_nvm_reset();
+ app_relay_request(0);
+ app_arbiter_init();
+ app_linkage_init();
+ app_timer_init();
+ app_runrec_reset();
+ gDevData.heat_switch = 0;
+ gDevData.linkage_delete = 0;
+ gDevData.time_en = 0;
+ gDevData.fault_en = 0;
+ gDevData.alarm_en = 0;
+ gDevData.fault_code = 0;
+ gDevData.alarm_code = 0;
+ memset(gDevData.linkage_config, 0, BOARD_LINKAGE_CFG_LEN);
+ memset(gDevData.run_record, 0, sizeof(gDevData.run_record));
+ log_info("factory reset: business vm cleared, pairing kept\n");
+}
diff --git a/apps/usr_app/app_function.h b/apps/usr_app/app_function.h
new file mode 100644
index 0000000..9ec38c1
--- /dev/null
+++ b/apps/usr_app/app_function.h
@@ -0,0 +1,67 @@
+/******************************************************************************
+ * @file app_function.h
+ * @brief 功能总装:按键动作 + DP 同步(配对/OTA 只在任务上下文落地)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ ******************************************************************************/
+
+#ifndef __APP_FUNCTION_H__
+#define __APP_FUNCTION_H__
+
+#include
+
+/**
+ * @brief 初始化功能模块状态
+ * @return 无
+ */
+void app_function_init(void);
+
+/**
+ * @brief 同步 DP(DP0=继电器实际态、DP20=fault_code),每个 20ms 业务 tick 调用
+ * @note DP0 上报**实际吸合状态**(过零完成之后),让 APP 看见过零完成
+ * @return 无
+ */
+void app_function_poll(void);
+
+/**
+ * @brief 任务上下文周期处理(usr_timer_loop 500ms)
+ * @note 写 VM / 配对 / OTA 只准在此落地,禁止放进 20ms ISR
+ * @return 无
+ */
+void app_function_loop(void);
+
+/**
+ * @brief 业务层短按回调(key_manager 触发):切换加热请求(开↔关,手动抢权)
+ * @return 无
+ */
+void usr_on_key_short(void);
+
+/**
+ * @brief 业务层"按住满 5s"回调(key_manager 触发,20ms 中断上下文)
+ * @note 只切提示灯(绿灯快闪),不清绑定
+ * @return 无
+ */
+void usr_on_key_pair_hint(void);
+
+/**
+ * @brief 业务层"按满 5s 后松手"回调(key_manager 触发,20ms 中断上下文)
+ * @note 只置标志:清绑定要写 VM,由 app_function_loop 在任务上下文执行
+ * @return 无
+ */
+void usr_on_key_pair_start(void);
+
+/**
+ * @brief 业务层"按住满 12s"回调(key_manager 触发,20ms 中断上下文)
+ * @note 只置标志:usr_app_ota_init 由 app_function_loop 在任务上下文执行
+ * @return 无
+ */
+void usr_on_key_ota_start(void);
+
+/**
+ * @brief 出厂重置(DP23 写 1):清业务 VM 与 RAM,保留 BLE 配对(规格书 14 章)
+ * @return 无
+ */
+void app_function_factory_reset(void);
+
+#endif /* __APP_FUNCTION_H__ */
diff --git a/apps/usr_app/app_led.c b/apps/usr_app/app_led.c
new file mode 100644
index 0000000..b90161d
--- /dev/null
+++ b/apps/usr_app/app_led.c
@@ -0,0 +1,190 @@
+/******************************************************************************
+ * @file app_led.c
+ * @brief 灯效:跟继电器实际态;OTA / 配对优先(规格书第 6 章)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ * @history
+ * - V1.1.0, 2026.09.04, cyWu, 精简注释
+ * - V1.0.0, 2026.09.04, cyWu, 首次发布
+ ******************************************************************************/
+
+#include "app_led.h"
+#include "led_manager.h"
+#include "bsp_hw.h"
+#include "board_pin.h"
+#include "app_pair.h"
+#include "app_relay.h"
+#include "system/includes.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[APP_LED]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+typedef struct {
+ uint8_t inited; /**< 1=已初始化 */
+ uint8_t ota; /**< 1=OTA 灯效中,app_led_run 只跑交替 */
+ uint8_t pair_hint; /**< 1=长按已满 5s 但还没松手提交配对,维持配对快闪 */
+} AppLedCtx_t;
+
+static AppLedCtx_t s_led;
+
+/**
+ * @brief 重新播放一次「一次性/限时」灯效,让它从第 0 帧重新开始
+ * @note 先 Delete 清空槽位再 Add,强制重新计时(见 led.c LED_EventAdd 语义)
+ * @param evt 要重启的事件
+ * @return 无
+ */
+static void led_restart(LED_Event_t evt)
+{
+ LED_EventDelete(evt);
+ LED_EventAdd(evt);
+}
+
+/**
+ * @brief 灯效初始化
+ * @return APP_LED_OK=成功
+ */
+AppLed_Ret_t app_led_init(void)
+{
+ if (s_led.inited) {
+ return APP_LED_OK;
+ }
+#if BOARD_LED_ENABLE
+ user_led_init();
+ s_led.ota = 0;
+ s_led.pair_hint = 0;
+ s_led.inited = 1;
+#else
+ log_info("led disabled\n");
+#endif
+ return APP_LED_OK;
+}
+
+/**
+ * @brief 查询模块是否已初始化
+ * @return APP_LED_OK=已初始化,APP_LED_ERR_NOT_INIT=未初始化
+ */
+AppLed_Ret_t app_led_get_status(void)
+{
+ return s_led.inited ? APP_LED_OK : APP_LED_ERR_NOT_INIT;
+}
+
+/**
+ * @brief 长按满 5s:绿灯 100ms 快闪提示(红灯灭)
+ * @return 无
+ */
+void app_led_hint_pair(void)
+{
+ if (!s_led.inited || s_led.ota) {
+ return;
+ }
+ s_led.pair_hint = 1;
+ LED_EventAllDelete(); /* 清掉跟继电器灯,避免红绿混色 */
+ bsp_led_all_off();
+ LED_EventAdd(LED_EVENT_PAIRING);
+}
+
+/**
+ * @brief 结束"将配网"提示,交回 app_led_run 按 app_pair 状态接管
+ * @return 无
+ */
+void app_led_clear_pair_hint(void)
+{
+ s_led.pair_hint = 0;
+}
+
+/**
+ * @brief 进入 OTA:清掉其它灯,挂红绿交替一直闪
+ * @return 无
+ */
+void app_led_request_ota(void)
+{
+ if (!s_led.inited) {
+ return;
+ }
+ s_led.ota = 1;
+ s_led.pair_hint = 0; /* 已改判 OTA,撤销配网提示 */
+ LED_EventAllDelete();
+ bsp_led_all_off();
+ LED_EventAdd(LED_EVENT_OTA);
+}
+
+/**
+ * @brief 查询是否处于 OTA 灯效
+ * @return 1=OTA 灯效中,0=否
+ */
+uint8_t app_led_is_ota(void)
+{
+ return s_led.ota;
+}
+
+/**
+ * @brief 按优先级刷新灯(20ms 业务节拍)
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void app_led_run(uint16_t dt)
+{
+ AppPairEvt_t pair_evt;
+
+ if (!s_led.inited) {
+ return;
+ }
+
+#if BOARD_LED_ENABLE
+ /* OTA 期间只跑红绿交替 */
+ if (s_led.ota) {
+ LED_EventAdd(LED_EVENT_OTA);
+ user_led_handle();
+ return;
+ }
+
+ /* 配对成功/失败是边沿事件,只在那一拍切灯效(先撤跟继电器灯避免混色) */
+ pair_evt = app_pair_tick_1ms(dt);
+ if (pair_evt == APP_PAIR_EVT_OK) {
+ LED_EventDelete(LED_EVENT_HEAT_ON);
+ LED_EventDelete(LED_EVENT_HEAT_OFF);
+ LED_EventDelete(LED_EVENT_PAIRING);
+ led_restart(LED_EVENT_PAIR_OK);
+ log_info("led pair ok 3s\n");
+ } else if (pair_evt == APP_PAIR_EVT_FAIL) {
+ LED_EventDelete(LED_EVENT_HEAT_ON);
+ LED_EventDelete(LED_EVENT_HEAT_OFF);
+ LED_EventDelete(LED_EVENT_PAIRING);
+ led_restart(LED_EVENT_PAIR_FAIL);
+ log_info("led pair fail 3s\n");
+ }
+
+ /* 配对提示 / 配对中:维持绿灯快闪(add 幂等,不重置计时) */
+ if (s_led.pair_hint || app_pair_is_active()) {
+ if (!LED_IsEventExist(LED_EVENT_PAIR_OK) &&
+ !LED_IsEventExist(LED_EVENT_PAIR_FAIL)) {
+ LED_EventAdd(LED_EVENT_PAIRING);
+ }
+ } else if (!LED_IsEventExist(LED_EVENT_PAIR_OK) &&
+ !LED_IsEventExist(LED_EVENT_PAIR_FAIL)) {
+ /* 无高事件:灯跟继电器实际态(过零完成之后) */
+ if (app_relay_get_actual()) {
+ LED_EventAdd(LED_EVENT_HEAT_ON);
+ } else {
+ LED_EventAdd(LED_EVENT_HEAT_OFF);
+ }
+ }
+
+ user_led_handle();
+#endif
+}
+
+/**
+ * @brief 清空全部灯事件并灭灯
+ * @return 无
+ */
+void app_led_clear(void)
+{
+#if BOARD_LED_ENABLE
+ LED_EventAllDelete();
+ bsp_led_all_off();
+#endif
+}
diff --git a/apps/usr_app/app_led.h b/apps/usr_app/app_led.h
new file mode 100644
index 0000000..2ba1702
--- /dev/null
+++ b/apps/usr_app/app_led.h
@@ -0,0 +1,71 @@
+/******************************************************************************
+ * @file app_led.h
+ * @brief 灯效:跟继电器实际态;OTA / 配对优先(规格书第 6 章)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ ******************************************************************************/
+
+#ifndef __APP_LED_H__
+#define __APP_LED_H__
+
+#include
+
+typedef enum {
+ APP_LED_OK = 0,
+ APP_LED_ERR_NOT_INIT
+} AppLed_Ret_t;
+
+/**
+ * @brief 灯效初始化,先全灭(事件表装载)
+ * @return APP_LED_OK=成功
+ */
+AppLed_Ret_t app_led_init(void);
+
+/**
+ * @brief 按优先级刷新灯(每 20ms 业务节拍调用)
+ * @param dt 距上次调用的毫秒数
+ * @note 内部读继电器实际态挂 HEAT 事件;同时推进配对会话计时
+ * (app_pair_tick_1ms)并消费配对成功/失败边沿事件。
+ * @return 无
+ */
+void app_led_run(uint16_t dt);
+
+/**
+ * @brief 长按满 5s 的"松手即配网"提示:绿灯 100ms 快闪(红灯灭)
+ * @note 此时还没清绑定(要等松手),只是给按住的用户反馈
+ * @return 无
+ */
+void app_led_hint_pair(void);
+
+/**
+ * @brief 结束"将配网"提示(配对会话已由 app_pair 接管,或改判为 OTA)
+ * @return 无
+ */
+void app_led_clear_pair_hint(void);
+
+/**
+ * @brief 进入 OTA:清掉其它灯,挂红绿交替一直闪(最高优先级)
+ * @return 无
+ */
+void app_led_request_ota(void);
+
+/**
+ * @brief 查询是否处于 OTA 灯效
+ * @return 1=OTA 灯效中,0=否
+ */
+uint8_t app_led_is_ota(void);
+
+/**
+ * @brief 清空全部灯事件并灭灯
+ * @return 无
+ */
+void app_led_clear(void);
+
+/**
+ * @brief 查询是否已初始化
+ * @return APP_LED_OK=已初始化,APP_LED_ERR_NOT_INIT=未初始化
+ */
+AppLed_Ret_t app_led_get_status(void);
+
+#endif /* __APP_LED_H__ */
diff --git a/apps/usr_app/app_linkage.c b/apps/usr_app/app_linkage.c
new file mode 100644
index 0000000..a9057fdf
--- /dev/null
+++ b/apps/usr_app/app_linkage.c
@@ -0,0 +1,126 @@
+/******************************************************************************
+ * @file app_linkage.c
+ * @brief CBR1 联动引擎骨架(P0:BOARD_LINKAGE_ENABLE=0)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P2 填充实现(规格书第 10 章)
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "app_linkage.h"
+#include "app_relay.h"
+#include "board_pin.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[LINK]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+static void linkage_disabled_once(void)
+{
+ static uint8_t s_logged;
+
+ if (!s_logged) {
+ s_logged = 1;
+ log_info("app_linkage disabled (P2)\n");
+ }
+}
+
+/**
+ * @brief 初始化联动
+ * @return 无
+ */
+void app_linkage_init(void)
+{
+#if BOARD_LINKAGE_ENABLE
+ /* P2: 清作废标志(配置由 app_nvm_load 灌) */
+#else
+ linkage_disabled_once();
+#endif
+}
+
+/**
+ * @brief 500ms 任务上下文跑联动
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void app_linkage_run(uint16_t dt)
+{
+#if BOARD_LINKAGE_ENABLE
+ (void)dt;
+ /* P2: 有效配置 + 未作废 + 有温度 → 按 10.3 请求开/关 */
+#else
+ (void)dt;
+#endif
+}
+
+/**
+ * @brief 立刻重判(APP 重写 DP8 / 定时收口)
+ * @return 无
+ */
+void app_linkage_reeval(void)
+{
+#if !BOARD_LINKAGE_ENABLE
+ linkage_disabled_once();
+#endif
+}
+
+/**
+ * @brief DP7 联动删除(脉冲)
+ * @return 无
+ */
+void app_linkage_delete(void)
+{
+#if !BOARD_LINKAGE_ENABLE
+ linkage_disabled_once();
+#endif
+}
+
+/**
+ * @brief APP 重写 DP8
+ * @return 无
+ */
+void app_linkage_set_config(void)
+{
+#if !BOARD_LINKAGE_ENABLE
+ linkage_disabled_once();
+#endif
+}
+
+/**
+ * @brief 手动作废本轮联动(仲裁器转调;P0 无联动,空操作)
+ * @return 无
+ */
+void app_linkage_void(void)
+{
+#if !BOARD_LINKAGE_ENABLE
+ /* P0:无联动引擎,无事可做 */
+#endif
+}
+
+/**
+ * @brief 配置是否有效
+ * @return 1=有效
+ */
+uint8_t app_linkage_is_valid(void)
+{
+ return 0; /* P0:无有效配置 */
+}
+
+/**
+ * @brief 缸体编号
+ * @return tank_id
+ */
+uint16_t app_linkage_get_tank_id(void)
+{
+ return 0;
+}
+
+/**
+ * @brief 配置版本
+ * @return 版本号
+ */
+uint8_t app_linkage_get_cfg_ver(void)
+{
+ return 0;
+}
diff --git a/apps/usr_app/app_linkage.h b/apps/usr_app/app_linkage.h
new file mode 100644
index 0000000..23f78ca
--- /dev/null
+++ b/apps/usr_app/app_linkage.h
@@ -0,0 +1,76 @@
+/******************************************************************************
+ * @file app_linkage.h
+ * @brief CBR1 联动引擎(第 10 章:1 条 20B DP8 配置,温度回差开/关)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P0 = 骨架(BOARD_LINKAGE_ENABLE=0;P2 填充)
+ *
+ * @note 开停只看温度(temp_x10,0.1℃ 有符号):
+ * T < Tmin → 请求开加热
+ * T > Tmax → 请求关加热
+ * [Tmin,Tmax] → 保持上一态
+ * 温度源:已连接=DP6(float×10);断连=扫描风扇 JBao-{tank} 广播。
+ * 丢广播/无效包 → 当没收到,保持上一态(禁止丢包关加热)。
+ ******************************************************************************/
+
+#ifndef __APP_LINKAGE_H__
+#define __APP_LINKAGE_H__
+
+#include
+
+/**
+ * @brief 初始化联动(配置有效性由 is_valid 判;作废标志清 0)
+ * @return 无
+ */
+void app_linkage_init(void);
+
+/**
+ * @brief 500ms 任务上下文跑联动:有效配置 + 未被手动作废 + 有有效温度才判
+ * @param dt 距上次调用的毫秒数(=500)
+ * @return 无
+ */
+void app_linkage_run(uint16_t dt);
+
+/**
+ * @brief 立刻按当前温度重判一次(APP 重写 DP8 / 定时窗收口后调用)
+ * @return 无
+ */
+void app_linkage_reeval(void);
+
+/**
+ * @brief DP7 联动删除(脉冲):清 RAM+VM 20B;联动占着则关;delete 清 0
+ * @return 无
+ */
+void app_linkage_delete(void);
+
+/**
+ * @brief APP 重写 DP8(memcpy 已完成):存 VM、清手动作废、立刻重判
+ * @return 无
+ */
+void app_linkage_set_config(void);
+
+/**
+ * @brief 手动作废本轮联动(RAM,不写 VM;仲裁器唯一转调点)
+ * @return 无
+ */
+void app_linkage_void(void);
+
+/**
+ * @brief 配置是否有效(缸体号≠0 且 Tmin≤Tmax)
+ * @return 1=有效
+ */
+uint8_t app_linkage_is_valid(void);
+
+/**
+ * @brief 缸体编号(DP8 Byte0-1,uint16 大端)
+ * @return tank_id
+ */
+uint16_t app_linkage_get_tank_id(void);
+
+/**
+ * @brief 配置版本(DP8 Byte6,扫描匹配用)
+ * @return 版本号
+ */
+uint8_t app_linkage_get_cfg_ver(void);
+
+#endif /* __APP_LINKAGE_H__ */
diff --git a/apps/usr_app/app_nvm.c b/apps/usr_app/app_nvm.c
new file mode 100644
index 0000000..fde3f99
--- /dev/null
+++ b/apps/usr_app/app_nvm.c
@@ -0,0 +1,123 @@
+/******************************************************************************
+ * @file app_nvm.c
+ * @brief CBR1 VM 持久化(P1 起):heat_switch / linkage 20B / time_en /
+ * time_config1~7 / run_record 31天 / fault_en / alarm_en
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P0 = 空骨架(BOARD_NVM_ENABLE=0,P1 填充)
+ *
+ * @note ⚠ 写 flash 只能在任务上下文(app_nvm_flush / app_nvm_save),
+ * 20ms 中断上下文只允许 app_nvm_tick 打脏。
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "app_nvm.h"
+#include "board_pin.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[APP_NVM]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+static void nvm_disabled_once(void)
+{
+ static uint8_t s_logged;
+
+ if (!s_logged) {
+ s_logged = 1;
+ log_info("app_nvm disabled (P1)\n");
+ }
+}
+
+/**
+ * @brief 初始化(P1:校验 VM 有效性)
+ * @return 无
+ */
+void app_nvm_init(void)
+{
+#if BOARD_NVM_ENABLE
+ /* P1: 校验 VM 有效性 */
+#else
+ nvm_disabled_once();
+#endif
+}
+
+/**
+ * @brief 立即保存全部业务字段(任务上下文)
+ * @return 无
+ */
+void app_nvm_save(void)
+{
+#if !BOARD_NVM_ENABLE
+ nvm_disabled_once();
+#endif
+}
+
+/**
+ * @brief 立即保存运行记录 item(跨天旋转用)
+ * @return 无
+ */
+void app_nvm_save_runrec(void)
+{
+#if !BOARD_NVM_ENABLE
+ nvm_disabled_once();
+#endif
+}
+
+/**
+ * @brief 从 VM 加载业务字段并应用(开机调用)
+ * @return 无
+ */
+void app_nvm_load(void)
+{
+#if BOARD_NVM_ENABLE
+ /* P1: heat_switch 恢复 + linkage/timer/run_record 回填 gDevData */
+#else
+ nvm_disabled_once();
+#endif
+}
+
+/**
+ * @brief 业务节拍(中断上下文):变化检测 + 防抖计时,不写 flash
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void app_nvm_tick(uint16_t dt)
+{
+#if !BOARD_NVM_ENABLE
+ (void)dt;
+#endif
+}
+
+/**
+ * @brief 任务上下文落盘:防抖满后真正写 VM(500ms loop 调用)
+ * @return 无
+ */
+void app_nvm_flush(void)
+{
+#if !BOARD_NVM_ENABLE
+ nvm_disabled_once();
+#endif
+}
+
+/**
+ * @brief 标记业务字段有变化,防抖计时归零
+ * @return 无
+ */
+void app_nvm_mark_dirty(void)
+{
+#if !BOARD_NVM_ENABLE
+ nvm_disabled_once();
+#endif
+}
+
+/**
+ * @brief 出厂重置:清全部业务 VM(保留 BLE 配对)
+ * @return 无
+ */
+void app_nvm_reset(void)
+{
+#if !BOARD_NVM_ENABLE
+ nvm_disabled_once();
+#endif
+}
diff --git a/apps/usr_app/app_nvm.h b/apps/usr_app/app_nvm.h
new file mode 100644
index 0000000..6cc128e
--- /dev/null
+++ b/apps/usr_app/app_nvm.h
@@ -0,0 +1,83 @@
+/******************************************************************************
+ * @file app_nvm.h
+ * @brief CBR1 VM 持久化(P1 起):heat_switch / linkage_config(20B) /
+ * time_en / time_config1~7 / run_record(31×uint32+日期) /
+ * fault_en / alarm_en(规格书第 13 章)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P0 = 空骨架(BOARD_NVM_ENABLE=0)
+ *
+ * @note 存:heat_switch、linkage_config(20B)、time_en、time_config1~7、
+ * run_record(31×uint32 + slot0 日期)、fault_en、alarm_en。
+ * 不存:DP1–5、DP6 当前温度、联动作废标志、过零 pending、
+ * linkage_delete(脉冲)、BLE 三元组(出厂重置不清)。
+ * 写 VM 时机:APP 改配置后(500ms 防抖);运行记录每累计 600s 一次、
+ * 跨天旋转立刻写。
+ *
+ * ⚠ 写 flash 只能在任务上下文(app_nvm_flush / app_nvm_save /
+ * app_nvm_save_runrec),20ms 中断上下文只允许 app_nvm_tick 打脏。
+ ******************************************************************************/
+
+#ifndef __APP_NVM_H__
+#define __APP_NVM_H__
+
+#include
+
+/**
+ * @brief 初始化(校验 VM 有效性,仅日志;数据加载在 app_nvm_load)
+ * @return 无
+ */
+void app_nvm_init(void);
+
+/**
+ * @brief 立即保存全部业务字段到 VM
+ * @note syscfg 写入不能在裸中断里调用;只由 app_nvm_flush()(任务上下文)调用
+ * @return 无
+ */
+void app_nvm_save(void);
+
+/**
+ * @brief 立即保存运行记录 item(跨天旋转时用,任务上下文)
+ * @note 与 app_nvm_save 的区别:只写 31 天记录这一项,避免跨天时把
+ * 状态/配置一起无谓擦写一遍
+ * @return 无
+ */
+void app_nvm_save_runrec(void);
+
+/**
+ * @brief 从 VM 加载业务字段并应用(开机调用):
+ * mode/fan_gear 恢复、联动/定时/运行记录回填 gDevData;mode 从周期头开跑
+ * @return 无
+ */
+void app_nvm_load(void);
+
+/**
+ * @brief 业务节拍(中断上下文):变化检测 + 防抖计时,**不执行写入**
+ * @param dt 距上次调用的毫秒数
+ * @note ⚠ 跑在 usr_timer 20ms 回调 = 硬件定时器中断上下文,栈极小,
+ * 只允许轻量比较计时;禁止在此调 syscfg_write(会栈溢出复位)。
+ * @return 无
+ */
+void app_nvm_tick(uint16_t dt);
+
+/**
+ * @brief 任务上下文落盘:防抖满后真正写 VM(由 usr_timer_loop 500ms 调用)
+ * @note 必须且只能在非中断上下文调用(VM 擦写 flash 栈用量大)
+ * @return 无
+ */
+void app_nvm_flush(void);
+
+/**
+ * @brief 标记业务字段有变化,防抖计时归零(档位/模式/配置变化时调用)
+ * @return 无
+ */
+void app_nvm_mark_dirty(void);
+
+/**
+ * @brief 出厂重置:清全部业务 VM(保留 BLE 配对,规格书第 14 章)
+ * @note 由 jb_product DP21 调用;RAM 复位由调用方完成
+ * @return 无
+ */
+void app_nvm_reset(void);
+
+#endif /* __APP_NVM_H__ */
diff --git a/apps/usr_app/app_pair.c b/apps/usr_app/app_pair.c
new file mode 100644
index 0000000..65d3c26
--- /dev/null
+++ b/apps/usr_app/app_pair.c
@@ -0,0 +1,74 @@
+/******************************************************************************
+ * @file app_pair.c
+ * @brief 配对会话计时实现
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.08.25
+ * @version V1.0.0,首次发布,实现配对会话计时与成功/超时判定
+ ******************************************************************************/
+
+#include "app_pair.h"
+#include "board_pin.h"
+#include "usr_le_api.h"
+#include "system/includes.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[APP_PAIR]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+/** 配对会话状态,替代原来散落的 s_pairing / s_pair_ms */
+typedef struct {
+ uint8_t active;
+ uint32_t elapsed_ms;
+} AppPairCtx_t;
+
+static AppPairCtx_t s_pair;
+
+/**
+ * @brief 开始一次配对会话(计时清零)
+ * @return 无
+ */
+void app_pair_start(void)
+{
+ s_pair.active = 1;
+ s_pair.elapsed_ms = 0;
+}
+
+/**
+ * @brief 推进配对计时并检查结果,每个业务 tick 调用一次
+ * @param dt 距上次调用的毫秒数
+ * @return APP_PAIR_EVT_OK=配对成功,APP_PAIR_EVT_FAIL=超时,否则 NONE
+ */
+AppPairEvt_t app_pair_tick_1ms(uint16_t dt)
+{
+ if (!s_pair.active) {
+ return APP_PAIR_EVT_NONE;
+ }
+
+ /* BLE 侧绑定成功:结束本次配对会话 */
+ if (usr_get_pair_flag()) {
+ s_pair.active = 0;
+ s_pair.elapsed_ms = 0;
+ log_info("pair ok\n");
+ return APP_PAIR_EVT_OK;
+ }
+
+ s_pair.elapsed_ms += dt;
+ if (s_pair.elapsed_ms >= BOARD_PAIR_TIMEOUT_MS) {
+ s_pair.active = 0;
+ s_pair.elapsed_ms = 0;
+ log_info("pair timeout 120s, stay on\n");
+ return APP_PAIR_EVT_FAIL;
+ }
+
+ return APP_PAIR_EVT_NONE;
+}
+
+/**
+ * @brief 查询是否仍在配对会话中
+ * @return 1=进行中,0=未在配对
+ */
+uint8_t app_pair_is_active(void)
+{
+ return s_pair.active;
+}
diff --git a/apps/usr_app/app_pair.h b/apps/usr_app/app_pair.h
new file mode 100644
index 0000000..521d5da
--- /dev/null
+++ b/apps/usr_app/app_pair.h
@@ -0,0 +1,43 @@
+/******************************************************************************
+ * @file app_pair.h
+ * @brief 配对会话计时:开机配对窗口是否成功/超时,与 BLE 配对本身解耦
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.08.25
+ * @version V1.0.0,首次发布,实现配对会话计时与成功/超时判定
+ *
+ * @note 真正的 BLE 广播/绑定逻辑在 usr_le_code;本模块只负责“进入配对后
+ * 120s 内 usr_get_pair_flag() 是否变 1”这件事,纯状态,不碰灯/按键。
+ ******************************************************************************/
+
+#ifndef __APP_PAIR_H__
+#define __APP_PAIR_H__
+
+#include
+
+/** 配对结果边沿事件,仅在状态变化那一次返回一次 */
+typedef enum {
+ APP_PAIR_EVT_NONE = 0,
+ APP_PAIR_EVT_OK,
+ APP_PAIR_EVT_FAIL
+} AppPairEvt_t;
+
+/**
+ * @brief 开始一次配对会话(计时清零)
+ * @return 无
+ */
+void app_pair_start(void);
+
+/**
+ * @brief 推进配对计时并检查结果,每个业务 tick 调用一次
+ * @param dt 距上次调用的毫秒数
+ * @return APP_PAIR_EVT_OK=配对成功,APP_PAIR_EVT_FAIL=超时,否则 NONE
+ */
+AppPairEvt_t app_pair_tick_1ms(uint16_t dt);
+
+/**
+ * @brief 查询是否仍在配对会话中
+ * @return 1=进行中,0=未在配对
+ */
+uint8_t app_pair_is_active(void);
+
+#endif /* __APP_PAIR_H__ */
diff --git a/apps/usr_app/app_relay.c b/apps/usr_app/app_relay.c
new file mode 100644
index 0000000..329549f
--- /dev/null
+++ b/apps/usr_app/app_relay.c
@@ -0,0 +1,154 @@
+/******************************************************************************
+ * @file app_relay.c
+ * @brief 继电器:请求等过零执行;超时则关强断、开放弃
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ * @history
+ * - V1.1.0, 2026.09.04, cyWu, 去掉未使用计数,精简注释
+ * - V1.0.0, 2026.09.04, cyWu, 首次发布
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "app_relay.h"
+#include "bsp_hw.h"
+#include "board_pin.h"
+#include "jb_product.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[RELAY]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+typedef struct {
+ uint8_t request; /**< 上层意图:0=关 1=开 */
+ uint8_t actual; /**< 过零后的 GPIO 真值 */
+} AppRelayCtx_t;
+
+static AppRelayCtx_t s_relay;
+
+/**
+ * @brief 写继电器 GPIO 并更新 actual(过零 ISR / 超时路径)
+ * @param on 1=吸合 0=断开
+ * @return 无
+ */
+static void relay_apply(uint8_t on)
+{
+ bsp_relay_set(on);
+ s_relay.actual = on;
+}
+
+/**
+ * @brief 初始化继电器:GPIO 上电默认关;P0 无 VM,请求=实际=关
+ * @return 无
+ */
+void app_relay_init(void)
+{
+ s_relay.request = 0;
+ s_relay.actual = 0;
+ bsp_relay_set(0);
+ log_info("relay init, request=off actual=off\n");
+}
+
+/**
+ * @brief 请求加热开/关(手动/APP 写 DP0/定时/联动统一入口)
+ * @param on 1=开 0=关
+ * @return 无
+ */
+void app_relay_request(uint8_t on)
+{
+ on = on ? 1 : 0;
+
+ if (on == s_relay.request) {
+ return; /* 意图没变:不重复发起(避免连续同向请求反复计超时) */
+ }
+ s_relay.request = on;
+
+#if BOARD_ZC_ENABLE
+ if (on == s_relay.actual) {
+ /* 例如 PC3 已因超时强关(actual=0),收到关请求:无需等沿,直接同步 */
+ log_info("request %s but actual already %d, sync\n", on ? "ON" : "OFF",
+ s_relay.actual);
+ return;
+ }
+ log_info("request %s, wait zc edge\n", on ? "ON" : "OFF");
+ /* 等 app_zc_poll 计数 / bsp_zc 沿回调执行 */
+#else
+ /* 无过零(调试):开不吸合,关立即强断(规格书 7.2 / 4 章) */
+ if (on) {
+ gDevData.fault_code = 2;
+ log_info("zc disabled: open request rejected, stay off\n");
+ s_relay.request = 0; /* 放弃开请求,回落到实际(不吸合) */
+ } else {
+ log_info("zc disabled: force relay off\n");
+ relay_apply(0);
+ }
+#endif
+}
+
+/**
+ * @brief 过零沿:有 pending 则切 GPIO
+ * @note FPIN ISR 上下文,禁止 printf / 写 VM / 切 BLE
+ * @return 无
+ */
+void app_relay_zc_edge(void)
+{
+ if (s_relay.request == s_relay.actual) {
+ return; /* 无 pending,仅记录过零(由 app_zc 管超时) */
+ }
+ relay_apply(s_relay.request);
+}
+
+/**
+ * @brief 过零超时:关则强断,开则放弃并保持关,fault_code=2
+ * @note 20ms 节拍调用;与 FPIN ISR 抢状态,关中断后复检
+ * @return 无
+ */
+void app_relay_zc_timeout(void)
+{
+ local_irq_disable();
+ if (s_relay.request == s_relay.actual) {
+ local_irq_enable();
+ return;
+ }
+ gDevData.fault_code = 2;
+
+ if (s_relay.request) {
+ /* 开请求超时:禁止无过零吸合,放弃本次开请求,保持关 */
+ s_relay.request = 0;
+ s_relay.actual = 0;
+ bsp_relay_set(0);
+ } else {
+ /* 关请求超时:强制断开(安全路径,规格书 7.2) */
+ bsp_relay_set(0);
+ s_relay.actual = 0;
+ }
+ local_irq_enable();
+}
+
+/**
+ * @brief 当前请求态
+ * @return 1=请求开,0=请求关
+ */
+uint8_t app_relay_get_request(void)
+{
+ return s_relay.request;
+}
+
+/**
+ * @brief 当前实际吸合态
+ * @return 1=已吸合,0=已断开
+ */
+uint8_t app_relay_get_actual(void)
+{
+ return s_relay.actual;
+}
+
+/**
+ * @brief 是否已同步(无 pending)
+ * @return 1=request==actual
+ */
+uint8_t app_relay_synced(void)
+{
+ return (s_relay.request == s_relay.actual) ? 1 : 0;
+}
diff --git a/apps/usr_app/app_relay.h b/apps/usr_app/app_relay.h
new file mode 100644
index 0000000..4cbf711
--- /dev/null
+++ b/apps/usr_app/app_relay.h
@@ -0,0 +1,57 @@
+/******************************************************************************
+ * @file app_relay.h
+ * @brief 继电器:request 等过零落到 actual;灯和 DP0 读 actual
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ ******************************************************************************/
+
+#ifndef __APP_RELAY_H__
+#define __APP_RELAY_H__
+
+#include
+
+/**
+ * @brief 初始化继电器(默认关)
+ * @return 无
+ */
+void app_relay_init(void);
+
+/**
+ * @brief 请求开/关(短按 / DP0 / 定时 / 联动统一入口)
+ * @param on 1=开(等过零)0=关(等过零)
+ * @return 无
+ */
+void app_relay_request(uint8_t on);
+
+/**
+ * @brief 过零沿回调:有 pending 则切 GPIO
+ * @return 无
+ */
+void app_relay_zc_edge(void);
+
+/**
+ * @brief 过零超时:关强断 / 开放弃,fault_code=2
+ * @return 无
+ */
+void app_relay_zc_timeout(void);
+
+/**
+ * @brief 当前请求
+ * @return 1=开 0=关
+ */
+uint8_t app_relay_get_request(void);
+
+/**
+ * @brief 实际吸合态(DP0 / 灯效)
+ * @return 1=吸合 0=断开
+ */
+uint8_t app_relay_get_actual(void);
+
+/**
+ * @brief request 是否已等于 actual
+ * @return 1=已同步
+ */
+uint8_t app_relay_synced(void);
+
+#endif /* __APP_RELAY_H__ */
diff --git a/apps/usr_app/app_runrec.c b/apps/usr_app/app_runrec.c
new file mode 100644
index 0000000..a04751e
--- /dev/null
+++ b/apps/usr_app/app_runrec.c
@@ -0,0 +1,149 @@
+/******************************************************************************
+ * @file app_runrec.c
+ * @brief CBR1 31 天运行记录骨架(P0:BOARD_RUNREC_ENABLE=0)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P2 填充实现(接口见 app_runrec.h)
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "app_runrec.h"
+#include "app_relay.h"
+#include "board_pin.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[RUNREC]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+static void runrec_disabled_once(void)
+{
+ static uint8_t s_logged;
+
+ if (!s_logged) {
+ s_logged = 1;
+ log_info("app_runrec disabled (P2)\n");
+ }
+}
+
+/**
+ * @brief 初始化
+ * @return 无
+ */
+void app_runrec_init(void)
+{
+#if BOARD_RUNREC_ENABLE
+ /* P2: 先灌 VM,未对时只加 slot0 */
+#else
+ runrec_disabled_once();
+#endif
+}
+
+/**
+ * @brief 20ms 节拍:实际吸合才累计
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void app_runrec_tick(uint16_t dt)
+{
+#if BOARD_RUNREC_ENABLE
+ if (app_relay_get_actual()) {
+ /* P2: s_pend_ms += dt; 满 600s → mark_dirty */
+ }
+#else
+ (void)dt;
+#endif
+}
+
+/**
+ * @brief 500ms 任务上下文:跨天旋转 + 灌 DP17
+ * @return 无
+ */
+void app_runrec_rotate_if_needed(void)
+{
+#if BOARD_RUNREC_ENABLE
+ /* P2 */
+#else
+ runrec_disabled_once();
+#endif
+}
+
+/**
+ * @brief 读某天秒数
+ * @param day 0~30
+ * @return 秒(P0 全 0)
+ */
+uint32_t app_runrec_get(uint8_t day)
+{
+#if BOARD_RUNREC_ENABLE
+ (void)day;
+ return 0;
+#else
+ (void)day;
+ return 0;
+#endif
+}
+
+/**
+ * @brief 打包 124B
+ * @param out 输出缓冲
+ * @return 无
+ */
+void app_runrec_pack(uint8_t *out)
+{
+#if BOARD_RUNREC_ENABLE
+ (void)out;
+#else
+ (void)out;
+#endif
+}
+
+/**
+ * @brief 解包 124B
+ * @param in 输入缓冲
+ * @return 无
+ */
+void app_runrec_unpack(const uint8_t *in)
+{
+#if BOARD_RUNREC_ENABLE
+ (void)in;
+#else
+ (void)in;
+#endif
+}
+
+/**
+ * @brief slot[0] 日期
+ * @return 日期
+ */
+uint32_t app_runrec_get_slot0_date(void)
+{
+ return 0;
+}
+
+/**
+ * @brief 设置 slot[0] 日期
+ * @param ymd yyyymmdd
+ * @return 无
+ */
+void app_runrec_set_slot0_date(uint32_t ymd)
+{
+#if BOARD_RUNREC_ENABLE
+ (void)ymd;
+#else
+ (void)ymd;
+#endif
+}
+
+/**
+ * @brief 清零(出厂重置)
+ * @return 无
+ */
+void app_runrec_reset(void)
+{
+#if BOARD_RUNREC_ENABLE
+ /* P2: 全 0 + 日期清 */
+#else
+ runrec_disabled_once();
+#endif
+}
diff --git a/apps/usr_app/app_runrec.h b/apps/usr_app/app_runrec.h
new file mode 100644
index 0000000..cac9d7a
--- /dev/null
+++ b/apps/usr_app/app_runrec.h
@@ -0,0 +1,80 @@
+/******************************************************************************
+ * @file app_runrec.h
+ * @brief CBR1 31 天运行记录(第 13.1 节,DP17 RO,124B):实际吸合计秒
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P0 = 骨架(BOARD_RUNREC_ENABLE=0;P2 填充)
+ *
+ * @note 什么叫在运行:过零后继电器**实际已吸合**(app_relay_get_actual()==1)。
+ * 日期轴:未对时只往 slot[0] 加不旋转;对时后按本地日历左移。
+ * 落盘:每累计 600s 写一次 VM;跨天立刻写。
+ ******************************************************************************/
+
+#ifndef __APP_RUNREC_H__
+#define __APP_RUNREC_H__
+
+#include
+#include "board_pin.h"
+
+#define APP_RUNREC_PACK_LEN (BOARD_RUN_RECORD_DAYS * 4u) /**< 124B */
+
+/**
+ * @brief 初始化(未对时:只往 slot0 加,不旋转)
+ * @return 无
+ */
+void app_runrec_init(void);
+
+/**
+ * @brief 20ms 节拍:继电器实际吸合则累计秒,满 10 分钟打脏(不写 flash)
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void app_runrec_tick(uint16_t dt);
+
+/**
+ * @brief 500ms 任务上下文:收下 pending 秒并灌 DP17;对时后跨天左移旋转
+ * @return 无
+ */
+void app_runrec_rotate_if_needed(void);
+
+/**
+ * @brief 读某天秒数(0=今天)
+ * @param day 0~30
+ * @return 秒
+ */
+uint32_t app_runrec_get(uint8_t day);
+
+/**
+ * @brief 打包 124B(每格 uint32 大端),灌 gDevData.run_record
+ * @param out 输出缓冲(≥124B)
+ * @return 无
+ */
+void app_runrec_pack(uint8_t *out);
+
+/**
+ * @brief 从 VM 解包 124B(uint32 大端)
+ * @param in 输入缓冲
+ * @return 无
+ */
+void app_runrec_unpack(const uint8_t *in);
+
+/**
+ * @brief slot[0] 对应本地日期(yyyymmdd 打包)
+ * @return 日期
+ */
+uint32_t app_runrec_get_slot0_date(void);
+
+/**
+ * @brief 设置 slot[0] 日期(对时/旋转后写回)
+ * @param ymd yyyymmdd
+ * @return 无
+ */
+void app_runrec_set_slot0_date(uint32_t ymd);
+
+/**
+ * @brief 清零 31 天记录与日期(出厂重置)
+ * @return 无
+ */
+void app_runrec_reset(void);
+
+#endif /* __APP_RUNREC_H__ */
diff --git a/apps/usr_app/app_th_scan.c b/apps/usr_app/app_th_scan.c
new file mode 100644
index 0000000..dcce06d
--- /dev/null
+++ b/apps/usr_app/app_th_scan.c
@@ -0,0 +1,52 @@
+/******************************************************************************
+ * @file app_th_scan.c
+ * @brief CBR1 断连温湿度扫描骨架(P0:BOARD_TH_SCAN_ENABLE=0)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P2 填充实现(规格书第 8 章)
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "app_th_scan.h"
+#include "board_pin.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[THSCAN]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+static void th_scan_disabled_once(void)
+{
+ static uint8_t s_logged;
+
+ if (!s_logged) {
+ s_logged = 1;
+ log_info("app_th_scan disabled (P2)\n");
+ }
+}
+
+/**
+ * @brief 初始化扫描模块
+ * @return 无
+ */
+void app_th_scan_init(void)
+{
+#if BOARD_TH_SCAN_ENABLE
+ /* P2: 无状态(500ms 时隙由 tick 驱动) */
+#else
+ th_scan_disabled_once();
+#endif
+}
+
+/**
+ * @brief 500ms 任务上下文:断连已配对时广播/扫描交替
+ * @return 无
+ */
+void app_th_scan_tick(void)
+{
+#if BOARD_TH_SCAN_ENABLE
+ /* P2: 已连接/配对/OTA → 退交替;断连已配对 → 偶见宝重连广播/奇扫描 */
+#else
+ th_scan_disabled_once();
+#endif
+}
diff --git a/apps/usr_app/app_th_scan.h b/apps/usr_app/app_th_scan.h
new file mode 100644
index 0000000..91e881c
--- /dev/null
+++ b/apps/usr_app/app_th_scan.h
@@ -0,0 +1,28 @@
+/******************************************************************************
+ * @file app_th_scan.h
+ * @brief CBR1 断连温湿度扫描(第 8 章,P2):500ms 重连广播 ↔ 500ms 扫描
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P0 = 骨架(BOARD_TH_SCAN_ENABLE=0)
+ *
+ * @note 匹配规则:名字 = "JBao-"+本机缸体号(DP8 uint16 十进制)且配置版本
+ * 相等 → temp_x10 = int8℃×10;湿度只打日志不参与开停。缸体号 0 不扫。
+ * 断连丢包:保持上一温度与上一加热态,禁止因丢包关加热。
+ ******************************************************************************/
+
+#ifndef __APP_TH_SCAN_H__
+#define __APP_TH_SCAN_H__
+
+/**
+ * @brief 初始化扫描模块
+ * @return 无
+ */
+void app_th_scan_init(void);
+
+/**
+ * @brief 500ms 任务上下文:断连已配对时 500ms 重连广播 / 500ms 扫描交替
+ * @return 无
+ */
+void app_th_scan_tick(void);
+
+#endif /* __APP_TH_SCAN_H__ */
diff --git a/apps/usr_app/app_timer_task.c b/apps/usr_app/app_timer_task.c
new file mode 100644
index 0000000..b9b3f1e
--- /dev/null
+++ b/apps/usr_app/app_timer_task.c
@@ -0,0 +1,84 @@
+/******************************************************************************
+ * @file app_timer_task.c
+ * @brief CBR1 定时引擎骨架(P0:BOARD_TIMER_ENABLE=0)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P2 填充实现(规格书第 11 章)
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "app_timer_task.h"
+#include "board_pin.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[TIMER]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+static void timer_disabled_once(void)
+{
+ static uint8_t s_logged;
+
+ if (!s_logged) {
+ s_logged = 1;
+ log_info("app_timer_task disabled (P2)\n");
+ }
+}
+
+/**
+ * @brief 初始化定时引擎
+ * @return 无
+ */
+void app_timer_init(void)
+{
+#if BOARD_TIMER_ENABLE
+ /* P2: RTC 门闩 + 状态复位 */
+#else
+ timer_disabled_once();
+#endif
+}
+
+/**
+ * @brief 500ms 任务上下文跑定时引擎
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void app_timer_run(uint16_t dt)
+{
+#if BOARD_TIMER_ENABLE
+ (void)dt;
+ /* P2: 窗判断 → 仲裁 timer_take/release → 请求开/关 */
+#else
+ (void)dt;
+#endif
+}
+
+/**
+ * @brief APP 写 DP9 定时总开关
+ * @param en 0=关 1=开
+ * @return 无
+ */
+void app_timer_set_en(uint8_t en)
+{
+#if BOARD_TIMER_ENABLE
+ (void)en;
+ /* P2: time_en=0 → 所有窗立刻结束(关加热)再补判联动 */
+#else
+ (void)en;
+#endif
+}
+
+/**
+ * @brief APP 写 DP10~16 定时配置
+ * @param idx 1~7
+ * @return 无
+ */
+void app_timer_set_config(uint8_t idx)
+{
+#if BOARD_TIMER_ENABLE
+ (void)idx;
+ /* P2: 配置入 RAM + mark_dirty */
+#else
+ (void)idx;
+#endif
+}
diff --git a/apps/usr_app/app_timer_task.h b/apps/usr_app/app_timer_task.h
new file mode 100644
index 0000000..0644b81
--- /dev/null
+++ b/apps/usr_app/app_timer_task.h
@@ -0,0 +1,41 @@
+/******************************************************************************
+ * @file app_timer_task.h
+ * @brief CBR1 定时引擎(第 11 章,P2:7 组 8B 配置 + time_en + RTC 门闩)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P0 = 骨架(BOARD_TIMER_ENABLE=0)
+ ******************************************************************************/
+
+#ifndef __APP_TIMER_TASK_H__
+#define __APP_TIMER_TASK_H__
+
+#include
+
+/**
+ * @brief 初始化定时引擎(RTC 门闩:usr_rtc_get_time 失败等同 time_en=0)
+ * @return 无
+ */
+void app_timer_init(void);
+
+/**
+ * @brief 500ms 任务上下文跑定时引擎(窗开始开 / 窗结束关 + 联动立刻重判)
+ * @param dt 距上次调用的毫秒数(=500)
+ * @return 无
+ */
+void app_timer_run(uint16_t dt);
+
+/**
+ * @brief APP 写 DP9 定时总开关
+ * @param en 0=关(所有窗立刻视为结束,关加热),1=开
+ * @return 无
+ */
+void app_timer_set_en(uint8_t en);
+
+/**
+ * @brief APP 写 DP10~16 定时配置(8 字节,Byte1 模式忽略但原样存 VM)
+ * @param idx 1~7
+ * @return 无
+ */
+void app_timer_set_config(uint8_t idx);
+
+#endif /* __APP_TIMER_TASK_H__ */
diff --git a/apps/usr_app/app_zc.c b/apps/usr_app/app_zc.c
new file mode 100644
index 0000000..0c75cbe
--- /dev/null
+++ b/apps/usr_app/app_zc.c
@@ -0,0 +1,93 @@
+/******************************************************************************
+ * @file app_zc.c
+ * @brief 过零业务:20ms 节拍判定 100ms 无沿超时
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ * @history
+ * - V1.1.0, 2026.09.04, cyWu, 精简注释
+ * - V1.0.0, 2026.09.04, cyWu, 首次发布
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "app_zc.h"
+#include "app_relay.h"
+#include "bsp_zc.h"
+#include "board_pin.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[APP_ZC]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+typedef struct {
+ uint8_t initialized;
+ uint8_t last_request;
+ uint8_t timeout_evt; /**< 1=开放弃 2=关强断,任务上下文取走打印 */
+ uint16_t pending_ms;
+} AppZcCtx_t;
+
+static AppZcCtx_t s_zc;
+
+/**
+ * @brief 初始化过零业务并挂继电器沿回调
+ * @return 无
+ */
+void app_zc_init(void)
+{
+ if (s_zc.initialized) {
+ return;
+ }
+#if BOARD_ZC_ENABLE
+ bsp_zc_init(app_relay_zc_edge);
+ s_zc.pending_ms = 0;
+ s_zc.last_request = app_relay_get_request();
+ s_zc.initialized = 1;
+ log_info("zc init ok\n");
+#else
+ log_info("zc disabled\n");
+#endif
+}
+
+/**
+ * @brief 20ms 节拍:pending 满 100ms 无沿则超时处理
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void app_zc_poll(uint16_t dt)
+{
+#if !BOARD_ZC_ENABLE
+ (void)dt;
+ return;
+#endif
+ if (!s_zc.initialized) {
+ return;
+ }
+
+ if (app_relay_get_request() != s_zc.last_request) {
+ s_zc.pending_ms = 0;
+ s_zc.last_request = app_relay_get_request();
+ }
+
+ if (!app_relay_synced()) {
+ s_zc.pending_ms = (uint16_t)(s_zc.pending_ms + dt);
+ if (s_zc.pending_ms >= BOARD_ZC_TIMEOUT_MS) {
+ s_zc.pending_ms = 0;
+ s_zc.timeout_evt = app_relay_get_request() ? 1 : 2;
+ app_relay_zc_timeout();
+ }
+ } else {
+ s_zc.pending_ms = 0;
+ }
+}
+
+/**
+ * @brief 取走一次超时事件(任务上下文打印)
+ * @return 0=无 1=开放弃 2=关强断
+ */
+uint8_t app_zc_consume_timeout(void)
+{
+ uint8_t evt = s_zc.timeout_evt;
+ s_zc.timeout_evt = 0;
+ return evt;
+}
diff --git a/apps/usr_app/app_zc.h b/apps/usr_app/app_zc.h
new file mode 100644
index 0000000..bd802db
--- /dev/null
+++ b/apps/usr_app/app_zc.h
@@ -0,0 +1,33 @@
+/******************************************************************************
+ * @file app_zc.h
+ * @brief 过零业务:装配 bsp_zc,20ms 节拍判 100ms 超时
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ ******************************************************************************/
+
+#ifndef __APP_ZC_H__
+#define __APP_ZC_H__
+
+#include
+
+/**
+ * @brief 初始化过零业务(挂继电器沿回调)
+ * @return 无
+ */
+void app_zc_init(void);
+
+/**
+ * @brief 20ms 节拍,只判超时,不扫沿
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void app_zc_poll(uint16_t dt);
+
+/**
+ * @brief 取走一次超时事件
+ * @return 0=无 1=开放弃 2=关强断
+ */
+uint8_t app_zc_consume_timeout(void);
+
+#endif /* __APP_ZC_H__ */
diff --git a/apps/usr_app/key.c b/apps/usr_app/key.c
new file mode 100644
index 0000000..701b744
--- /dev/null
+++ b/apps/usr_app/key.c
@@ -0,0 +1,265 @@
+/******************************************************************************
+ * @file key.c
+ * @brief 按键状态机实现
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.08.25
+ * @version V1.0.0,首次发布,实现按键短按/长按/释放状态机
+ ******************************************************************************/
+
+#include "key.h"
+#include
+
+static uint32_t s_key_count_time;
+static uint8_t s_key_num = USR_KEY_COUNT;
+keyCategory_t keyTable[USR_KEY_COUNT];
+
+/**
+ * @brief 读按键电平到 fsm
+ * @param key 状态机
+ * @return 1=已读,0=被屏蔽
+ */
+static uint8_t get_key_level(keyFSM_t *key)
+{
+ if (key->keyShield == KEY_DISABLE) {
+ return 0;
+ }
+ if (key->keyReadValue == NULL) {
+ return 0;
+ }
+ key->keyLevel = (key->keyReadValue() == key->keyDownLevel) ? Bit_SET : Bit_RESET;
+ return 1;
+}
+
+/**
+ * @brief 单击 / 双击间隔判定
+ * @param click 状态机
+ * @return 1=可以出事件
+ */
+static uint8_t key_click_frequency(keyFSM_t *click)
+{
+ click->keyInterval++;
+ switch (click->keyFrequency) {
+ case CLICK:
+ if (click->keyInterval >= (KEY_INTERVAL / (DEBOUNCE_TIME * KEY_TIMER_MS))) {
+ click->eventType = SHORT_Event;
+ return 1;
+ }
+ break;
+ case DbLCLICK:
+ if (click->keyInterval >= (KEY_INTERVAL / (DEBOUNCE_TIME * KEY_TIMER_MS))) {
+ click->eventType = DBCL_Event;
+ return 1;
+ }
+ break;
+ default:
+ return 1;
+ }
+ return 0;
+}
+
+/**
+ * @brief 按键状态机步进
+ * @param key_buf 状态机
+ * @return 1=已处理
+ */
+static uint8_t read_key_status(keyFSM_t *key_buf)
+{
+ if (!get_key_level(key_buf)) {
+ return 0;
+ }
+
+ switch (key_buf->keyStatus) {
+ case KEY_ST_NULL:
+ if (key_buf->keyLevel == Bit_SET) {
+ key_buf->keyStatus = KEY_ST_SURE;
+ }
+ break;
+ case KEY_ST_SURE:
+ if (key_buf->keyLevel == Bit_SET) {
+ key_buf->eventType = DOWN_Event;
+ key_buf->keyStatus = KEY_ST_DOWN;
+ key_buf->keyCount = 0;
+ key_buf->keyLongFlag = 1;
+ } else {
+ key_buf->keyStatus = KEY_ST_NULL;
+ }
+ break;
+ case KEY_ST_DOWN:
+ if (key_buf->keyLevel != Bit_SET) {
+ if (key_buf->keyShortFlag == 0) {
+ key_buf->keyShortFlag = 1;
+ key_buf->keyFrequency++;
+ key_buf->keyInterval = 0;
+ }
+ if (key_click_frequency(key_buf)) {
+ key_buf->keyFrequency = 0;
+ key_buf->keyStatus = KEY_ST_NULL;
+ }
+ if ((key_buf->keyCount >= key_buf->keyLongTime / DEBOUNCE_TIME)
+ && (key_buf->keyCount < key_buf->keyLastTime / DEBOUNCE_TIME)) {
+ key_buf->keyFrequency = 0;
+ key_buf->keyStatus = KEY_ST_NULL;
+ key_buf->eventType = RELEASE_Event;
+ }
+ key_buf->keyCount = 0;
+ key_buf->keyLongFlag = 1;
+ } else {
+ if ((++key_buf->keyCount >= key_buf->keyLastTime / DEBOUNCE_TIME)) {
+ key_buf->keyCount = 0;
+ key_buf->keyFrequency = 0;
+ key_buf->keyStatus = KEY_ST_LONG;
+ key_buf->eventType = LAST_Event;
+ key_buf->keyLongFlag = 1;
+ }
+ if ((key_buf->keyCount >= key_buf->keyLongTime / DEBOUNCE_TIME)
+ && (key_buf->keyCount < key_buf->keyLastTime / DEBOUNCE_TIME)) {
+ key_buf->keyFrequency = 0;
+ if (key_buf->keyLongFlag == 1) {
+ key_buf->eventType = LONG_Event;
+ key_buf->keyLongFlag = 0;
+ }
+ }
+ key_buf->keyShortFlag = 0;
+ }
+ break;
+ case KEY_ST_LONG:
+ if (key_buf->keyLevel != Bit_SET) {
+ key_buf->keyStatus = KEY_ST_NULL;
+ key_buf->eventType = RELEASE_Event;
+ }
+ break;
+ default:
+ break;
+ }
+ return 1;
+}
+
+/**
+ * @brief 扫描全部按键状态机
+ * @return 无
+ */
+static void key_event_process(void)
+{
+ size_t i;
+
+ for (i = 0; i < s_key_num; i++) {
+ (void)read_key_status(&keyTable[i].fsm);
+ }
+}
+
+/**
+ * @brief 1ms 调用一次;满 DEBOUNCE_TIME 才真正采样
+ * @return 无
+ */
+void keyCheckProcess(void)
+{
+ s_key_count_time++;
+ if (s_key_count_time >= (DEBOUNCE_TIME / KEY_TIMER_MS)) {
+ s_key_count_time = 0;
+ key_event_process();
+ }
+}
+
+/**
+ * @brief 拷贝产品按键表
+ * @param keys 按键数组,长度 USR_KEY_COUNT
+ * @return 无
+ */
+void keyParaInit(keyCategory_t *keys)
+{
+ if (keys == NULL) {
+ return;
+ }
+ if (USR_KEY_COUNT >= KEY_MAX_NUMBER) {
+ s_key_num = KEY_MAX_NUMBER;
+ }
+ memcpy(keyTable, keys, sizeof(keyCategory_t) * s_key_num);
+}
+
+/**
+ * @brief 运行中改长按/连按时长并复位状态
+ * @param key_event 按键索引
+ * @param key_param 新参数
+ * @return 无
+ */
+void setKeyEventParams(uint8_t key_event, keyCategory_t key_param)
+{
+ if (key_event >= s_key_num) {
+ return;
+ }
+ keyTable[key_event].fsm.keyShield = key_param.fsm.keyShield;
+ keyTable[key_event].fsm.keyLongTime = key_param.fsm.keyLongTime;
+ keyTable[key_event].fsm.keyLastTime = key_param.fsm.keyLastTime;
+ keyTable[key_event].fsm.keyStatus = KEY_ST_NULL;
+ keyTable[key_event].fsm.eventType = NULL_Event;
+ keyTable[key_event].fsm.keyCount = 0;
+}
+
+/**
+ * @brief 派发按键事件回调
+ * @return 无
+ */
+void keyHandle(void)
+{
+ size_t i;
+
+ for (i = 0; i < s_key_num; i++) {
+ if (keyTable[i].fsm.eventType == NULL_Event) {
+ continue;
+ }
+ switch (keyTable[i].fsm.eventType) {
+ case RELEASE_Event:
+ if (keyTable[i].func.releasePressCb != NULL) {
+ keyTable[i].func.releasePressCb();
+ }
+ keyTable[i].fsm.eventType = NULL_Event;
+ break;
+ case SHORT_Event:
+ if (keyTable[i].func.ShortPressCb != NULL) {
+ keyTable[i].func.ShortPressCb();
+ }
+ keyTable[i].fsm.eventType = NULL_Event;
+ break;
+ case DOWN_Event:
+ if (keyTable[i].func.downPressCb != NULL) {
+ keyTable[i].func.downPressCb();
+ }
+ keyTable[i].fsm.eventType = NULL_Event;
+ break;
+ case LONG_Event:
+ if (keyTable[i].func.longPressCb != NULL) {
+ keyTable[i].func.longPressCb();
+ }
+ keyTable[i].fsm.eventType = NULL_Event;
+ break;
+ case LAST_Event:
+ if (keyTable[i].func.lastPressCb != NULL) {
+ keyTable[i].func.lastPressCb();
+ }
+ break;
+ case DBCL_Event:
+ if (keyTable[i].func.dbclPressCb != NULL) {
+ keyTable[i].func.dbclPressCb();
+ }
+ keyTable[i].fsm.eventType = NULL_Event;
+ break;
+ default:
+ break;
+ }
+ }
+}
+
+/**
+ * @brief 复位指定按键状态机
+ * @param key_index 按键索引
+ * @return 无
+ */
+void reset_key_Status(keyList key_index)
+{
+ if (key_index >= USR_KEY_COUNT) {
+ return;
+ }
+ keyTable[key_index].fsm.keyStatus = KEY_ST_NULL;
+ keyTable[key_index].fsm.eventType = NULL_Event;
+ keyTable[key_index].fsm.keyCount = 0;
+}
diff --git a/apps/usr_app/key.h b/apps/usr_app/key.h
new file mode 100644
index 0000000..693ddec
--- /dev/null
+++ b/apps/usr_app/key.h
@@ -0,0 +1,98 @@
+/******************************************************************************
+ * @file key.h
+ * @brief 按键状态机:短按 / 长按 / 释放
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.08.25
+ * @version V1.0.0,首次发布,实现按键短按/长按/释放状态机
+ ******************************************************************************/
+
+#ifndef __KEY_H__
+#define __KEY_H__
+
+#include
+#include
+
+/*============================================================================*/
+/* 宏定义 */
+/*============================================================================*/
+
+#define KEY_TIMER_MS 1
+#define KEY_MAX_NUMBER 12
+#define DEBOUNCE_TIME 30
+#define KEY_INTERVAL 30
+
+#define CLICK 1
+#define DbLCLICK 2
+
+typedef enum {
+ USR_KEY_USER = 0,
+ USR_KEY_COUNT
+} keyList;
+
+typedef enum {
+ Bit_RESET = 0,
+ Bit_SET
+} BitAction_t;
+
+typedef enum {
+ KEY_ST_NULL = 0,
+ KEY_ST_RELEASE,
+ KEY_ST_SURE,
+ KEY_ST_UP,
+ KEY_ST_DOWN,
+ KEY_ST_LONG
+} keyStatus_t;
+
+typedef enum {
+ NULL_Event = 0,
+ DOWN_Event,
+ SHORT_Event,
+ LONG_Event,
+ LAST_Event,
+ DBCL_Event,
+ RELEASE_Event
+} keyEvent_t;
+
+typedef enum {
+ KEY_DISABLE = 0,
+ KEY_ENABLE = !KEY_DISABLE
+} keyEnable_t;
+
+typedef struct {
+ uint8_t keyShortFlag;
+ uint8_t keyLongFlag;
+ uint8_t keyInterval;
+ uint8_t keyFrequency;
+ uint16_t keyLongTime;
+ uint16_t keyLastTime;
+ uint32_t keyCount;
+ keyEnable_t keyShield;
+ BitAction_t keyLevel;
+ BitAction_t keyDownLevel;
+ keyStatus_t keyStatus;
+ keyEvent_t eventType;
+ uint8_t (*keyReadValue)(void);
+} keyFSM_t;
+
+typedef struct {
+ void (*nullPressCb)(void);
+ void (*releasePressCb)(void);
+ void (*downPressCb)(void);
+ void (*ShortPressCb)(void);
+ void (*longPressCb)(void);
+ void (*lastPressCb)(void);
+ void (*dbclPressCb)(void);
+} keyFunc_t;
+
+typedef struct {
+ keyFSM_t fsm;
+ keyFunc_t func;
+} keyCategory_t;
+
+void keyParaInit(keyCategory_t *keys);
+void setKeyEventParams(uint8_t key_event, keyCategory_t key_param);
+void keyCheckProcess(void);
+void keyHandle(void);
+void reset_key_Status(keyList key_index);
+
+#endif /* __KEY_H__ */
diff --git a/apps/usr_app/key_manager.c b/apps/usr_app/key_manager.c
new file mode 100644
index 0000000..6b2fc00
--- /dev/null
+++ b/apps/usr_app/key_manager.c
@@ -0,0 +1,170 @@
+/******************************************************************************
+ * @file key_manager.c
+ * @brief 按键:短按切加热;按住 5s 配对 / 12s OTA
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V3.1.0
+ * @history
+ * - V3.1.0, 2026.09.04, cyWu, 精简注释
+ * - V3.0.0, 2026.08.31, cyWu, 复用 key.c 长按分级
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "key_manager.h"
+#include "bsp_hw.h"
+#include "board_pin.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[KEY]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+/** 模块状态 */
+typedef struct {
+ uint8_t initialized;
+ uint8_t boot_key_lock; /**< 1=上电时键还按着,等松开才认短按(不挡长按分级) */
+ uint8_t ota_fired; /**< 1=已到点触发 OTA,本次按住松手不再提交配对 */
+} KeyManagerCtx_t;
+
+static KeyManagerCtx_t s_km;
+
+/**
+ * @brief 读按键:1=按下
+ * @return 1=按下,0=松开
+ */
+static uint8_t key_user_read(void)
+{
+ return bsp_key_is_pressed();
+}
+
+/**
+ * @brief 短按(SHORT_Event,松手时刻 <5s):上电未松手前忽略,否则切加热
+ * @return 无
+ */
+static void key_user_short(void)
+{
+ if (s_km.boot_key_lock) {
+ s_km.boot_key_lock = 0; /* 上电松手当成释放,不当短按切加热 */
+ log_info("boot key released\n");
+ return;
+ }
+ usr_on_key_short();
+}
+
+/**
+ * @brief 长按到点(LONG_Event,满 5s 仍按住):挂配对提示灯
+ * @note ⚠ 跑在 usr_timer 20ms(硬件定时器中断上下文):只准打标志 + 切灯效
+ * 事件(纯 RAM)。清绑定(写 VM)留给 app_function_loop(任务上下文)。
+ * @return 无
+ */
+static void key_user_pair_hint(void)
+{
+ usr_on_key_pair_hint();
+}
+
+/**
+ * @brief 连按到点(LAST_Event,满 12s 仍按住,到点即触发不等松手):进 OTA
+ * @note ⚠ 同上,只置标志;usr_app_ota_init 由 app_function_loop 执行。
+ * key.c 的 keyHandle 对 LAST_Event 不清 eventType,松手前每 20ms 都会
+ * 再派发一次,这里用 ota_fired 挡住重复调用。
+ * @return 无
+ */
+static void key_user_ota_start(void)
+{
+ if (s_km.ota_fired) {
+ return;
+ }
+ s_km.ota_fired = 1;
+ usr_on_key_ota_start();
+}
+
+/**
+ * @brief 释放(RELEASE_Event,只有按满 ≥5s 才会触发):未进 OTA 则提交配对
+ * @note 按满 12s 已触发 OTA 的这次按住,松手不再提交配对(ota_fired 拦住)。
+ * @return 无
+ */
+static void key_user_release(void)
+{
+ s_km.boot_key_lock = 0;
+ if (!s_km.ota_fired) {
+ usr_on_key_pair_start();
+ }
+ s_km.ota_fired = 0;
+}
+
+static keyCategory_t s_keys[USR_KEY_COUNT] = {
+ [USR_KEY_USER] = {
+ .fsm.keyShield = KEY_ENABLE,
+ .fsm.keyDownLevel = Bit_SET,
+ .fsm.eventType = NULL_Event,
+ .fsm.keyLongTime = BOARD_KEY_PAIR_MS, /* 满 5s 仍按住 -> longPressCb */
+ .fsm.keyLastTime = BOARD_KEY_OTA_MS, /* 满 12s 仍按住 -> lastPressCb */
+ .fsm.keyReadValue = key_user_read,
+ .func.ShortPressCb = key_user_short,
+ .func.releasePressCb = key_user_release,
+ .func.longPressCb = key_user_pair_hint,
+ .func.lastPressCb = key_user_ota_start,
+ },
+};
+
+/**
+ * @brief 装载按键表
+ * @return 无
+ */
+void user_key_init(void)
+{
+ if (s_km.initialized) {
+ return;
+ }
+ keyParaInit(s_keys);
+ s_km.boot_key_lock = 0;
+ s_km.ota_fired = 0;
+ s_km.initialized = 1;
+}
+
+/**
+ * @brief 上电后:键还按着则锁住短按,松开才认短按
+ * @note 只锁短按,**不锁长按分级**:上电就按住 5s 进配对是受支持的用法。
+ * @return 无
+ */
+void user_key_lock_if_held(void)
+{
+ reset_key_Status(USR_KEY_USER);
+ if (bsp_key_is_pressed()) {
+ s_km.boot_key_lock = 1; /* 上电手还按着,等松开才认短按,避免一上电就切加热 */
+ log_info("boot key held, wait release\n");
+ }
+}
+
+/**
+ * @brief 按真实毫秒推进状态机
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void user_key_scan(uint16_t dt)
+{
+ uint16_t i;
+
+ if (!s_km.initialized) {
+ return;
+ }
+ if (dt == 0) {
+ dt = 1;
+ }
+ /* 按键库按 1ms 步进,业务节拍是 20ms,这里循环 dt 次对齐真实时间 */
+ for (i = 0; i < dt; i++) {
+ keyCheckProcess();
+ }
+}
+
+/**
+ * @brief 派发按键回调
+ * @return 无
+ */
+void user_key_handle(void)
+{
+ if (!s_km.initialized) {
+ return;
+ }
+ keyHandle();
+}
diff --git a/apps/usr_app/key_manager.h b/apps/usr_app/key_manager.h
new file mode 100644
index 0000000..e28a630
--- /dev/null
+++ b/apps/usr_app/key_manager.h
@@ -0,0 +1,65 @@
+/******************************************************************************
+ * @file key_manager.h
+ * @brief 按键:短按切加热;按住 5s 配对 / 12s OTA
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V3.1.0
+ ******************************************************************************/
+
+#ifndef __KEY_MANAGER_H__
+#define __KEY_MANAGER_H__
+
+#include "key.h"
+
+/**
+ * @brief 业务层短按回调(app_function 实现)
+ * @return 无
+ */
+void usr_on_key_short(void);
+
+/**
+ * @brief 业务层"按住满 5s"回调(app_function 实现):切配对提示灯,尚未清绑定
+ * @return 无
+ */
+void usr_on_key_pair_hint(void);
+
+/**
+ * @brief 业务层"按满 5s 后松手"回调(app_function 实现):提交配对
+ * @note 只置标志;清绑定(写 VM)在 app_function_loop(任务上下文)执行
+ * @return 无
+ */
+void usr_on_key_pair_start(void);
+
+/**
+ * @brief 业务层"按住满 12s"回调(app_function 实现):到点即进 OTA
+ * @note 只置标志;usr_app_ota_init 在 app_function_loop(任务上下文)执行
+ * @return 无
+ */
+void usr_on_key_ota_start(void);
+
+/**
+ * @brief 装载按键表
+ * @return 无
+ */
+void user_key_init(void);
+
+/**
+ * @brief 上电后:若键还按着则锁住短按,松开才认短按(不挡长按分级)
+ * @return 无
+ */
+void user_key_lock_if_held(void);
+
+/**
+ * @brief 按真实毫秒推进状态机(20ms 节拍会循环 dt 次)
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void user_key_scan(uint16_t dt);
+
+/**
+ * @brief 派发按键回调
+ * @return 无
+ */
+void user_key_handle(void);
+
+#endif /* __KEY_MANAGER_H__ */
diff --git a/apps/usr_app/led.c b/apps/usr_app/led.c
new file mode 100644
index 0000000..8fc5823
--- /dev/null
+++ b/apps/usr_app/led.c
@@ -0,0 +1,328 @@
+/******************************************************************************
+ * @file led.c
+ * @brief LED 事件框架实现:只改 ledMask 对应通道,红绿互不抢
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.08.25
+ * @version V1.0.0
+ * @history
+ * - V1.0.0, 2026.08.25, cyWu, 首次发布,实现按通道掩码独立开关的 LED 事件框架
+ ******************************************************************************/
+
+#include "led.h"
+#include
+
+static LED_EventTableItem_t s_led_priority_table[PRIORITY_MAX];
+static LED_EventTableItem_t s_led_event_table[LED_EVENT_MAX];
+static LED_SetState_t s_led_set_state;
+static TIME_Get_t s_time_get;
+
+static uint32_t get_tick_diff(uint32_t meiosis);
+static uint8_t led_lighting_update(LED_Config_t *led);
+#if (BLINK_ENABLE)
+static uint8_t led_blinking_update(LED_Config_t *led);
+#endif
+#if (ALTERNATING_BLINK_ENABLE)
+static uint8_t led_alternating_update(LED_Config_t *led);
+#endif
+
+/**
+ * @brief 注册写灯回调,只对掩码里的通道动手
+ * @param setState 回调,led=通道掩码,state=LED_ON/OFF
+ * @return 无
+ */
+void LED_SetStateInit(LED_SetState_t setState)
+{
+ s_led_set_state = setState;
+}
+
+/**
+ * @brief 注册毫秒时间戳回调
+ * @param getTime 回调
+ * @return 无
+ */
+void SET_TimeGetInit(TIME_Get_t getTime)
+{
+ s_time_get = getTime;
+}
+
+/**
+ * @brief 拷贝产品事件表
+ * @param ledEvent 长度必须为 LED_EVENT_MAX
+ * @return 无
+ */
+void LED_EventTableInit(LED_EventTableItem_t ledEvent[])
+{
+ if (ledEvent == NULL) {
+ return;
+ }
+ memset(s_led_priority_table, 0, sizeof(s_led_priority_table));
+ memset(s_led_event_table, 0, sizeof(s_led_event_table));
+ memcpy(s_led_event_table, ledEvent, sizeof(s_led_event_table));
+}
+
+/**
+ * @brief 把事件挂到对应优先级槽;同优先级会被覆盖
+ * @param ledEvent 事件号
+ * @return 无
+ */
+void LED_EventAdd(LED_Event_t ledEvent)
+{
+ size_t i;
+ LED_EventTableItem_t *item;
+ LED_EventPriority_t prio;
+
+ if ((s_time_get == NULL) || (s_led_set_state == NULL)) {
+ return;
+ }
+ if (ledEvent >= LED_EVENT_MAX) {
+ return;
+ }
+
+ for (i = 0; i < LED_EVENT_MAX; i++) {
+ item = &s_led_event_table[i];
+ if (item->event != ledEvent) {
+ continue;
+ }
+ prio = item->priority;
+ if (prio >= PRIORITY_MAX) {
+ return;
+ }
+ if (s_led_priority_table[prio].event != ledEvent) {
+ item->config.currentTime = s_time_get();
+ if (s_led_priority_table[prio].event != LED_EVENT_NONE) {
+ /* 同优先级换事件:只关掉旧灯里新事件用不到的通道 */
+ s_led_set_state((uint16_t)(s_led_priority_table[prio].config.ledMask
+ ^ item->config.ledMask),
+ LED_OFF);
+ }
+ }
+ s_led_priority_table[prio] = *item;
+ return;
+ }
+}
+
+/**
+ * @brief 清空优先级表(不改 GPIO,由调用方灭灯)
+ * @return 无
+ */
+void LED_EventAllDelete(void)
+{
+ memset(s_led_priority_table, 0, sizeof(s_led_priority_table));
+}
+
+/**
+ * @brief 删除指定事件并关掉它占用的通道
+ * @param ledEvent 事件号
+ * @return 无
+ */
+void LED_EventDelete(LED_Event_t ledEvent)
+{
+ size_t i;
+
+ for (i = 0; i < PRIORITY_MAX; i++) {
+ if (s_led_priority_table[i].event != ledEvent) {
+ continue;
+ }
+ memset(&s_led_priority_table[i], 0, sizeof(LED_EventTableItem_t));
+ if ((s_led_set_state != NULL) && (ledEvent < LED_EVENT_MAX)) {
+ s_led_set_state(s_led_event_table[ledEvent].config.ledMask, LED_OFF);
+ }
+ return;
+ }
+}
+
+/**
+ * @brief 从高优先级往下跑事件
+ * @note handler 返回 1 会拦住更低优先级。红绿要共存时 handler 必须返回 0
+ * @return 无
+ */
+void LED_EventHandle(void)
+{
+ int i;
+ uint8_t ret;
+
+ for (i = (int)PRIORITY_MAX - 1; i >= 0; i--) {
+ if (s_led_priority_table[i].event == LED_EVENT_NONE) {
+ continue;
+ }
+ if (s_led_priority_table[i].ledEventHandler == NULL) {
+ continue;
+ }
+ ret = s_led_priority_table[i].ledEventHandler(&s_led_priority_table[i].config);
+ if (ret) {
+ return;
+ }
+ }
+}
+
+/**
+ * @brief 是否有事件在跑
+ * @return 1=有,0=无
+ */
+uint8_t LED_IsEventActive(void)
+{
+ size_t i;
+
+ for (i = 0; i < PRIORITY_MAX; i++) {
+ if (s_led_priority_table[i].event != LED_EVENT_NONE) {
+ return 1;
+ }
+ }
+ return 0;
+}
+
+/**
+ * @brief 指定事件是否挂在优先级表里
+ * @param ledEvent 事件号
+ * @return 1=在,0=不在
+ */
+uint8_t LED_IsEventExist(LED_Event_t ledEvent)
+{
+ size_t i;
+
+ for (i = 0; i < PRIORITY_MAX; i++) {
+ if (s_led_priority_table[i].event == ledEvent) {
+ return 1;
+ }
+ }
+ return 0;
+}
+
+/**
+ * @brief 按配置刷新一次灯效
+ * @param led 配置,不可为空
+ * @return 1=还在跑,0=次数用完
+ */
+uint8_t LED_Update(LED_Config_t *led)
+{
+ if (led == NULL) {
+ return 0;
+ }
+
+ switch (led->state) {
+ case LED_LIGHT:
+ return led_lighting_update(led);
+#if (BLINK_ENABLE)
+ case LED_BLINKING:
+ return led_blinking_update(led);
+#endif
+#if (ALTERNATING_BLINK_ENABLE)
+ case LED_ALTERNATING_BLINK:
+ return led_alternating_update(led);
+#endif
+ default:
+ return 0;
+ }
+}
+
+/**
+ * @brief 毫秒时间差,兼容溢出
+ * @param meiosis 起点时间戳
+ * @return 差值 ms
+ */
+static uint32_t get_tick_diff(uint32_t meiosis)
+{
+ uint32_t now;
+
+ if (s_time_get == NULL) {
+ return 0;
+ }
+ now = s_time_get();
+ if (now >= meiosis) {
+ return now - meiosis;
+ }
+ return (0xFFFFFFFFu - meiosis) + now;
+}
+
+/**
+ * @brief 常亮;runNum 按秒计,0=一直亮
+ * @param led 配置
+ * @return 1=还在亮,0=到点灭
+ */
+static uint8_t led_lighting_update(LED_Config_t *led)
+{
+ uint16_t sec_count;
+
+ if (s_led_set_state == NULL) {
+ return 0;
+ }
+ sec_count = (uint16_t)(get_tick_diff(led->currentTime) / 1000u);
+ if ((led->runNum > 0) && (sec_count >= led->runNum)) {
+ s_led_set_state(led->ledMask, LED_OFF);
+ return 0;
+ }
+ s_led_set_state(led->ledMask, LED_ON);
+ return 1;
+}
+
+#if (BLINK_ENABLE)
+/**
+ * @brief 闪烁;runNum 按完整周期计,0=一直闪
+ * @param led 配置
+ * @return 1=还在闪,0=到点灭
+ */
+static uint8_t led_blinking_update(LED_Config_t *led)
+{
+ uint32_t run_cycle;
+ uint16_t blink_count;
+ uint32_t sec_left;
+
+ if (s_led_set_state == NULL) {
+ return 0;
+ }
+ run_cycle = led->blinkParams.onTime + led->blinkParams.offTime;
+ if (run_cycle == 0) {
+ return 0;
+ }
+ blink_count = (uint16_t)(get_tick_diff(led->currentTime) / run_cycle);
+ sec_left = get_tick_diff(led->currentTime) % run_cycle;
+ if ((led->runNum > 0) && (blink_count >= led->runNum)) {
+ s_led_set_state(led->ledMask, LED_OFF);
+ return 0;
+ }
+ if (sec_left <= led->blinkParams.onTime) {
+ s_led_set_state(led->ledMask, led->blinkParams.orDer ? LED_OFF : LED_ON);
+ } else {
+ s_led_set_state(led->ledMask, led->blinkParams.orDer ? LED_ON : LED_OFF);
+ }
+ return 1;
+}
+#endif
+
+#if (ALTERNATING_BLINK_ENABLE)
+/**
+ * @brief 两组灯按时间片轮流亮
+ * @param led 配置
+ * @return 1=还在跑,0=到点灭
+ */
+static uint8_t led_alternating_update(LED_Config_t *led)
+{
+ uint16_t blink_count;
+ uint32_t sec_left;
+ uint32_t time_slice;
+ size_t i;
+
+ if (s_led_set_state == NULL) {
+ return 0;
+ }
+ if (led->alternatingBlinkParams.Cycle == 0) {
+ return 0;
+ }
+ blink_count = (uint16_t)(get_tick_diff(led->currentTime)
+ / led->alternatingBlinkParams.Cycle);
+ sec_left = get_tick_diff(led->currentTime) % led->alternatingBlinkParams.Cycle;
+ if ((led->runNum > 0) && (blink_count >= led->runNum)) {
+ s_led_set_state(led->ledMask, LED_OFF);
+ return 0;
+ }
+ time_slice = led->alternatingBlinkParams.Cycle / ALTERNAT_GROUP;
+ s_led_set_state(led->ledMask, LED_OFF);
+ for (i = 0; i < ALTERNAT_GROUP; i++) {
+ if ((sec_left > (time_slice * i)) && (sec_left <= (time_slice * (i + 1)))) {
+ s_led_set_state(led->alternatingBlinkParams.Group[i], LED_ON);
+ break;
+ }
+ }
+ return 1;
+}
+#endif
diff --git a/apps/usr_app/led.h b/apps/usr_app/led.h
new file mode 100644
index 0000000..63343e6
--- /dev/null
+++ b/apps/usr_app/led.h
@@ -0,0 +1,123 @@
+/******************************************************************************
+ * @file led.h
+ * @brief LED 事件框架:按通道掩码独立开关,红绿可同时亮
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.08.25
+ * @version V1.1.0
+ * @history
+ * - V1.0.0, 2026.08.25, cyWu, 首次发布,实现按通道掩码独立开关的 LED 事件框架
+ * - V1.1.0, 2026.08.27, cyWu, 新增 LED_EVENT_BOOT_REJECT(拒绝开机低电闪),
+ * 与运行时 LED_EVENT_EMPTY 参数解耦,各自独立可调
+ ******************************************************************************/
+
+#ifndef __LED_H__
+#define __LED_H__
+
+#include
+#include
+
+/*============================================================================*/
+/* 宏定义 */
+/*============================================================================*/
+
+#define BLINK_ENABLE 1
+#define ALTERNATING_BLINK_ENABLE 1
+#define BREATH_ENABLE 0
+#define RUNNING_ENABLE 0
+#define MARQUEE_ENABLE 0
+
+#define LED_ON 1
+#define LED_OFF 0
+
+#define LED_CHANNEL(user_led) (1u << (user_led))
+
+#define ledPrintf(level, fmt, ...) ((void)0)
+
+/*============================================================================*/
+/* 类型 */
+/*============================================================================*/
+
+typedef enum {
+ LED_LIGHT = 0, /**< 常亮 */
+ LED_BLINKING, /**< 闪烁 */
+ LED_ALTERNATING_BLINK /**< 红绿交替 */
+} LED_State_t;
+
+typedef enum {
+ LED_EVENT_NONE = 0,
+ LED_EVENT_HEAT_OFF, /**< 跟继电器-关:绿灯常亮,红灯灭(低优先级) */
+ LED_EVENT_HEAT_ON, /**< 跟继电器-开:红灯常亮,绿灯灭(低优先级) */
+ LED_EVENT_PAIRING, /**< 按住满5s提示/配对中:绿灯100ms快闪,红灯灭 */
+ LED_EVENT_PAIR_OK, /**< 配对成功:绿灯常亮若干秒,再回跟继电器 */
+ LED_EVENT_PAIR_FAIL, /**< 配对失败:红绿交替若干秒,再回跟继电器 */
+ LED_EVENT_OTA, /**< OTA:红绿交替,直到升级结束/复位(最高) */
+ LED_EVENT_MAX
+} LED_Event_t;
+
+/**
+ * 优先级:高者覆盖低者(handler 返回 1 会拦下更低优先级)。
+ * 加热垫红绿互斥:HEAT 只占最低槽,配对/结果/OTA 事件添加前先全清灯,
+ * 结束后 app_led_run 按继电器实际态自动重新挂 HEAT(无需显式恢复)。
+ */
+typedef enum {
+ PRIORITY_1 = 0, /**< 跟继电器(HEAT_OFF / HEAT_ON) */
+ PRIORITY_2, /**< 配对提示 / 配对结果 */
+ PRIORITY_3, /**< OTA(最高) */
+ PRIORITY_MAX
+} LED_EventPriority_t;
+
+#if (BLINK_ENABLE)
+typedef struct {
+ uint8_t orDer; /**< 1=先灭后亮,0=先亮后灭 */
+ uint32_t onTime; /**< 亮时间 ms */
+ uint32_t offTime; /**< 灭时间 ms */
+} LED_BlinkParams_t;
+#endif
+
+#if (ALTERNATING_BLINK_ENABLE)
+#define ALTERNAT_GROUP 2
+typedef struct {
+ uint16_t Group[ALTERNAT_GROUP]; /**< 交替两组的通道掩码 */
+ uint32_t Cycle; /**< 一整轮交替周期 ms */
+} LED_AlternatingBlinkParams_t;
+#endif
+
+typedef struct {
+ LED_State_t state;
+ uint16_t ledMask; /**< 本事件只动这些通道,其它灯不受影响 */
+ uint16_t runNum; /**< 0=无限;常亮按秒计,闪烁按周期计 */
+ uint32_t currentTime;
+#if (BLINK_ENABLE)
+ LED_BlinkParams_t blinkParams;
+#endif
+#if (ALTERNATING_BLINK_ENABLE)
+ LED_AlternatingBlinkParams_t alternatingBlinkParams;
+#endif
+} LED_Config_t;
+
+typedef struct {
+ LED_Event_t event;
+ LED_EventPriority_t priority;
+ LED_Config_t config;
+ uint8_t (*ledEventHandler)(LED_Config_t *config);
+} LED_EventTableItem_t;
+
+typedef void (*LED_SetState_t)(uint16_t led, uint8_t state);
+typedef uint32_t (*TIME_Get_t)(void);
+
+/*============================================================================*/
+/* 外部接口 */
+/*============================================================================*/
+
+void LED_SetStateInit(LED_SetState_t setState);
+void SET_TimeGetInit(TIME_Get_t getTime);
+void LED_EventTableInit(LED_EventTableItem_t ledEvent[]);
+void LED_EventAdd(LED_Event_t ledEvent);
+void LED_EventDelete(LED_Event_t ledEvent);
+void LED_EventAllDelete(void);
+void LED_EventHandle(void);
+uint8_t LED_IsEventActive(void);
+uint8_t LED_IsEventExist(LED_Event_t ledEvent);
+uint8_t LED_Update(LED_Config_t *led);
+
+#endif /* __LED_H__ */
diff --git a/apps/usr_app/led_manager.c b/apps/usr_app/led_manager.c
new file mode 100644
index 0000000..ba13867
--- /dev/null
+++ b/apps/usr_app/led_manager.c
@@ -0,0 +1,188 @@
+/******************************************************************************
+ * @file led_manager.c
+ * @brief CBR1 红绿 LED 事件表:跟继电器 / 配对 / OTA(规格书第 6 章)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P0 Bring-up(由门夹双通道事件表裁剪:删电池/充电/模式事件)
+ *
+ * @note 加热垫红绿互斥(一次只一种图案),高事件(配对/结果/OTA)添加前
+ * app_led 层先全清事件+灭灯;事件结束自删后 app_led_run 按继电器
+ * 实际态自动重新挂 HEAT 事件。
+ ******************************************************************************/
+
+#include "led_manager.h"
+#include "system/includes.h"
+#include "bsp_hw.h"
+#include "board_pin.h"
+
+static uint8_t led_heat_handler(LED_Config_t *led_config);
+static uint8_t led_pairing_handler(LED_Config_t *led_config);
+static uint8_t led_pair_ok_handler(LED_Config_t *led_config);
+static uint8_t led_pair_fail_handler(LED_Config_t *led_config);
+static uint8_t led_ota_handler(LED_Config_t *led_config);
+
+/**
+ * @brief 只改掩码里的通道,其它灯保持原样
+ * @param led 通道掩码
+ * @param state LED_ON / LED_OFF
+ * @return 无
+ */
+static void set_led_multi(uint16_t led, uint8_t state)
+{
+ if (led & LED_CHANNEL(LED_CH_RED)) {
+ bsp_led_red_set(state);
+ }
+ if (led & LED_CHANNEL(LED_CH_GREEN)) {
+ bsp_led_green_set(state);
+ }
+}
+
+/**
+ * @brief 跟继电器常亮:返回 1 占住最低槽
+ * @param led_config 配置
+ * @return 1
+ */
+static uint8_t led_heat_handler(LED_Config_t *led_config)
+{
+ (void)LED_Update(led_config);
+ return 1;
+}
+
+/**
+ * @brief 配对绿快闪(无限):返回 1 拦下更低优先级,配对期间只此一灯
+ * @param led_config 配置
+ * @return 1
+ */
+static uint8_t led_pairing_handler(LED_Config_t *led_config)
+{
+ (void)LED_Update(led_config);
+ return 1;
+}
+
+/**
+ * @brief 配对成功常绿若干秒:到点自删,灯交回 app_led_run
+ * @param led_config 配置
+ * @return 1=还在亮,0=已自删
+ */
+static uint8_t led_pair_ok_handler(LED_Config_t *led_config)
+{
+ if (LED_Update(led_config) == 0) {
+ LED_EventDelete(LED_EVENT_PAIR_OK);
+ return 0;
+ }
+ return 1;
+}
+
+/**
+ * @brief 配对失败红绿交替若干秒:到点自删
+ * @param led_config 配置
+ * @return 1=还在跑,0=已自删
+ */
+static uint8_t led_pair_fail_handler(LED_Config_t *led_config)
+{
+ if (LED_Update(led_config) == 0) {
+ LED_EventDelete(LED_EVENT_PAIR_FAIL);
+ return 0;
+ }
+ return 1;
+}
+
+/**
+ * @brief OTA 红绿交替(无限):最高优先级,拦下其它一切
+ * @param led_config 配置
+ * @return 1
+ */
+static uint8_t led_ota_handler(LED_Config_t *led_config)
+{
+ (void)LED_Update(led_config);
+ return 1;
+}
+
+/**
+ * @brief 注册回调并装载 CBR1 事件表(GPIO 已在 bsp_hw_init)
+ * @note 事件枚举数值 = 事件表下标(led.c LED_EventDelete 依赖)
+ * @return 无
+ */
+void user_led_init(void)
+{
+ static LED_EventTableItem_t led_event_category[LED_EVENT_MAX] = {
+ [LED_EVENT_HEAT_OFF] = {
+ /* 加热关(实际断开):绿灯常亮,红灯灭 */
+ .event = LED_EVENT_HEAT_OFF,
+ .priority = PRIORITY_1,
+ .config.ledMask = LED_CHANNEL(LED_CH_GREEN),
+ .config.state = LED_LIGHT,
+ .config.runNum = 0, /* 0=一直亮 */
+ .ledEventHandler = led_heat_handler,
+ },
+ [LED_EVENT_HEAT_ON] = {
+ /* 加热开(实际吸合):红灯常亮,绿灯灭 */
+ .event = LED_EVENT_HEAT_ON,
+ .priority = PRIORITY_1,
+ .config.ledMask = LED_CHANNEL(LED_CH_RED),
+ .config.state = LED_LIGHT,
+ .config.runNum = 0,
+ .ledEventHandler = led_heat_handler,
+ },
+ [LED_EVENT_PAIRING] = {
+ /* 按住满5s提示 / 配对中:绿灯 100ms 快闪(红灯由 app_led 层保证灭) */
+ .event = LED_EVENT_PAIRING,
+ .priority = PRIORITY_2,
+ .config.ledMask = LED_CHANNEL(LED_CH_GREEN),
+ .config.state = LED_BLINKING,
+ .config.blinkParams.orDer = 0,
+ .config.blinkParams.onTime = BOARD_LED_FAST_ON_MS,
+ .config.blinkParams.offTime = BOARD_LED_FAST_OFF_MS,
+ .config.runNum = 0,
+ .ledEventHandler = led_pairing_handler,
+ },
+ [LED_EVENT_PAIR_OK] = {
+ /* 配对成功:绿灯常亮 3s 后回跟继电器 */
+ .event = LED_EVENT_PAIR_OK,
+ .priority = PRIORITY_2,
+ .config.ledMask = LED_CHANNEL(LED_CH_GREEN),
+ .config.state = LED_LIGHT,
+ .config.runNum = (uint16_t)((BOARD_LED_PAIR_OK_MS + 999u) / 1000u),
+ .ledEventHandler = led_pair_ok_handler,
+ },
+ [LED_EVENT_PAIR_FAIL] = {
+ /* 配对失败:红绿交替 3s 后回跟继电器(半周期 BOARD_LED_ALT_MS) */
+ .event = LED_EVENT_PAIR_FAIL,
+ .priority = PRIORITY_2,
+ .config.ledMask = (uint16_t)(LED_CHANNEL(LED_CH_RED) | LED_CHANNEL(LED_CH_GREEN)),
+ .config.state = LED_ALTERNATING_BLINK,
+ .config.alternatingBlinkParams.Group[0] = LED_CHANNEL(LED_CH_RED),
+ .config.alternatingBlinkParams.Group[1] = LED_CHANNEL(LED_CH_GREEN),
+ .config.alternatingBlinkParams.Cycle = (uint32_t)(BOARD_LED_ALT_MS * 2u),
+ .config.runNum = (uint16_t)((BOARD_LED_PAIR_FAIL_MS + (BOARD_LED_ALT_MS * 2u) - 1u)
+ / (BOARD_LED_ALT_MS * 2u)),
+ .ledEventHandler = led_pair_fail_handler,
+ },
+ [LED_EVENT_OTA] = {
+ /* OTA:红绿交替一直闪(200ms 半周期),直到升级结束/复位 */
+ .event = LED_EVENT_OTA,
+ .priority = PRIORITY_3,
+ .config.ledMask = (uint16_t)(LED_CHANNEL(LED_CH_RED) | LED_CHANNEL(LED_CH_GREEN)),
+ .config.state = LED_ALTERNATING_BLINK,
+ .config.alternatingBlinkParams.Group[0] = LED_CHANNEL(LED_CH_RED),
+ .config.alternatingBlinkParams.Group[1] = LED_CHANNEL(LED_CH_GREEN),
+ .config.alternatingBlinkParams.Cycle = (uint32_t)(BOARD_LED_ALT_MS * 2u),
+ .config.runNum = 0, /* 0=无限 */
+ .ledEventHandler = led_ota_handler,
+ },
+ };
+
+ LED_SetStateInit(set_led_multi);
+ SET_TimeGetInit((TIME_Get_t)timer_get_ms);
+ LED_EventTableInit(led_event_category);
+ bsp_led_all_off();
+}
+
+/**
+ * @brief 跑一轮 LED 事件
+ * @return 无
+ */
+void user_led_handle(void)
+{
+ LED_EventHandle();
+}
diff --git a/apps/usr_app/led_manager.h b/apps/usr_app/led_manager.h
new file mode 100644
index 0000000..0b569de
--- /dev/null
+++ b/apps/usr_app/led_manager.h
@@ -0,0 +1,32 @@
+/******************************************************************************
+ * @file led_manager.h
+ * @brief CBR1 红绿 LED 通道与事件表(跟继电器/配对/OTA)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.0.0,P0 Bring-up(门夹双通道框架裁剪:红 PA7 / 绿 PA8)
+ ******************************************************************************/
+
+#ifndef __LED_MANAGER_H__
+#define __LED_MANAGER_H__
+
+#include "led.h"
+
+typedef enum {
+ LED_CH_RED = 0,
+ LED_CH_GREEN,
+ LED_CH_MAX
+} LedChannel_t;
+
+/**
+ * @brief 注册 GPIO 回调并装载 CBR1 事件表
+ * @return 无
+ */
+void user_led_init(void);
+
+/**
+ * @brief 跑一轮 LED 事件
+ * @return 无
+ */
+void user_led_handle(void);
+
+#endif /* __LED_MANAGER_H__ */
diff --git a/apps/usr_hw_demo/usr_hw_gpio_int.c b/apps/usr_hw_demo/usr_hw_gpio_int.c
new file mode 100644
index 0000000..b3f6d03
--- /dev/null
+++ b/apps/usr_hw_demo/usr_hw_gpio_int.c
@@ -0,0 +1,107 @@
+#include "asm/mcpwm.h"
+#include "asm/clock.h"
+#include "asm/gpio.h"
+
+
+
+/******************************* 外部GPIO引脚中断参考代码 ***************************/
+
+void (*io_isr_cbfun)(u8 index) = NULL;
+void set_io_ext_interrupt_cbfun(void (*cbfun)(u8 index))
+{
+ io_isr_cbfun = cbfun;
+}
+___interrupt
+void io_interrupt(void)
+{
+ u32 io_index = -1;
+ if (JL_MCPWM->CH0_CON1 & BIT(15)) {
+ JL_MCPWM->CH0_CON1 |= BIT(14);
+ io_index = 0;
+ } else if (JL_MCPWM->CH1_CON1 & BIT(15)) {
+ JL_MCPWM->CH1_CON1 |= BIT(14);
+ io_index = 1;
+ } else if (JL_MCPWM->CH2_CON1 & BIT(15)) {
+ JL_MCPWM->CH2_CON1 |= BIT(14);
+ io_index = 2;
+ } else if (JL_MCPWM->CH3_CON1 & BIT(15)) {
+ JL_MCPWM->CH3_CON1 |= BIT(14);
+ io_index = 3;
+ } else {
+ return;
+ }
+ if (io_isr_cbfun) {
+ io_isr_cbfun(io_index);
+ }
+}
+void io_ext_interrupt_init(u8 index, u8 port, u8 trigger_mode)
+{
+ if (port > IO_PORT_MAX) {
+ return;
+ }
+ gpio_set_die(port, 1);
+ gpio_set_direction(port, 1);
+ if (trigger_mode) {
+ gpio_set_pull_up(port, 1);
+ gpio_set_pull_down(port, 0);
+ JL_MCPWM->FPIN_CON &= ~BIT(16 + index);//下降沿触发
+ } else {
+ gpio_set_pull_up(port, 0);
+ gpio_set_pull_down(port, 1);
+ JL_MCPWM->FPIN_CON |= BIT(16 + index);//上升沿触发
+ }
+ JL_MCPWM->FPIN_CON |= BIT(8 + index);//开启滤波
+ JL_MCPWM->FPIN_CON |= (0b111111 << 0); //滤波时间 = 16 * 64 / hsb_clk (单位:s)
+
+ gpio_ich_sel_iutput_signal(port, INPUT_CH_SIGNAL_MC_FPIN0 + index, INPUT_CH_TYPE_GP_ICH);
+ request_irq(IRQ_MCPWM_CHX_IDX, 3, io_interrupt, 0); //注册中断函数
+ PWM_CH_REG *pwm_reg = get_pwm_ch_reg(index);
+ pwm_reg->ch_con1 = BIT(14) | BIT(11) | BIT(4) | (index << 0);
+ JL_MCPWM->MCPWM_CON0 |= BIT(index);
+#if 0
+ printf("JL_MCPWM->CH%d_CON1 = 0x%x\n", index, pwm_reg->ch_con1);
+ printf("JL_MCPWM->FPIN_CON = 0x%x\n", JL_MCPWM->FPIN_CON);
+ printf("JL_MCPWM->MCPWM_CON0 = 0x%x\n", JL_MCPWM->MCPWM_CON0);
+ printf("JL_IOMAP->ICH_CON0 = 0x%x\n", JL_IOMAP->ICH_CON0);
+ printf("JL_IOMAP->ICH_CON1 = 0x%x\n", JL_IOMAP->ICH_CON1);
+ printf("JL_IOMAP->ICH_CON2 = 0x%x\n", JL_IOMAP->ICH_CON2);
+ printf("JL_IOMAP->ICH_CON3 = 0x%x\n", JL_IOMAP->ICH_CON3);
+ printf("JL_IMAP->FI_GP_ICH0 = %d\n", JL_IMAP->FI_GP_ICH0);
+ printf("JL_IMAP->FI_GP_ICH1 = %d\n", JL_IMAP->FI_GP_ICH1);
+ printf("JL_IMAP->FI_GP_ICH2 = %d\n", JL_IMAP->FI_GP_ICH2);
+ printf("JL_IMAP->FI_GP_ICH3 = %d\n", JL_IMAP->FI_GP_ICH3);
+ printf("JL_IMAP->FI_GP_ICH4 = %d\n", JL_IMAP->FI_GP_ICH4);
+ printf("JL_IMAP->FI_GP_ICH5 = %d\n", JL_IMAP->FI_GP_ICH5);
+ printf("JL_IMAP->FI_GP_ICH6 = %d\n", JL_IMAP->FI_GP_ICH6);
+ printf("JL_IMAP->FI_GP_ICH7 = %d\n", JL_IMAP->FI_GP_ICH7);
+ printf("JL_IMAP->FI_GP_ICH8 = %d\n", JL_IMAP->FI_GP_ICH8);
+ printf("JL_IMAP->FI_GP_ICH9 = %d\n", JL_IMAP->FI_GP_ICH9);
+#endif // 0
+}
+void io_ext_interrupt_close(u8 index, u8 port)
+{
+ if (port > IO_PORT_MAX) {
+ return;
+ }
+ gpio_set_die(port, 0);
+ gpio_set_direction(port, 1);
+ gpio_set_pull_up(port, 0);
+ gpio_set_pull_down(port, 0);
+ gpio_ich_disable_iutput_signal(port, INPUT_CH_SIGNAL_MC_FPIN0 + index, INPUT_CH_TYPE_GP_ICH);
+ PWM_CH_REG *pwm_reg = get_pwm_ch_reg(index);
+ pwm_reg->ch_con1 = BIT(14);
+}
+
+///////////// 使用举例如下 //////////////////
+void my_io_isr_cbfun(u8 index)
+{
+ printf("io index --> %d Hello world !\n", index);
+}
+void io_ext_interrupt_test(void)
+{
+ set_io_ext_interrupt_cbfun(my_io_isr_cbfun);
+ io_ext_interrupt_init(0, IO_PORTA_04, 1);
+ io_ext_interrupt_init(1, IO_PORTB_01, 1);
+ io_ext_interrupt_init(2, IO_PORTC_02, 1);
+ io_ext_interrupt_init(3, IO_PORTG_01, 1);
+}
diff --git a/apps/usr_hw_demo/usr_hw_pwm.c b/apps/usr_hw_demo/usr_hw_pwm.c
new file mode 100644
index 0000000..aec953d
--- /dev/null
+++ b/apps/usr_hw_demo/usr_hw_pwm.c
@@ -0,0 +1,405 @@
+#include "asm/mcpwm.h"
+#include "asm/clock.h"
+#include "asm/gpio.h"
+
+#define MCPWM_DEBUG_ENABLE 1
+#if MCPWM_DEBUG_ENABLE
+#define mcpwm_debug(fmt, ...) printf("[MCPWM] "fmt, ##__VA_ARGS__)
+#else
+#define mcpwm_debug(...)
+#endif
+
+#define MCPWM_CLK clk_get("mcpwm")
+
+
+
+/********************************硬件PWM相关函数**************************/
+
+
+PWM_TIMER_REG *get_pwm_timer_reg(pwm_ch_num_type index)
+{
+ PWM_TIMER_REG *reg = NULL;
+ switch (index) {
+ case pwm_ch0:
+ reg = (PWM_TIMER_REG *)(&(JL_MCPWM->TMR0_CON));
+ break;
+ case pwm_ch1:
+ reg = (PWM_TIMER_REG *)(&(JL_MCPWM->TMR1_CON));
+ break;
+ case pwm_ch2:
+ reg = (PWM_TIMER_REG *)(&(JL_MCPWM->TMR2_CON));
+ break;
+ case pwm_ch3:
+ reg = (PWM_TIMER_REG *)(&(JL_MCPWM->TMR3_CON));
+ break;
+ default:
+ break;
+ }
+ return reg;
+}
+
+PWM_CH_REG *get_pwm_ch_reg(pwm_ch_num_type index)
+{
+ PWM_CH_REG *reg = NULL;
+ switch (index) {
+ case pwm_ch0:
+ reg = (PWM_CH_REG *)(&(JL_MCPWM->CH0_CON0));
+ break;
+ case pwm_ch1:
+ reg = (PWM_CH_REG *)(&(JL_MCPWM->CH1_CON0));
+ break;
+ case pwm_ch2:
+ reg = (PWM_CH_REG *)(&(JL_MCPWM->CH2_CON0));
+ break;
+ case pwm_ch3:
+ reg = (PWM_CH_REG *)(&(JL_MCPWM->CH3_CON0));
+ break;
+ default:
+ break;
+ }
+ return reg;
+}
+
+static u8 clk_in_io = IO_PORTC_02;
+/*
+ * @brief 更改MCPWM的频率
+ * @parm frequency 频率
+ */
+void mcpwm_set_frequency(pwm_ch_num_type ch, pwm_aligned_mode_type align, u32 frequency)
+{
+ PWM_TIMER_REG *reg = get_pwm_timer_reg(ch);
+ if (reg == NULL) {
+ return;
+ }
+
+ reg->tmr_con = 0;
+ reg->tmr_cnt = 0;
+ reg->tmr_pr = 0;
+
+ u32 i = 0;
+ u32 mcpwm_div_clk = 0;
+ u32 mcpwm_tmr_pr = 0;
+ u32 mcpwm_fre_min = 0;
+ u32 clk = MCPWM_CLK;
+ for (i = 0; i < 16; i++) {
+ mcpwm_fre_min = clk / (65536 * (1 << i));
+ if ((frequency >= mcpwm_fre_min) || (i == 15)) {
+ break;
+ }
+ }
+ reg->tmr_con |= (i << 3); //div 2^i
+ mcpwm_div_clk = clk / (1 << i);
+ if (frequency == 0) {
+ mcpwm_tmr_pr = 0;
+ } else {
+ if (align == pwm_center_aligned) { //中心对齐
+ mcpwm_tmr_pr = mcpwm_div_clk / (frequency * 2) - 1;
+ } else {
+ mcpwm_tmr_pr = mcpwm_div_clk / frequency - 1;
+ }
+ }
+ reg->tmr_pr = mcpwm_tmr_pr;
+ //timer mode
+ if (align == pwm_center_aligned) { //中心对齐
+ reg->tmr_con |= 0b10;
+ } else {
+ reg->tmr_con |= 0b01;
+ }
+}
+
+static u32 old_mcpwm_clk = 0;
+static void clock_critical_enter(void)
+{
+ old_mcpwm_clk = clk_get("mcpwm");
+}
+static void clock_critical_exit(void)
+{
+ u32 new_mcpwm_clk = clk_get("mcpwm");
+ if (new_mcpwm_clk == old_mcpwm_clk) {
+ return;
+ }
+ PWM_CH_REG *pwm_reg = NULL;
+ PWM_TIMER_REG *timer_reg = NULL;
+ for (u8 ch = 0; ch < 4; ch++) {
+ if (JL_MCPWM->MCPWM_CON0 & BIT(ch + 8)) {
+ pwm_reg = get_pwm_ch_reg(ch);
+ timer_reg = get_pwm_timer_reg(ch);
+ if (new_mcpwm_clk > old_mcpwm_clk) {
+ timer_reg->tmr_pr = timer_reg->tmr_pr * (new_mcpwm_clk / old_mcpwm_clk);
+ pwm_reg->ch_cmpl = pwm_reg->ch_cmpl * (new_mcpwm_clk / old_mcpwm_clk);
+ } else {
+ timer_reg->tmr_pr = timer_reg->tmr_pr / (old_mcpwm_clk / new_mcpwm_clk);
+ pwm_reg->ch_cmpl = pwm_reg->ch_cmpl / (old_mcpwm_clk / new_mcpwm_clk);
+ }
+ pwm_reg->ch_cmph = pwm_reg->ch_cmpl;
+ }
+ }
+}
+CLOCK_CRITICAL_HANDLE_REG(mcpwm, clock_critical_enter, clock_critical_exit)
+
+/*
+ * @brief 设置一个通道的占空比
+ * @parm pwm_ch_num 通道号:pwm_ch0,pwm_ch1,pwm_ch2
+ * @parm duty 占空比:0 ~ 10000 对应 0% ~ 100%
+ */
+void mcpwm_set_duty(pwm_ch_num_type pwm_ch, u16 duty)
+{
+ PWM_TIMER_REG *timer_reg = get_pwm_timer_reg(pwm_ch);
+ PWM_CH_REG *pwm_reg = get_pwm_ch_reg(pwm_ch);
+
+ if (pwm_reg && timer_reg) {
+ pwm_reg->ch_cmpl = timer_reg->tmr_pr * duty / 10000;
+ pwm_reg->ch_cmph = pwm_reg->ch_cmpl;
+ timer_reg->tmr_cnt = 0;
+ timer_reg->tmr_con |= 0b01;
+ if (duty == 10000) {
+ timer_reg->tmr_cnt = 0;
+ timer_reg->tmr_con &= ~(0b11);
+ } else if (duty == 0) {
+ timer_reg->tmr_cnt = pwm_reg->ch_cmpl;
+ timer_reg->tmr_con &= ~(0b11);
+ }
+ }
+}
+
+/*
+ * @brief 打开或者关闭一个时基
+ * @parm pwm_ch_num 通道号:pwm_ch0,pwm_ch1,pwm_ch2
+ * @parm enable 1:打开 0:关闭
+ */
+void mctimer_ch_open_or_close(pwm_ch_num_type pwm_ch, u8 enable)
+{
+ if (pwm_ch > pwm_ch_max) {
+ return;
+ }
+ if (enable) {
+ JL_MCPWM->MCPWM_CON0 |= BIT(pwm_ch + 8); //TnEN
+ } else {
+ JL_MCPWM->MCPWM_CON0 &= (~BIT(pwm_ch + 8)); //TnDIS
+ }
+}
+
+
+/*
+ * @brief 打开或者关闭一个通道
+ * @parm pwm_ch_num 通道号:pwm_ch0,pwm_ch1,pwm_ch2
+ * @parm enable 1:打开 0:关闭
+ */
+void mcpwm_ch_open_or_close(pwm_ch_num_type pwm_ch, u8 enable)
+{
+ if (pwm_ch >= pwm_ch_max) {
+ return;
+ }
+ if (enable) {
+ JL_MCPWM->MCPWM_CON0 |= BIT(pwm_ch); //PWMnEN
+ } else {
+ JL_MCPWM->MCPWM_CON0 &= (~BIT(pwm_ch)); //PWMnDIS
+ }
+}
+
+/*
+ * @brief 关闭MCPWM模块
+ */
+void mcpwm_open(pwm_ch_num_type pwm_ch)
+{
+ if (pwm_ch >= pwm_ch_max) {
+ return;
+ }
+ PWM_CH_REG *pwm_reg = get_pwm_ch_reg(pwm_ch);
+ pwm_reg->ch_con1 &= ~(0b111 << 8);
+ pwm_reg->ch_con1 |= (pwm_ch << 8); //sel mctmr
+ mcpwm_ch_open_or_close(pwm_ch, 1);
+ mctimer_ch_open_or_close(pwm_ch, 1);
+}
+
+
+/*
+ * @brief 关闭MCPWM模块
+ */
+void mcpwm_close(pwm_ch_num_type pwm_ch)
+{
+ mctimer_ch_open_or_close(pwm_ch, 0);
+ mcpwm_ch_open_or_close(pwm_ch, 0);
+}
+
+
+void log_pwm_info(pwm_ch_num_type pwm_ch)
+{
+ PWM_CH_REG *pwm_reg = get_pwm_ch_reg(pwm_ch);
+ PWM_TIMER_REG *timer_reg = get_pwm_timer_reg(pwm_ch);
+ mcpwm_debug("tmr%d con0 = 0x%x", pwm_ch, timer_reg->tmr_con);
+ mcpwm_debug("tmr%d pr = 0x%x", pwm_ch, timer_reg->tmr_pr);
+ mcpwm_debug("pwm ch%d_con0 = 0x%x", pwm_ch, pwm_reg->ch_con0);
+ mcpwm_debug("pwm ch%d_con1 = 0x%x", pwm_ch, pwm_reg->ch_con1);
+ mcpwm_debug("pwm ch%d_cmph = 0x%x, pwm ch%d_cmpl = 0x%x", pwm_ch, pwm_reg->ch_cmph, pwm_ch, pwm_reg->ch_cmpl);
+ mcpwm_debug("MCPWM_CON0 = 0x%x", JL_MCPWM->MCPWM_CON0);
+ mcpwm_debug("mcpwm clk = %d", MCPWM_CLK);
+}
+
+void mcpwm_init(struct pwm_platform_data *arg)
+{
+ //set output IO
+ PWM_CH_REG *pwm_reg = get_pwm_ch_reg(arg->pwm_ch_num);
+ if (pwm_reg == NULL) {
+ return;
+ }
+
+ //set mctimer frequency
+ mcpwm_set_frequency(arg->pwm_ch_num, arg->pwm_aligned_mode, arg->frequency);
+
+ pwm_reg->ch_con0 = 0;
+
+ if (arg->complementary_en) { //是否互补
+ pwm_reg->ch_con0 &= ~(BIT(5) | BIT(4));
+ pwm_reg->ch_con0 |= BIT(5); //L_INV
+ } else {
+ pwm_reg->ch_con0 &= ~(BIT(5) | BIT(4));
+ }
+
+ mcpwm_open(arg->pwm_ch_num); //mcpwm enable
+ //set duty
+ mcpwm_set_duty(arg->pwm_ch_num, arg->duty);
+
+ //H:
+ if (arg->h_pin < IO_MAX_NUM) { //任意引脚
+ pwm_reg->ch_con0 |= BIT(2); //H_EN
+ gpio_och_sel_output_signal(arg->h_pin, OUTPUT_CH_SIGNAL_MC_PWM0_H + 2 * arg->pwm_ch_num);
+ gpio_set_direction(arg->h_pin, 0); //DIR output
+ }
+ //L:
+ if (arg->l_pin < IO_MAX_NUM) { //任意引脚
+ pwm_reg->ch_con0 |= BIT(3); //L_EN
+ gpio_och_sel_output_signal(arg->l_pin, OUTPUT_CH_SIGNAL_MC_PWM0_L + 2 * arg->pwm_ch_num);
+ gpio_set_direction(arg->l_pin, 0); //DIR output
+ }
+
+ log_pwm_info(arg->pwm_ch_num);
+}
+
+#define PWM_CH0_ENABLE 0
+#define PWM_CH1_ENABLE 0
+#define PWM_CH2_ENABLE 0
+#define PWM_CH3_ENABLE 0
+void usr_mcpwm_init(void)
+{
+ struct pwm_platform_data pwm_p_data;
+
+#if PWM_CH0_ENABLE
+ pwm_p_data.pwm_aligned_mode = pwm_edge_aligned; //边沿对齐
+ pwm_p_data.pwm_ch_num = pwm_ch0; //通道号
+ pwm_p_data.frequency = 500; //1KHz
+ pwm_p_data.duty = 0; //占空比50%
+ pwm_p_data.h_pin = IO_PORTA_06; //任意引脚
+ pwm_p_data.l_pin = -1; //任意引脚,不需要就填-1
+ pwm_p_data.complementary_en = 0; //两个引脚的波形, 0: 同步, 1: 互补,互补波形的占空比体现在H引脚上
+ mcpwm_init(&pwm_p_data);
+#endif
+#if PWM_CH1_ENABLE
+ pwm_p_data.pwm_aligned_mode = pwm_edge_aligned; //边沿对齐
+ pwm_p_data.pwm_ch_num = pwm_ch1; //通道号
+ pwm_p_data.frequency = 500; //1KHz
+ pwm_p_data.duty = 0; //占空比25%
+ pwm_p_data.h_pin = IO_PORTA_08; //任意引脚
+ pwm_p_data.l_pin = -1; //任意引脚,不需要就填-1
+ pwm_p_data.complementary_en = 0; //两个引脚的波形, 0: 同步, 1: 互补,互补波形的占空比体现在H引脚上
+ mcpwm_init(&pwm_p_data);
+#endif
+#if PWM_CH2_ENABLE
+ pwm_p_data.pwm_aligned_mode = pwm_edge_aligned; //边沿对齐
+ pwm_p_data.pwm_ch_num = pwm_ch2; //通道号
+ pwm_p_data.frequency = 10000; //10KHz
+ pwm_p_data.duty = 5000; //占空比50%
+ pwm_p_data.h_pin = IO_PORTA_03; //任意引脚
+ pwm_p_data.l_pin = -1; //任意引脚,不需要就填-1
+ mcpwm_init(&pwm_p_data);
+#endif
+#if PWM_CH3_ENABLE
+ pwm_p_data.pwm_aligned_mode = pwm_edge_aligned; //边沿对齐
+ pwm_p_data.pwm_ch_num = pwm_ch3; //通道号
+ pwm_p_data.frequency = 10000; //10KHz
+ pwm_p_data.duty = 7500; //占空比75%
+ pwm_p_data.h_pin = IO_PORTA_04; //任意引脚
+ pwm_p_data.l_pin = IO_PORTA_05; //任意引脚,不需要就填-1
+ pwm_p_data.complementary_en = 0; //两个引脚的波形, 0: 同步, 1: 互补,互补波形的占空比体现在H引脚上
+ mcpwm_init(&pwm_p_data);
+#endif
+
+
+}
+
+
+/*
+ extern void clk_out(u8 gpio, enum CLK_OUT_SOURCE clk);
+ clk_out(IO_PORTA_07, LSB_CLK_OUT);
+ extern void wdt_clear();
+*/
+
+
+
+/******************************* 用timer做pwm的参考代码 ***************************/
+
+/**
+ * @param JL_TIMERx : JL_TIMER0/1/2/3
+ * @param pwm_io : JL_PORTA_01, JL_PORTB_02,,,等等,支持任意普通IO
+ * @param fre : 频率,单位Hz
+ * @param duty : 初始占空比,0~10000对应0~100%
+ */
+void timer_pwm_init(JL_TIMER_TypeDef *JL_TIMERx, u32 pwm_io, u32 fre, u32 duty)
+{
+ switch ((u32)JL_TIMERx) {
+ case (u32)JL_TIMER0 :
+ gpio_och_sel_output_signal(pwm_io, OUTPUT_CH_SIGNAL_TIMER0_PWM);
+ break;
+ case (u32)JL_TIMER1 :
+ gpio_och_sel_output_signal(pwm_io, OUTPUT_CH_SIGNAL_TIMER1_PWM);
+ break;
+ case (u32)JL_TIMER2 :
+ gpio_och_sel_output_signal(pwm_io, OUTPUT_CH_SIGNAL_TIMER2_PWM);
+ break;
+ case (u32)JL_TIMER3 :
+ bit_clr_ie(IRQ_TIME3_IDX);
+ gpio_och_sel_output_signal(pwm_io, OUTPUT_CH_SIGNAL_TIMER3_PWM);
+ break;
+ case (u32)JL_TIMER4 :
+ gpio_och_sel_output_signal(pwm_io, OUTPUT_CH_SIGNAL_TIMER4_PWM);
+ break;
+ case (u32)JL_TIMER5 :
+ gpio_och_sel_output_signal(pwm_io, OUTPUT_CH_SIGNAL_TIMER5_PWM);
+ break;
+ default:
+ return;
+ }
+ u32 u_clk = 24000000;
+ //初始化timer
+ JL_TIMERx->CON = 0;
+ JL_TIMERx->CON |= (0b110 << 10); //时钟源选择STD_24M时钟源
+ JL_TIMERx->CON |= (0b0001 << 4); //时钟源再4分频
+ JL_TIMERx->CNT = 0; //清计数值
+ JL_TIMERx->PRD = u_clk / (4 * fre); //设置周期
+ JL_TIMERx->PWM = (JL_TIMERx->PRD * duty) / 10000; //设置初始占空比,0~10000对应0~100%
+ JL_TIMERx->CON |= (0b01 << 0); //计数模式
+ JL_TIMERx->CON |= BIT(8); //PWM使能
+ //设置引脚状态
+ gpio_set_die(pwm_io, 1);
+ gpio_set_pull_up(pwm_io, 0);
+ gpio_set_pull_down(pwm_io, 0);
+ gpio_set_direction(pwm_io, 0);
+
+ printf("JL_TIMERx->PRD = 0x%x\n", JL_TIMERx->PRD);
+ printf("JL_TIMERx->CON = 0x%x\n", JL_TIMERx->CON);
+}
+
+
+void set_timer_pwm_duty(JL_TIMER_TypeDef *JL_TIMERx, u32 duty)
+{
+ JL_TIMERx->PWM = (JL_TIMERx->PRD * duty) / 10000; //0~10000对应0~100%
+}
+
+void timer_pwm_test(void)///GPIO随意映射
+{
+ timer_pwm_init(JL_TIMER2, IO_PORTC_03, 10000, 7000);
+ timer_pwm_init(JL_TIMER3, IO_PORTG_04, 1000, 2000);
+}
+
+
+
diff --git a/apps/usr_hw_demo/usr_hw_rtc.c b/apps/usr_hw_demo/usr_hw_rtc.c
new file mode 100644
index 0000000..085b4d3
--- /dev/null
+++ b/apps/usr_hw_demo/usr_hw_rtc.c
@@ -0,0 +1,187 @@
+#include "app_config.h"
+#include "key_event_deal.h"
+#include "system/includes.h"
+#include "tone_player.h"
+#include "app_task.h"
+#include "tone_player.h"
+#include "media/includes.h"
+#include "system/sys_time.h"
+//#include "alarm.h"
+#include "clock_cfg.h"
+#include "rtc_app.h"
+
+/*************************************************************
+ 此文件函数主要是rtc
+
+**************************************************************/
+#include "usr_jb_global.h"
+
+#if TCFG_APP_RTC_EN
+
+
+
+#define LOG_TAG_CONST APP_RTC
+#define LOG_TAG "[APP_RTC]"
+#define LOG_ERROR_ENABLE
+#define LOG_DEBUG_ENABLE
+#define LOG_INFO_ENABLE
+/* #define LOG_DUMP_ENABLE */
+#define LOG_CLI_ENABLE
+#include "debug.h"
+
+struct rtc_opr {
+ void *dev_handle;
+ /*u8 rtc_set_mode;
+ u8 rtc_pos;
+ u8 alm_enable;
+ u8 alm_num;
+ struct sys_time set_time;*/
+};
+
+static struct rtc_opr *__this = NULL;
+
+static void rtc_app_init()
+{
+ if (!__this) {
+ __this = zalloc(sizeof(struct rtc_opr));
+ ASSERT(__this, "%s %di \n", __func__, __LINE__);
+ __this->dev_handle = dev_open("rtc", NULL);
+ if (!__this->dev_handle) {
+ ASSERT(0, "%s %d \n", __func__, __LINE__);
+ }
+ }
+}
+
+
+//*----------------------------------------------------------------------------*/
+/**@brief rtc 退出
+ @param 无
+ @return
+ @note
+*/
+/*----------------------------------------------------------------------------*/
+static void rtc_task_close()
+{
+ if (__this) {
+ if (__this->dev_handle) {
+ dev_close(__this->dev_handle);
+ __this->dev_handle = NULL;
+ }
+ free(__this);
+ __this = NULL;
+ }
+}
+
+
+//*----------------------------------------------------------------------------*/
+/**@brief rtc 启动
+ @param 无
+ @return
+ @note
+*/
+/*----------------------------------------------------------------------------*/
+void rtc_task_start()
+{
+ rtc_app_init();
+}
+
+
+//*----------------------------------------------------------------------------*/
+/**@brief rtc 主任务
+ @param 无
+ @return 无
+ @note
+*/
+/*----------------------------------------------------------------------------*/
+void usr_jb_rtc_init()
+{
+ rtc_task_start();
+}
+
+struct sys_time usr_jb_current_time;
+
+
+// 计算某年某月某日是星期几(返回1-7对应周一到周日)
+uint8_t calculateWeekday(uint16_t year, uint8_t month, uint8_t day)
+{
+ if (month < 3) {
+ month += 12;
+ year -= 1;
+ }
+ uint16_t century = year / 100;
+ uint16_t yearInCentury = year % 100;
+ uint16_t h = (day + (13 * (month + 1)) / 5 + yearInCentury + yearInCentury / 4 + century / 4 + 5 * century) % 7;
+ uint8_t weekday = (h + 5) % 7 + 1;
+ return weekday;
+}
+void usr_rtc_read_time()
+{
+ static u8 cnt=0;
+ cnt++;
+ if(cnt>2)
+ {
+ cnt=0;
+ read_sys_time(&usr_jb_current_time);
+ if((usr_jb_current_time.year<2025)||(usr_jb_current_time.year>2098))
+ {
+ usr_var.usr_time_set_flag=0;
+ }
+ else
+ {
+ usr_var.usr_time_set_flag=1;
+ }
+
+ if(usr_var.usr_time_set_flag)
+ {
+ usr_var.usr_cureent_utc=convertToUtcTimestamp(usr_jb_current_time.year,usr_jb_current_time.month,usr_jb_current_time.day,usr_jb_current_time.hour,usr_jb_current_time.min,usr_jb_current_time.sec);
+ if(usr_var.usr_cureent_utc)
+ {
+ if(usr_var.usr_cureent_utc>usr_var.usr_timezoneOffset)usr_var.usr_cureent_utc=usr_var.usr_cureent_utc-usr_var.usr_timezoneOffset;
+ }
+ usr_var.usr_week_data=calculateWeekday(usr_jb_current_time.year,usr_jb_current_time.month,usr_jb_current_time.day);
+
+ usr_jb_ignore_alft();
+ }
+
+ /*printf("time: %d-%d-%d %d:%d:%d",
+ usr_jb_current_time.year,
+ usr_jb_current_time.month,
+ usr_jb_current_time.day,
+ usr_jb_current_time.hour,
+ usr_jb_current_time.min,
+ usr_jb_current_time.sec);*/
+ }
+}
+
+struct sys_time usr_set_time;
+void usr_set_sys_time()//设置时间
+{
+ //dev_ioctl(__this->dev_handle, IOCTL_SET_SYS_TIME, (u32)&usr_set_time);
+ write_sys_time(&usr_set_time);
+ usr_jb_current_time.year=usr_set_time.year;
+ usr_jb_current_time.month=usr_set_time.month;
+ usr_jb_current_time.day=usr_set_time.day;
+ usr_jb_current_time.hour=usr_set_time.hour;
+ usr_jb_current_time.min=usr_set_time.min;
+ usr_jb_current_time.sec=usr_set_time.sec;
+
+ usr_var.usr_cureent_utc=convertToUtcTimestamp(usr_jb_current_time.year,usr_jb_current_time.month,usr_jb_current_time.day,usr_jb_current_time.hour,usr_jb_current_time.min,usr_jb_current_time.sec);
+ if(usr_var.usr_cureent_utc)
+ {
+ if(usr_var.usr_cureent_utc>usr_var.usr_timezoneOffset)usr_var.usr_cureent_utc=usr_var.usr_cureent_utc-usr_var.usr_timezoneOffset;
+ }
+ printf("##### usr_var.usr_cureent_utc = %d\n",usr_var.usr_cureent_utc);
+}
+#else
+
+
+void app_rtc_task()
+{
+
+}
+
+#endif
+
+
+
+
diff --git a/apps/usr_hw_demo/usr_hw_timer.c b/apps/usr_hw_demo/usr_hw_timer.c
new file mode 100644
index 0000000..c8967a9
--- /dev/null
+++ b/apps/usr_hw_demo/usr_hw_timer.c
@@ -0,0 +1,140 @@
+#include "system/includes.h"
+#include "app_config.h"
+#include "asm/pwm_led.h"
+#include "tone_player.h"
+#include "ui_manage.h"
+#include "app_main.h"
+#include "app_task.h"
+#include "asm/charge.h"
+#include "app_power_manage.h"
+#include "app_charge.h"
+#include "user_cfg.h"
+#include "audio.h"
+#include "vm.h"
+#include "sys_time.h"
+
+/*
+ 硬件定时器demo
+*/
+
+
+extern const int CONFIG_CPU_UNMASK_IRQ_ENABLE;//需要极准确的定时效果时开启不可屏蔽中断
+static JL_TIMER_TypeDef *TIMER = NULL;
+static int irq_id = -1;
+#define TIMER_UNIT_US 1000//设置中断时间,单位us
+#define MAX_TIME_CNT 0x7fff //分频准确范围,更具实际情况调整
+#define MIN_TIME_CNT 0x0030
+static const u16 timer_div[] = {
+ /*0000*/ 1,
+ /*0001*/ 4,
+ /*0010*/ 16,
+ /*0011*/ 64,
+ /*0100*/ 2,
+ /*0101*/ 8,
+ /*0110*/ 32,
+ /*0111*/ 128,
+ /*1000*/ 256,
+ /*1001*/ 4 * 256,
+ /*1010*/ 16 * 256,
+ /*1011*/ 64 * 256,
+ /*1100*/ 2 * 256,
+ /*1101*/ 8 * 256,
+ /*1110*/ 32 * 256,
+ /*1111*/ 128 * 256,
+};
+
+
+SET_INTERRUPT
+static void user_timer_isr()
+{
+ /*if(CONFIG_CPU_UNMASK_IRQ_ENABLE){
+ unmask_enter_critical();
+ }
+ else{
+ local_irq_disable();
+ }*/
+ TIMER->CON |= BIT(14); //清中断标志位
+ //用户自定义代码
+ //......
+
+
+ /*if(CONFIG_CPU_UNMASK_IRQ_ENABLE){
+ unmask_exit_critical();
+ }
+ else{
+ local_irq_enable();
+ }*/
+}
+
+int user_timer_uninit();
+int user_timer_init(u16 usr_time_us)
+{
+ u32 prd_cnt;
+ u8 index;
+ u32 app_timer_clk = 24000000;//时钟用STD24M
+
+ user_timer_uninit();
+
+ printf("------------%s :%d", __func__, __LINE__);
+ printf("%d %d %d %d %d %d\n", JL_TIMER0->CON, JL_TIMER1->CON, JL_TIMER2->CON,
+ JL_TIMER3->CON, JL_TIMER4->CON, JL_TIMER5->CON);
+
+ /*if((JL_TIMER0->CON & 0x3) == 0) {
+ TIMER = JL_TIMER0;
+ irq_id = IRQ_TIME0_IDX;
+ }
+ else */if((JL_TIMER1->CON & 0x3) == 0) {
+ TIMER = JL_TIMER1;
+ irq_id = IRQ_TIME1_IDX;
+ }
+ else if((JL_TIMER2->CON & 0x3) == 0) {
+ TIMER = JL_TIMER2;
+ irq_id = IRQ_TIME2_IDX;
+ }
+ else if((JL_TIMER3->CON & 0x3) == 0) {
+ TIMER = JL_TIMER3;
+ irq_id = IRQ_TIME3_IDX;
+ }
+ else if((JL_TIMER4->CON & 0x3) == 0) {
+ TIMER = JL_TIMER4;
+ irq_id = IRQ_TIME4_IDX;
+ }
+ else if((JL_TIMER5->CON & 0x3) == 0) {
+ TIMER = JL_TIMER5;
+ irq_id = IRQ_TIME5_IDX;
+ }
+ else{
+ return -1;
+ }
+
+ for (index = 0; index < (sizeof(timer_div) / sizeof(timer_div[0])); index++) {
+ prd_cnt = usr_time_us * (app_timer_clk / 1000000) / timer_div[index];
+ if (prd_cnt > MIN_TIME_CNT && prd_cnt < MAX_TIME_CNT) {
+ break;
+ }
+ }
+ printf("irq_id %d PRD : 0x%x / %d", irq_id, prd_cnt, app_timer_clk);
+
+ TIMER->CNT = 0;
+ TIMER->PRD = prd_cnt - 1;
+ request_irq(irq_id, 2, user_timer_isr, 0);
+ if(CONFIG_CPU_UNMASK_IRQ_ENABLE){
+ irq_unmask_set(irq_id, 1, 0);
+ }
+ TIMER->CON = (index << 4) | BIT(0) | BIT(11)| BIT(12);//BIT(11)| BIT(12):时钟用STD24M
+
+ printf("PRD : 0x%x / %d", prd_cnt, app_timer_clk);
+
+ return 0;
+}
+
+int user_timer_uninit()
+{
+ if(TIMER == NULL){
+ return 0;
+ }
+ TIMER->CON = BIT(14);
+ unrequest_irq(irq_id);
+ TIMER = NULL;
+ return 0;
+}
diff --git a/apps/usr_hw_demo/usr_hw_uart.c b/apps/usr_hw_demo/usr_hw_uart.c
new file mode 100644
index 0000000..aeebe9e
--- /dev/null
+++ b/apps/usr_hw_demo/usr_hw_uart.c
@@ -0,0 +1,103 @@
+#include "system/includes.h"
+#include "asm/uart_dev.h"
+#include "system/event.h"
+#include "key_event_deal.h"
+#include "app_task.h"
+
+#include "usr_jb_global.h"
+
+
+/*
+ 硬件uart demo
+*/
+
+
+
+#if USR_JB_UART_RX_PIN
+
+
+/*
+ 在打开sleep时使用UART接收,需要把UART RX引脚配置为唤醒脚
+ 在休眠时,如果对端MCU需要发数据给蓝牙IC,则需要先发一个唤醒帧来唤醒蓝牙IC,
+ 唤醒后对端MCU再发送协议数据,具体用法全局搜索 USR_JB_UART_RX_PIN 这个宏,本demo已经做好了
+
+ 或者也可以单独使用一个GPIO,当对端有数据要过来时,拉低或者拉高GPIO,数据结束后再休眠
+
+*/
+
+
+
+const uart_bus_t *uart_bus;
+static u8 uart_cbuf[512] __attribute__((aligned(4)));
+static u8 uart_rxbuf[512] __attribute__((aligned(4)));
+
+
+int uart_tr_send_data(u8 *data, u32 len)
+{
+ if(uart_bus)
+ {
+ if(usr_var.usr_uart_recieve==0)
+ {
+ usr_var.usr_uart_recieve=1;
+ uart_bus->write(data,len);
+ usr_var.usr_uart_recieve=0;
+ }
+ else
+ {
+ uart_bus->write(data,len);
+ }
+ }
+}
+void usr_uart_send_test()
+{
+ char* test_string="I am uart test\n";
+ uart_tr_send_data(test_string,strlen(test_string));
+}
+static void uart_u_task(void *arg)
+{
+ int ret;
+ u32 uart_rxcnt = 0;
+ printf("uart_u_task start\n");
+ while (1)
+ {
+ //uart_bus->read()在尚未收到串口数据时会pend信号量,挂起task,直到UART_RX_PND或UART_RX_OT_PND中断发生,post信号量,唤醒task
+ uart_rxcnt = uart_bus->read(uart_rxbuf, sizeof(uart_rxbuf), 0);
+ if (uart_rxcnt)
+ {
+ usr_var.usr_uart_recieve=5;///不进入sleep
+ uart_tr_send_data(uart_rxbuf,uart_rxcnt);///收发回传测试
+ }
+ }
+}
+
+void uart_dev_init_main()
+{
+
+ struct uart_platform_data_t u_arg = {0};
+ u_arg.tx_pin = USR_JB_UART_TX_PIN;
+ u_arg.rx_pin = USR_JB_UART_RX_PIN;
+ u_arg.rx_cbuf = uart_cbuf;
+ u_arg.rx_cbuf_size = 512;
+ u_arg.frame_length = 512;
+ u_arg.rx_timeout = 10;
+ u_arg.isr_cbfun = NULL;
+ u_arg.baud = 115200;
+ u_arg.is_9bit = 0;
+
+ uart_bus = uart_dev_open(&u_arg);
+ if (uart_bus != NULL)
+ {
+ printf("uart_dev_open() success\n");
+ os_task_create(uart_u_task, (void *)uart_bus, 31, 512, 0, "uart_u_task");
+ }
+}
+#endif
+
+
+
+
+
+
+
+
+
diff --git a/apps/usr_jb_proto/usr_jb_main.c b/apps/usr_jb_proto/usr_jb_main.c
index ebf80b1..82ff3ed 100644
--- a/apps/usr_jb_proto/usr_jb_main.c
+++ b/apps/usr_jb_proto/usr_jb_main.c
@@ -1,18 +1,50 @@
+/******************************************************************************
+ * @file usr_jb_main.c
+ * @brief CBR1 业务组合根:只编排调用顺序
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ * @history
+ * - V1.1.0, 2026.09.04, cyWu, 精简注释
+ * - V1.0.0, 2026.09.04, cyWu, P0 组合根
+ *
+ * @note jb_product.c 不写业务。20ms tick 禁止写 VM / 切 BLE;
+ * 配对、OTA、落盘在 500ms usr_timer_loop(任务上下文)。
+ ******************************************************************************/
+
#include "system/includes.h"
#include "app_config.h"
#include "app_main.h"
#include "app_task.h"
#include "user_cfg.h"
-#include "vm.h"
-#include "sys_time.h"
#include "jb_protocol.h"
#include "jb_product.h"
#include "jb_ringbuffer.h"
#include "jb_common.h"
#include "usr_le_product.h"
+#include "usr_le_api.h"
+#include "bsp_hw.h"
+#include "bsp_zc.h"
+#include "app_pair.h"
+#include "app_function.h"
+#include "app_relay.h"
+#include "app_zc.h"
+#include "app_led.h"
+#include "app_arbiter.h"
+#include "app_linkage.h"
+#include "app_runrec.h"
+#include "app_timer_task.h"
+#include "app_th_scan.h"
+#include "app_nvm.h"
+#include "key_manager.h"
-/* 向 usr_le_code 库提供产品身份(换产品只改 jb_protocol.h 宏即可) */
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[USR_JB]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+/* 向 usr_le_code 提供产品身份(换产品只改 jb_protocol.h 宏即可) */
const uint8_t g_usr_le_prodcode[USR_LE_PRODCODE_LEN] = {
JB_CATEGORY_CODE[0], JB_CATEGORY_CODE[1],
JB_CATEGORY_CODE[2], JB_CATEGORY_CODE[3]
@@ -22,28 +54,141 @@ const uint8_t g_usr_le_pcode[USR_LE_PCODE_LEN] = {
JB_PRODUCT_CODE[3], JB_PRODUCT_CODE[4], JB_PRODUCT_CODE[5]
};
-void usr_timer_1_ms();
-static int jb_timer_id=0;
+#define USR_TIMER_IDLE_MS 20 /**< 全局统一节拍(规格书第 3 章) */
-void usr_jb_init()
+static void usr_timer_tick(void *priv);
+static void usr_log_boot_version(void);
+
+/**
+ * @brief 20ms 业务节拍(硬件定时器中断)
+ * @param priv 未使用
+ * @note 禁止写 VM / 切 BLE / printf
+ * @return 无
+ */
+static void usr_timer_tick(void *priv)
{
+ uint16_t dt = USR_TIMER_IDLE_MS;
+ uint16_t i;
+
+ (void)priv;
+ for (i = 0; i < dt; i++) {
+ jbTimerIrq();
+ }
+
+ user_key_scan(dt); /* 内部完成 5s/12s 长按分级,只置标志 */
+ user_key_handle(); /* 派发短按:切加热 */
+ app_zc_poll(dt); /* 只做 100ms 无沿超时兜底(切变在过零 ISR) */
+ app_function_poll(); /* 同步 DP0 = 实际继电器态 */
+ app_runrec_tick(dt); /* 实际吸合才累计;满 10 分钟只打脏,不写 flash */
+ app_nvm_tick(dt); /* 变化检测 + 500ms 防抖计时(不写 flash!) */
+ app_led_run(dt); /* 灯跟继电器实际态;内部推进配对会话 */
+}
+
+#if (BOARD_ZC_ENABLE && BOARD_ZC_SW_POLL_1MS)
+/**
+ * @brief 过零 1ms 软件采样兜底
+ * @param priv 未使用
+ * @return 无
+ */
+static void usr_timer_1ms(void *priv)
+{
+ (void)priv;
+ bsp_zc_scan_1ms();
+}
+#endif
+
+/**
+ * @brief 上电打印固件版本与编译时间,方便串口对应当前是哪一包
+ * @return 无
+ */
+static void usr_log_boot_version(void)
+{
+ log_info("sw V%d.%d.%d hw V%d.%d.%d build %s %s\n",
+ JB_MCU_SW_VERSION_MAJOR, JB_MCU_SW_VERSION_MINOR, JB_MCU_SW_VERSION_PATCH,
+ JB_MCU_HW_VERSION_MAJOR, JB_MCU_HW_VERSION_MINOR, JB_MCU_HW_VERSION_PATCH,
+ __DATE__, __TIME__);
+}
+
+/**
+ * @brief 业务初始化(上电即开机)
+ * @return 无
+ */
+void usr_jb_init(void)
+{
+ usr_log_boot_version(); /* UART 已在 app_main 起来,最先能看到版本 */
+
+ bsp_hw_init();
+ app_led_init(); /* 先于一切业务:灯效事件表要最先装载 */
+
jbInit();
userInit();
- if(jb_timer_id==0)jb_timer_id = sys_s_hi_timer_add(NULL, usr_timer_1_ms, TIMER_PERIOD_MS);
+
+ app_relay_init(); /* 执行器(请求/实际态)先于过零与仲裁 */
+ app_zc_init(); /* PA1 双沿 → 继电器切变(app_relay 回调) */
+ app_arbiter_init(); /* 占用仲裁(手动>定时>联动)先于联动/定时引擎 */
+ app_linkage_init();
+ app_runrec_init();
+ app_timer_init();
+ app_th_scan_init();
+
+ app_nvm_init();
+ app_nvm_load(); /* P1: 会回填 heat_switch/联动/定时/31 天运行记录 */
+
+ app_function_init();
+
+ user_key_init();
+ /* 上电时键若还按着:只锁短按(避免一上电就切加热),长按 5s/12s 分级照常生效,
+ * 所以"插电时就按住 5s 进配对"这一用法仍然成立 */
+ user_key_lock_if_held();
+
+ /* priority=1 常驻注册,永不重挂(220V 常电不需要低功耗) */
+ usr_timer_add(NULL, usr_timer_tick, USR_TIMER_IDLE_MS, 1);
+
+#if (BOARD_ZC_ENABLE && BOARD_ZC_SW_POLL_1MS)
+ /* 过零软件兜底采样:真实 1ms 节拍(P33 中断之外的第二道保险) */
+ usr_timer_add(NULL, usr_timer_1ms, 1, 1);
+#endif
+ log_info("timer started, period=%ums, zc_irq=%d, zc_poll_1ms=%d\n",
+ (unsigned)USR_TIMER_IDLE_MS, BOARD_ZC_USE_IRQ,
+ (BOARD_ZC_ENABLE && BOARD_ZC_SW_POLL_1MS) ? 1 : 0);
}
-void usr_timer_1_ms()
+/**
+ * @brief BLE 收到见宝载荷,送入协议环形缓冲
+ * @param data 载荷指针
+ * @param len 载荷长度,单位字节
+ * @return 无
+ */
+void usr_jb_le_recieve_data(u8 *data, u16 len)
{
- jbTimerIrq();
+ jbPutData(data, len);
}
-void usr_jb_le_recieve_data(u8* data,u16 len)
-{
- jbPutData(data,len);
-}
-
-void usr_timer_loop()///500ms
+/**
+ * @brief 约 500ms:协议、自动化引擎、配对/OTA 落地(任务上下文)
+ * @return 无
+ */
+void usr_timer_loop(void)
{
userHandle();
jbProtocolHandle(&gDevData);
+ app_th_scan_tick(); /* P2:断连时 500ms 重连广播/500ms 扫描交替 */
+ app_linkage_run(500); /* P2:联动引擎(任务上下文;仲裁见 app_arbiter) */
+ app_timer_run(500); /* P2:定时引擎(RTC 门闩 + 窗判断) */
+ app_runrec_rotate_if_needed(); /* P2:跨天旋转/落盘/灌 DP17 */
+ app_function_loop(); /* VM 防抖落盘 + 配对/OTA 动作落地 */
+
+#if (BOARD_ZC_ENABLE && BOARD_ZC_DIAG_LOG)
+ /* 台上诊断:确认过零 FPIN 中断真的在跑(edge 持续不涨 = 前端没信号/中断没起来) */
+ {
+ static uint32_t s_zc_cnt_last;
+ uint32_t cnt = bsp_zc_edge_count();
+ uint32_t last = bsp_zc_last_edge_ms();
+ uint32_t ago = last ? ((uint32_t)timer_get_ms() - last) : 0xFFFFFFFFu;
+ log_info("zc diag: edge=%u(+%u) ago=%ums lvl=%d req=%d act=%d\n",
+ cnt, (uint32_t)(cnt - s_zc_cnt_last), ago, (int)bsp_zc_level(),
+ (int)app_relay_get_request(), (int)app_relay_get_actual());
+ s_zc_cnt_last = cnt;
+ }
+#endif
}
diff --git a/apps/usr_periph/board_pin.h b/apps/usr_periph/board_pin.h
new file mode 100644
index 0000000..78d6b11
--- /dev/null
+++ b/apps/usr_periph/board_pin.h
@@ -0,0 +1,115 @@
+/******************************************************************************
+ * @file board_pin.h
+ * @brief CBR1 板级引脚、电平与时序(换板只改本文件)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ *
+ * @note UART0_TX=PC5 在 board_jl701n_demo_cfg.h。
+ * 过零用 MCPWM FPIN 双沿(CH0 下降 / CH1 上升),勿占这两个 PWM 通道。
+ * 软件采样兜底必须挂真实 1ms 定时器,禁止在 20ms 节拍里空循环读沿。
+ ******************************************************************************/
+
+#ifndef __BOARD_PIN_H__
+#define __BOARD_PIN_H__
+
+#include "asm/gpio.h"
+
+/*============================================================================*/
+/* 功能开关:某路硬件异常时可单独关掉,不影响配网 */
+/* 0 时对应模块 init/run 直接 return,GPIO 不配,日志打一次 xxx disabled */
+/*============================================================================*/
+
+#define BOARD_LED_ENABLE 1 /**< 1=红绿状态 LED(PA7 红 / PA8 绿,低亮) */
+#define BOARD_KEY_ENABLE 1 /**< 1=按键 PB1(低按下) */
+#define BOARD_RELAY_ENABLE 1 /**< 1=继电器 PC3(高导通) */
+#define BOARD_ZC_ENABLE 1 /**< 1=过零 PA1;=0 时开请求禁吸合、关请求强制断开 */
+#define BOARD_LINKAGE_ENABLE 0 /* 联动引擎(第 10 章);P2 开 */
+#define BOARD_TIMER_ENABLE 0 /* 定时引擎(第 11 章);P2 开 */
+#define BOARD_TH_SCAN_ENABLE 0 /* 断连扫描风扇广播(第 8 章);P2 开 */
+#define BOARD_RUNREC_ENABLE 0 /* 31 天运行记录(第 13 章);P2 开 */
+#define BOARD_NVM_ENABLE 0 /* VM 持久化(第 13 章);P1 开 */
+
+/*============================================================================*/
+/* GPIO */
+/*============================================================================*/
+
+#define BOARD_UART_TX_PIN IO_PORTC_05 /**< 调试 UART TX(cfg: TCFG_UART0_TX_PORT) */
+#define BOARD_KEY_PIN IO_PORTB_01 /**< 按键,低电平按下 */
+#define BOARD_ZC_PIN IO_PORTA_01 /**< 过零检测,双沿都算一次过零 */
+#define BOARD_LED_GREEN_PIN IO_PORTA_08 /**< 绿灯,低电平亮 */
+#define BOARD_LED_RED_PIN IO_PORTA_07 /**< 红灯,低电平亮 */
+#define BOARD_RELAY_PIN IO_PORTC_03 /**< 继电器,高电平导通(加热开) */
+
+#define BOARD_LED_ON_LEVEL 0 /**< LED 点亮电平:0=低亮 */
+#define BOARD_RELAY_ON_LEVEL 1 /**< 继电器有效电平:1=高导通 */
+#define BOARD_KEY_ACTIVE_LEVEL 0 /**< 按键有效电平:0=按下为低 */
+
+/*============================================================================*/
+/* 按键 / 配对 / OTA */
+/*============================================================================*/
+
+/**
+ * 直接作为 key.c 状态机的 keyLongTime/keyLastTime 使用:
+ * 松手时刻
+ * @date 2026.09.04
+ * @version V1.1.0
+ * @history
+ * - V1.1.0, 2026.09.04, cyWu, 精简注释(单标志不包结构体)
+ * - V1.0.0, 2026.09.04, cyWu, 首次发布
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "asm/gpio.h"
+#include "bsp_hw.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[BSP_HW]"
+#define LOG_ERROR_ENABLE
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+static uint8_t s_initialized;
+
+/**
+ * @brief 配置 GPIO 为推挽输出
+ * @param pin GPIO 编号
+ * @param value 输出初值(电平)
+ * @return 无
+ */
+static void bsp_pin_output(u32 pin, int value)
+{
+ gpio_set_pull_up(pin, 0);
+ gpio_set_pull_down(pin, 0);
+ gpio_set_die(pin, 1);
+ gpio_direction_output(pin, value);
+}
+
+/**
+ * @brief 配置 GPIO 为数字输入(可内部上拉)
+ * @param pin GPIO 编号
+ * @param pull_up 1=内部上拉,0=不上拉
+ * @return 无
+ */
+static void bsp_pin_input(u32 pin, int pull_up)
+{
+ gpio_direction_output(pin, 1);
+ gpio_set_direction(pin, 1);
+ gpio_set_pull_down(pin, 0);
+ gpio_set_pull_up(pin, pull_up ? 1 : 0);
+ gpio_set_die(pin, 1);
+}
+
+/**
+ * @brief 初始化 GPIO
+ * @note 上电顺序:继电器先保持关(PC3=0),灯全灭(LED_ENABLE 由事件表接管)。
+ * @return BSP_HW_OK=成功
+ */
+BspHw_Ret_t bsp_hw_init(void)
+{
+ if (s_initialized) {
+ return BSP_HW_OK;
+ }
+
+#if BOARD_LED_ENABLE
+ bsp_pin_output(BOARD_LED_RED_PIN, BOARD_LED_ON_LEVEL ? 0 : 1); /* 红灯灭 */
+ bsp_pin_output(BOARD_LED_GREEN_PIN, BOARD_LED_ON_LEVEL ? 0 : 1); /* 绿灯灭 */
+#else
+ log_info("hw led disabled\n");
+#endif
+
+#if BOARD_KEY_ENABLE
+ bsp_pin_input(BOARD_KEY_PIN, 1); /* 上拉输入,低按下 */
+#else
+ log_info("hw key disabled\n");
+#endif
+
+#if BOARD_RELAY_ENABLE
+ bsp_pin_output(BOARD_RELAY_PIN, 0); /* 继电器初始关(PC3=0),开请求等过零 */
+#else
+ log_info("hw relay disabled\n");
+#endif
+
+ s_initialized = 1;
+ log_info("hw init led=%d key=%d relay=%d\n",
+ BOARD_LED_ENABLE, BOARD_KEY_ENABLE, BOARD_RELAY_ENABLE);
+ return BSP_HW_OK;
+}
+
+/**
+ * @brief 查询板级硬件是否已初始化
+ * @return BSP_HW_OK=已初始化,BSP_HW_ERR_NOT_INIT=未初始化
+ */
+BspHw_Ret_t bsp_hw_get_status(void)
+{
+ return s_initialized ? BSP_HW_OK : BSP_HW_ERR_NOT_INIT;
+}
+
+/**
+ * @brief 红灯开关(PA7,低电平亮)
+ * @param on 1=亮,0=灭
+ * @return 无
+ */
+void bsp_led_red_set(uint8_t on)
+{
+#if BOARD_LED_ENABLE
+ gpio_direction_output(BOARD_LED_RED_PIN,
+ on ? BOARD_LED_ON_LEVEL : (BOARD_LED_ON_LEVEL ? 0 : 1));
+#else
+ (void)on;
+#endif
+}
+
+/**
+ * @brief 绿灯开关(PA8,低电平亮)
+ * @param on 1=亮,0=灭
+ * @return 无
+ */
+void bsp_led_green_set(uint8_t on)
+{
+#if BOARD_LED_ENABLE
+ gpio_direction_output(BOARD_LED_GREEN_PIN,
+ on ? BOARD_LED_ON_LEVEL : (BOARD_LED_ON_LEVEL ? 0 : 1));
+#else
+ (void)on;
+#endif
+}
+
+/**
+ * @brief 红绿全灭
+ * @return 无
+ */
+void bsp_led_all_off(void)
+{
+ bsp_led_red_set(0);
+ bsp_led_green_set(0);
+}
+
+/**
+ * @brief 读取按键(PB1)
+ * @return 1=按下,0=未按下
+ */
+uint8_t bsp_key_is_pressed(void)
+{
+#if BOARD_KEY_ENABLE
+ return (uint8_t)(gpio_read(BOARD_KEY_PIN) == BOARD_KEY_ACTIVE_LEVEL);
+#else
+ return 0;
+#endif
+}
+
+/**
+ * @brief 直接写继电器 GPIO(只准 app_relay 在过零/超时路径调用)
+ * @param on 1=吸合,0=断开
+ * @return 无
+ */
+void bsp_relay_set(uint8_t on)
+{
+#if BOARD_RELAY_ENABLE
+ gpio_direction_output(BOARD_RELAY_PIN,
+ on ? BOARD_RELAY_ON_LEVEL : (BOARD_RELAY_ON_LEVEL ? 0 : 1));
+#else
+ (void)on;
+#endif
+}
+
+/**
+ * @brief 读继电器 GPIO 当前电平
+ * @return 1=吸合,0=断开
+ */
+uint8_t bsp_relay_get(void)
+{
+#if BOARD_RELAY_ENABLE
+ return (uint8_t)(gpio_read(BOARD_RELAY_PIN) == BOARD_RELAY_ON_LEVEL);
+#else
+ return 0;
+#endif
+}
diff --git a/apps/usr_periph/bsp_hw.h b/apps/usr_periph/bsp_hw.h
new file mode 100644
index 0000000..0d12b47
--- /dev/null
+++ b/apps/usr_periph/bsp_hw.h
@@ -0,0 +1,77 @@
+/******************************************************************************
+ * @file bsp_hw.h
+ * @brief 板级 GPIO:红绿 LED、按键、继电器(过零见 bsp_zc)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V1.1.0
+ ******************************************************************************/
+
+#ifndef __BSP_HW_H__
+#define __BSP_HW_H__
+
+#include
+#include "board_pin.h"
+
+typedef enum {
+ BSP_HW_OK = 0,
+ BSP_HW_ERR_NOT_INIT
+} BspHw_Ret_t;
+
+/**
+ * @brief 初始化 GPIO:LED 双通道(初始灭)、按键(上拉输入)、继电器(初始关)
+ * @return BSP_HW_OK=成功
+ */
+BspHw_Ret_t bsp_hw_init(void);
+
+/**
+ * @brief 查询板级硬件是否已初始化
+ * @return BSP_HW_OK=已初始化,BSP_HW_ERR_NOT_INIT=未初始化
+ */
+BspHw_Ret_t bsp_hw_get_status(void);
+
+/* ═══ 红绿 LED(低电平亮;on 传"亮"语义,内部按 BOARD_LED_ON_LEVEL 取反)═══ */
+
+/**
+ * @brief 红灯开关(PA7)
+ * @param on 1=亮,0=灭
+ * @return 无
+ */
+void bsp_led_red_set(uint8_t on);
+
+/**
+ * @brief 绿灯开关(PA8)
+ * @param on 1=亮,0=灭
+ * @return 无
+ */
+void bsp_led_green_set(uint8_t on);
+
+/**
+ * @brief 红绿全灭
+ * @return 无
+ */
+void bsp_led_all_off(void);
+
+/* ═══ 按键 ═══ */
+
+/**
+ * @brief 读取按键
+ * @return 1=按下,0=未按下
+ */
+uint8_t bsp_key_is_pressed(void);
+
+/* ═══ 继电器(PC3,高电平导通)═══ */
+
+/**
+ * @brief 直接写继电器 GPIO(**只准由 app_relay 在过零时刻/超时路径调用**)
+ * @param on 1=吸合(加热开),0=断开(加热关)
+ * @return 无
+ */
+void bsp_relay_set(uint8_t on);
+
+/**
+ * @brief 读继电器 GPIO 当前电平
+ * @return 1=吸合,0=断开
+ */
+uint8_t bsp_relay_get(void);
+
+#endif /* __BSP_HW_H__ */
diff --git a/apps/usr_periph/bsp_zc.c b/apps/usr_periph/bsp_zc.c
new file mode 100644
index 0000000..a745d3d
--- /dev/null
+++ b/apps/usr_periph/bsp_zc.c
@@ -0,0 +1,192 @@
+/******************************************************************************
+ * @file bsp_zc.c
+ * @brief PA1 过零:MCPWM FPIN 双沿中断,沿到则回调切继电器
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V3.1.0
+ * @history
+ * - V3.1.0, 2026.09.04, cyWu, 状态收进 BspZcCtx_t,去掉无用计数与排障史注释
+ * - V3.0.0, 2026.09.04, cyWu, MCPWM FPIN 硬件边沿中断
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "asm/gpio.h"
+#include "asm/mcpwm.h"
+#include "asm/hwi.h"
+#include "asm/irq.h"
+#include "asm/timer.h"
+#include "bsp_zc.h"
+#include "board_pin.h"
+#include
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[BSP_ZC]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+extern PWM_CH_REG *get_pwm_ch_reg(pwm_ch_num_type index);
+
+typedef struct {
+ BspZcEdgeCb_t edge_cb;
+ volatile uint32_t edge_cnt;
+ volatile uint32_t last_edge_ms;
+ volatile uint8_t last_level;
+ uint8_t initialized;
+} BspZcCtx_t;
+
+static BspZcCtx_t s_zc;
+
+/**
+ * @brief FPIN 边沿 ISR:清 pending、记沿、执行切变回调
+ * @note 中断上下文:禁止 printf / 写 VM / 切 BLE
+ * @return 无
+ */
+___interrupt
+static void bsp_zc_interrupt(void)
+{
+ if (JL_MCPWM->CH0_CON1 & BIT(15)) {
+ JL_MCPWM->CH0_CON1 |= BIT(14);
+ } else if (JL_MCPWM->CH1_CON1 & BIT(15)) {
+ JL_MCPWM->CH1_CON1 |= BIT(14);
+ } else if (JL_MCPWM->CH2_CON1 & BIT(15)) {
+ JL_MCPWM->CH2_CON1 |= BIT(14);
+ } else if (JL_MCPWM->CH3_CON1 & BIT(15)) {
+ JL_MCPWM->CH3_CON1 |= BIT(14);
+ } else {
+ return;
+ }
+
+ s_zc.edge_cnt++;
+ s_zc.last_edge_ms = (uint32_t)timer_get_ms();
+ s_zc.last_level = (uint8_t)gpio_read(BOARD_ZC_PIN);
+
+ if (s_zc.edge_cb) {
+ s_zc.edge_cb();
+ }
+}
+
+/**
+ * @brief 把 PA1 接到一个 FPIN 通道(上升沿或下降沿)
+ * @param index FPIN 通道 0~3
+ * @param rising 1=上升沿,0=下降沿
+ * @return 无
+ */
+static void bsp_zc_fpin_channel_init(u8 index, u8 rising)
+{
+ PWM_CH_REG *pwm_reg;
+
+ if (index > 3) {
+ return;
+ }
+
+ if (rising) {
+ JL_MCPWM->FPIN_CON |= BIT(16 + index);
+ } else {
+ JL_MCPWM->FPIN_CON &= ~BIT(16 + index);
+ }
+
+#if BOARD_ZC_FPIN_FILTER_EN
+ JL_MCPWM->FPIN_CON |= BIT(8 + index);
+ JL_MCPWM->FPIN_CON |= ((u32)BOARD_ZC_FPIN_FILTER & 0x3FUL);
+#else
+ JL_MCPWM->FPIN_CON &= ~BIT(8 + index);
+#endif
+
+ gpio_ich_sel_iutput_signal(BOARD_ZC_PIN,
+ (enum INPUT_CH_SIGNAL)(INPUT_CH_SIGNAL_MC_FPIN0 + index),
+ INPUT_CH_TYPE_GP_ICH);
+
+ request_irq(IRQ_MCPWM_CHX_IDX, 3, bsp_zc_interrupt, 0);
+
+ pwm_reg = get_pwm_ch_reg((pwm_ch_num_type)index);
+ pwm_reg->ch_con1 = BIT(14) | BIT(11) | BIT(4) | (index << 0);
+ JL_MCPWM->MCPWM_CON0 |= BIT(index);
+}
+
+/**
+ * @brief 初始化 PA1 输入并挂双沿 FPIN 中断
+ * @param cb 沿回调,NULL 则只计数
+ * @return 无
+ */
+void bsp_zc_init(BspZcEdgeCb_t cb)
+{
+ if (s_zc.initialized) {
+ return;
+ }
+
+ memset(&s_zc, 0, sizeof(s_zc));
+ s_zc.edge_cb = cb;
+
+ gpio_set_die(BOARD_ZC_PIN, 1);
+ gpio_set_direction(BOARD_ZC_PIN, 1);
+ gpio_set_pull_up(BOARD_ZC_PIN, BOARD_ZC_PULL_UP);
+ gpio_set_pull_down(BOARD_ZC_PIN, BOARD_ZC_PULL_DOWN);
+ s_zc.last_level = (uint8_t)gpio_read(BOARD_ZC_PIN);
+
+#if BOARD_ZC_USE_IRQ
+ bsp_zc_fpin_channel_init((u8)BOARD_ZC_FPIN_FALL_CH, 0);
+#if BOARD_ZC_FPIN_BOTH_EDGE
+ bsp_zc_fpin_channel_init((u8)BOARD_ZC_FPIN_RISE_CH, 1);
+#endif
+#endif
+
+ s_zc.initialized = 1;
+ log_info("zc init irq=%d fall=%d rise=%d filt=%d lvl=%d\n",
+ BOARD_ZC_USE_IRQ, BOARD_ZC_FPIN_FALL_CH,
+#if BOARD_ZC_FPIN_BOTH_EDGE
+ BOARD_ZC_FPIN_RISE_CH,
+#else
+ -1,
+#endif
+ BOARD_ZC_FPIN_FILTER_EN, (int)s_zc.last_level);
+}
+
+/**
+ * @brief 1ms 软件采样兜底(须由真实 1ms 定时器调用)
+ * @return 无
+ */
+void bsp_zc_scan_1ms(void)
+{
+ uint8_t lvl;
+
+ if (!s_zc.initialized) {
+ return;
+ }
+
+ lvl = (uint8_t)gpio_read(BOARD_ZC_PIN);
+ if (lvl != s_zc.last_level) {
+ s_zc.last_level = lvl;
+ s_zc.edge_cnt++;
+ s_zc.last_edge_ms = (uint32_t)timer_get_ms();
+ if (s_zc.edge_cb) {
+ s_zc.edge_cb();
+ }
+ }
+}
+
+/**
+ * @brief 累计过零沿次数
+ * @return 沿计数
+ */
+uint32_t bsp_zc_edge_count(void)
+{
+ return s_zc.edge_cnt;
+}
+
+/**
+ * @brief 最近一次沿的时间戳
+ * @return timer_get_ms(),0=从未检测到
+ */
+uint32_t bsp_zc_last_edge_ms(void)
+{
+ return s_zc.last_edge_ms;
+}
+
+/**
+ * @brief 当前 PA1 电平
+ * @return 0 或 1
+ */
+uint8_t bsp_zc_level(void)
+{
+ return (uint8_t)gpio_read(BOARD_ZC_PIN);
+}
diff --git a/apps/usr_periph/bsp_zc.h b/apps/usr_periph/bsp_zc.h
new file mode 100644
index 0000000..9b22fa6
--- /dev/null
+++ b/apps/usr_periph/bsp_zc.h
@@ -0,0 +1,55 @@
+/******************************************************************************
+ * @file bsp_zc.h
+ * @brief PA1 过零检测(MCPWM FPIN 双沿中断)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.04
+ * @version V3.1.0
+ * @history
+ * - V3.1.0, 2026.09.04, cyWu, 精简注释
+ * - V3.0.0, 2026.09.04, cyWu, FPIN 硬件边沿中断
+ *
+ * @note BR28 无通用 GPIO 中断,沿检测复用 MCPWM FPIN(例程 usr_hw_gpio_int.c)。
+ * PA1 占两个通道实现双沿。ISR 只准清 pending、计数、回调切 GPIO。
+ * 软件兜底必须用真实 1ms 定时器,禁止在 20ms 节拍里空循环采样。
+ ******************************************************************************/
+
+#ifndef __BSP_ZC_H__
+#define __BSP_ZC_H__
+
+#include
+
+typedef void (*BspZcEdgeCb_t)(void);
+
+/**
+ * @brief 初始化 PA1 并挂 FPIN 双沿中断
+ * @param cb 沿回调(app_relay 提供),NULL 则只计数
+ * @return 无
+ */
+void bsp_zc_init(BspZcEdgeCb_t cb);
+
+/**
+ * @brief 1ms 软件采样兜底
+ * @note 须由真实 1ms 定时器调用
+ * @return 无
+ */
+void bsp_zc_scan_1ms(void);
+
+/**
+ * @brief 累计过零沿次数
+ * @return 沿计数
+ */
+uint32_t bsp_zc_edge_count(void);
+
+/**
+ * @brief 最近一次沿的时间戳
+ * @return timer_get_ms(),0=从未检测到
+ */
+uint32_t bsp_zc_last_edge_ms(void);
+
+/**
+ * @brief 当前 PA1 电平
+ * @return 0 或 1
+ */
+uint8_t bsp_zc_level(void);
+
+#endif /* __BSP_ZC_H__ */