From 866b522c59523cf3de2025c52d53c230344f4e47 Mon Sep 17 00:00:00 2001
From: Wuchuyuan <1917507415@qq.com>
Date: Wed, 9 Sep 2026 12:57:24 +0800
Subject: [PATCH] =?UTF-8?q?=E5=96=B7=E6=B7=8B=E5=99=A8=20P0=20=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD=E5=AE=9E=E7=8E=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AC701N.cbp | 46 ++
Doc/喷淋器_固件需求规格书.md | 813 +++++++++++++++++++++++
Makefile | 12 +
apps/earphone/app_main.c | 11 +-
apps/usr_app/app_defer.c | 57 ++
apps/usr_app/app_defer.h | 59 ++
apps/usr_app/app_function.c | 124 ++++
apps/usr_app/app_function.h | 61 ++
apps/usr_app/app_led.c | 198 ++++++
apps/usr_app/app_led.h | 71 ++
apps/usr_app/app_pair.c | 74 +++
apps/usr_app/app_pair.h | 43 ++
apps/usr_app/app_pump.c | 90 +++
apps/usr_app/app_pump.h | 45 ++
apps/usr_app/app_spray.c | 208 ++++++
apps/usr_app/app_spray.h | 57 ++
apps/usr_app/key.c | 265 ++++++++
apps/usr_app/key.h | 101 +++
apps/usr_app/key_manager.c | 201 ++++++
apps/usr_app/key_manager.h | 68 ++
apps/usr_app/led.c | 328 +++++++++
apps/usr_app/led.h | 124 ++++
apps/usr_app/led_manager.c | 201 ++++++
apps/usr_app/led_manager.h | 32 +
apps/usr_jb_proto/Protocol/jb_product.c | 8 +
apps/usr_jb_proto/Protocol/jb_protocol.h | 2 +-
apps/usr_jb_proto/usr_jb_main.c | 124 +++-
apps/usr_periph/board_pin.h | 101 +++
apps/usr_periph/bsp_hw.c | 195 ++++++
apps/usr_periph/bsp_hw.h | 79 +++
30 files changed, 3773 insertions(+), 25 deletions(-)
create mode 100644 Doc/喷淋器_固件需求规格书.md
create mode 100644 apps/usr_app/app_defer.c
create mode 100644 apps/usr_app/app_defer.h
create mode 100644 apps/usr_app/app_function.c
create mode 100644 apps/usr_app/app_function.h
create mode 100644 apps/usr_app/app_led.c
create mode 100644 apps/usr_app/app_led.h
create mode 100644 apps/usr_app/app_pair.c
create mode 100644 apps/usr_app/app_pair.h
create mode 100644 apps/usr_app/app_pump.c
create mode 100644 apps/usr_app/app_pump.h
create mode 100644 apps/usr_app/app_spray.c
create mode 100644 apps/usr_app/app_spray.h
create mode 100644 apps/usr_app/key.c
create mode 100644 apps/usr_app/key.h
create mode 100644 apps/usr_app/key_manager.c
create mode 100644 apps/usr_app/key_manager.h
create mode 100644 apps/usr_app/led.c
create mode 100644 apps/usr_app/led.h
create mode 100644 apps/usr_app/led_manager.c
create mode 100644 apps/usr_app/led_manager.h
create mode 100644 apps/usr_periph/board_pin.h
create mode 100644 apps/usr_periph/bsp_hw.c
create mode 100644 apps/usr_periph/bsp_hw.h
diff --git a/AC701N.cbp b/AC701N.cbp
index f570e7a..fcca52d 100644
--- a/AC701N.cbp
+++ b/AC701N.cbp
@@ -176,6 +176,7 @@
+
@@ -782,6 +783,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Doc/喷淋器_固件需求规格书.md b/Doc/喷淋器_固件需求规格书.md
new file mode 100644
index 0000000..675db64
--- /dev/null
+++ b/Doc/喷淋器_固件需求规格书.md
@@ -0,0 +1,813 @@
+# 喷淋器固件需求规格书
+
+| 项 | 内容 |
+|---|---|
+| 产品 | 喷淋器(爬宠) |
+| 芯片 | JL7016 / AC7016C(BR28) |
+| 工程 | `喷淋器_soc_JieLi-JL7016` |
+| 产品码 | ProdtCode=`CBR2`,productCode=`PRCBR2` |
+| 协议 | 见宝 SOC 55AA,V1.0.0 |
+| 文档版本 | V1.0.0 |
+| 日期 | 2026.09.09 |
+| 作者 | cyWu |
+| 读者 | WorkBuddy / 固件开发 |
+| 参考工程 | `D:\Jianbao\Project\产品\爬宠\电子开关-加热垫\电子开关-加热垫_soc_JieLi-JL7016`(框架 / 仲裁 / 联动 / 运行记录 / 配对 OTA / NVM) |
+| 框架源头 | `D:\Jianbao\Project\产品\爬宠\温湿度风扇\温湿度风扇_soc_JieLi-JL7016`(20ms + 500ms、按键分级、扫描时隙) |
+| 灯驱参考 | `D:\Jianbao\Project\产品\宠物\门夹弹力绳\门夹弹力绳_soc_JieLi-JL7016`(红绿双通道硬件;**事件表不要抄加热垫互斥灯**) |
+
+> **V1.0.0(2026-09-09):** 首版共同理解。5V 常电双脚水泵;两键两灯;红绿通道独立;单击按上次配置跑「时长 × 次数 + 间隔」;APP 写 DP7 立刻开喷;湿度联动回差;7 条计划到点触发一轮(不是时间窗);运行记录计水泵实际转动秒;`MAX_PACKAGE_LEN=512`。
+
+本文是 **已拍板的共同理解**,不是草稿。实现时以本文为准;DP 表以 `dp_definition.json` + 本文第 10.2 / 11.2 / 12.1 / 14.1 / 16 章为准。
+
+---
+
+## 0. WorkBuddy 开干前必读(硬约束)
+
+1. **程序框架必须和加热垫 / 风扇一致。** 组合根 `usr_jb_main.c`:20ms `usr_timer_tick`(硬件定时器中断)+ 500ms `usr_timer_loop`(BLE 任务上下文)。配对 / OTA / 写 VM 只准在任务上下文落地(`app_defer` / `app_function_loop`)。禁止在 20ms ISR 里 `syscfg_write`、切 BLE 广播、跑联动 / 计划引擎。
+2. **不要把业务算法写进 `jb_product.c`。** 只把 DP 写入 `gDevData` 并转调 `app_*`。
+3. **不要抄门夹的电池 / 低电 / 深睡开机判定。** 本机 **5V 常电、无电池**。上电即开机;拔电即关,无软关机。
+4. **不要抄加热垫的过零 / 继电器,也不要抄风扇的 PWM / 数码管 / AHT20。** 执行器是 **PC2 + PA6 双脚水泵**;湿度来自扫描或 DP6,不是本机传感器。
+5. **`MAX_PACKAGE_LEN` 必须为 512。** 生成值是 128,装不下 DP18 `run_record`(124B)。改完禁止再生成回 128。OTA 单包 `JB_OTA_MAX_PAYLOAD` 可保持 96。
+6. **DP6 `current_humi` 为 WO。** 已连接时服务器下发;设备不上报;断连扫描到的湿度不填 DP6。
+7. **DP5 `current_temp` 本版忽略。** 联动只看湿度。
+8. **本版写忽略、不实现动作:** DP0 总开关、DP2 模式、DP3 单次时长、DP4 连续时长、DP5 当前温度、DP19 心跳。DP21 / DP23 保持 0(无缺水检测)。DP20 / DP22 原样存、不做位过滤。
+9. 每个模块必须能用 `board_pin.h` 的 `BOARD_xxx_ENABLE` **单独关掉** 仍能编过、能配网上报。
+10. 日志全部 **英文**。中文只出现在注释和本文。
+11. 编码风格对齐风扇 + 工程师规范:文件小写+下划线,`g_` / `s_` 前缀,枚举 `Pascal_t`,接口 `xxx_init` / `xxx_run` 或 tick / `xxx_get_status`。
+12. **现有工程是协议骨架。** 没有 `apps/usr_app`、没有 `board_pin.h`。`app_main.c` 里上电强制 `usr_goto_pair=1` **必须删掉**,改为上电进工作态(已配对走重连广播)。
+13. 调试 UART TX = **PC5**。关掉会与 PA8 / PA7 / PB1 / PA0 / PC2 / PA6 冲突的 demo 外设(IR 键、PWM LED、demo I2C 等)。
+14. **红灯 PA8、绿灯 PA7。** 和加热垫(PA8 绿 / PA7 红)**相反**,禁止照抄加热垫 `board_pin.h` 灯脚。
+15. **灯是两路独立通道。** 喷淋 / 空闲只改绿灯,不要去关红灯;配网闪红灯时不要去关绿灯。只有 OTA 和配对失败动画临时占用双灯。禁止抄加热垫「开红关绿、互斥」事件表。
+16. **上电绝不自动喷。** 不从 VM 恢复 DP1。泵两脚上电默认低。
+17. **遵守第 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_last_level`、`s_initialized` 并列。正例:`static PumpCtx_t s_pump`。
+3. **不要只写不读的诊断变量。** 需要看泵是否在转,用已有 getter + 一条日志。
+4. **文件头三件事:做什么、一两行硬约束、history。** 同一条约束只写一次。禁止把排障日记复制到每个 `@note`。
+5. **函数注释短。** `@brief` 一行;`@param` / `@return` 按需;`@note` 只写本函数独特约束。不要复述代码。
+6. **注释解释为什么,不写排障日记。** 「两脚必须同时高,泵才会转」是为什么;「我改了三次 GPIO 才好」不要进源码。
+7. **P0 路径不堆死字段。** 没有 getter、没有日志、P2 也没用上的计数不要先占着。
+
+---
+
+## 1. 产品一句话
+
+5V 常供电的爬宠喷淋器:双 GPIO 同时导通才开泵。本地单击喷淋键,按上次 APP 配置跑完「单次时长 × 次数 + 间隔」一轮;APP 可写配置立刻开喷,也可写开关停喷。连着时用服务器下发的湿度做联动;断连时扫描风扇 `JBao-{缸体号}` 广播取湿度。过干则循环喷、过湿则停、中间维持上一态。7 条喷淋计划到开始时:分各跑一轮(不是加热垫那种时间窗)。水泵实际转动的秒数记 31 天运行记录。
+
+---
+
+## 2. 硬件资源
+
+### 2.1 引脚(以用户原理图说明 + 本文为准)
+
+| 功能 | MCU | 电平 / 说明 |
+|---|---|---|
+| 红灯 | **PA8** `IO_PORTA_08` | **低电平亮**(与加热垫灯脚相反) |
+| 绿灯 | **PA7** `IO_PORTA_07` | **低电平亮**(与加热垫灯脚相反) |
+| 喷淋键 | **PB1** `IO_PORTB_01` | 低电平按下 |
+| 配网键 | **PA0** `IO_PORTA_00` | 低电平按下 |
+| 水泵总供电 | **PC2** `IO_PORTC_02` | **高电平**把 5V 导到泵 VCC |
+| 水泵开关 | **PA6** `IO_PORTA_06` | **高电平**导通泵;必须与 PC2 同时开,泵才工作 |
+| 调试 UART TX | **PC5** `IO_PORTC_05` | 只发打印 |
+| 供电 | 5V 常电 → 板内 3.3V | 无电池、无充电管理 |
+
+板级文件仍指向 `CONFIG_BOARD_JL701N_DEMO`。产品 GPIO **全部集中到** `apps/usr_periph/board_pin.h`,并改 `board_jl701n_demo_cfg.h`:
+
+```c
+#define TCFG_UART0_TX_PORT IO_PORTC_05
+```
+
+关掉与产品脚冲突的 demo 外设(IR 键、PWM LED、demo I2C / RDEC 等)。产品灯走 `app_led`,保持 `TCFG_PWMLED_ENABLE=DISABLE`。
+
+### 2.2 水泵安全
+
+- GPIO 上电默认:**PC2=0 且 PA6=0**。
+- 喷水时段:两脚同时置高。
+- 间隔等待、空闲、停止、故障收口:两脚同时拉低。
+- **禁止只开其中一只脚。** 写泵接口必须成对操作。
+- 无过零、无继电器模块。切 GPIO 可在 20ms 节拍里直接改(5V 小泵,不是市电触点)。
+- 上电绝不按 VM 自动开泵。
+
+---
+
+## 3. 软件架构(对齐加热垫,删过零/继电器,加双键/双脚泵/喷淋作业)
+
+```
+apps/earphone/app_main.c
+ └─ usr_rtc_init()
+ └─ usr_jb_init() ← 唯一组合根
+ bsp_hw_init()
+ app_led_init() ← 灯效通道表最先装载
+ jbInit() + userInit()
+ (上电即开机:无按键等待)
+ app_pump_init()
+ app_spray_init()
+ app_arbiter_init()
+ app_linkage_init() / app_timer_init() / app_runrec_init()
+ app_th_scan_init()
+ app_nvm_init() + app_nvm_load()
+ app_function_init()
+ user_key_init() + lock_if_held(两颗键各自锁短按)
+ usr_timer_add(usr_timer_tick, 20ms, priority=1) ← 常驻,永不重挂
+```
+
+**20ms 业务节拍**(`usr_timer_tick`,硬件定时器中断上下文,禁止再开 1ms ISR):
+
+1. `jbTimerIrq()` × dt
+2. `user_key_scan` + `user_key_handle`(两颗键)
+3. `app_spray_poll`(作业节拍:时长 / 间隔倒数;到点切泵)
+4. `app_function_poll`(同步 DP1 = 作业态,不是某一只脚的瞬时电平)
+5. `app_runrec_tick`(两脚都已导通则累加秒;满 10 分钟只打脏,不写 flash)
+6. `app_nvm_tick`(变化检测 + 500ms 防抖计时,不写 flash)
+7. `app_led_run`
+
+**priority 固定 1、只注册一次,不动态重挂**:5V 常电,不需要低功耗。
+
+`usr_timer_loop()`(BLE 500ms 任务上下文):`userHandle()`、`jbProtocolHandle()`、
+`app_th_scan_tick()`(断连时 500ms 重连广播 / 500ms 扫描交替)、
+`app_linkage_run()` / `app_timer_run()`、`app_runrec_rotate_if_needed()`、
+`app_function_loop()`(VM 落盘 + 配对 / OTA 落地)。
+
+可直接复用加热垫 / 风扇源码(拷贝后改引脚 / 事件表,不要改框架语义):
+
+| 复用 | 路径 | 改什么 |
+|---|---|---|
+| 按键 FSM | `key.c` / `key.h` / `key_manager.c` | **两实例**:喷淋键只短按;配网键 5s / 12s |
+| 配对会话 | `app_pair.c` | 120s 超时;红灯通道跟配对态 |
+| 延后执行 | `app_defer.c` | 原样 |
+| NVM / 仲裁 / 运行记录 | `app_nvm` / `app_arbiter` / `app_runrec` | owner 从继电器改为喷淋作业;记录改计泵实际导通秒 |
+| 联动 | `app_linkage` | 开停条件改湿度回差(第 11.3);湿度源改扫描 / DP6 |
+| 定时 | `app_timer_task` | **改成到点触发一轮**,不要抄加热垫时间窗 |
+| RTC | `usr_rtc.c` | 原样 |
+| 扫描 | `app_th_scan` | 取广播湿度,不取温度 |
+| 协议骨架 | `usr_jb_proto` | 已生成;`MAX_PACKAGE_LEN` 改 512,DP6=WO |
+
+LED 底层拷门夹双通道(`led.c` 打开 `ALTERNATING_BLINK_ENABLE`),事件表按第 6 章 **独立通道**,不要用加热垫互斥表。
+
+**不要拷贝:** `app_battery.c`、`app_boot.c`、`app_power.c`、`app_fan.c`、`app_disp.c`、`app_th.c`、`app_mode.c`、`app_relay.c`、`app_zc.c`、`bsp_zc.c`、充电 GPIO、CT1642、AHT20、过零。
+
+---
+
+## 4. 模块开关(独立使能 / 屏蔽调试)
+
+全部放 `apps/usr_periph/board_pin.h`。`0` 时:对应 `init/run` 直接 return,GPIO 不配,日志打一次 `xxx disabled`。
+
+```c
+#define BOARD_LED_ENABLE 1
+#define BOARD_KEY_ENABLE 1
+#define BOARD_PUMP_ENABLE 1
+#define BOARD_SPRAY_ENABLE 1 /* 喷淋作业引擎;=0 时键/DP 不转泵 */
+#define BOARD_LINKAGE_ENABLE 1
+#define BOARD_TIMER_ENABLE 1 /* P0/P1 可先 0 */
+#define BOARD_TH_SCAN_ENABLE 1 /* 断连扫描;P0/P1 可 0 */
+#define BOARD_RUNREC_ENABLE 1
+#define BOARD_NVM_ENABLE 1 /* P0 可 0 */
+```
+
+P0 建议先 `LINKAGE/TIMER/TH_SCAN/RUNREC/NVM=0`,台上只验双键、双灯、双脚泵。
+
+---
+
+## 5. 电源与按键
+
+### 5.1 上电 / 断电
+
+- **上电直接进入工作态**,无需按键。流程:`bsp_hw_init` → `app_led_init` → `jbInit/userInit` → 各 `app_*_init` → `app_nvm_load` → `user_key_init` + `user_key_lock_if_held` → 20ms 节拍。
+- **关机 = 拔电。** 无软关机、无深睡开机判定。
+- 上电若某键仍按着:只锁**该键短按**(防一上电就开喷),不锁配网键的 5s / 12s。
+- 设备工作态恒开机。DP0 本版忽略。DP1 是 **喷淋作业开关**。
+- 无电池、无充电逻辑。
+- **冷启动水泵:** GPIO 先保持关。VM 恢复配置 / 联动 / 计划 / 运行记录,**不恢复 DP1,不开泵**。湿度没到之前联动不改它(第 13 章)。
+- 上电灯:绿灯常亮;已配对则红灯常亮,未配对红灯灭。
+
+### 5.2 两键分工(运行态)
+
+加热垫是一颗键兼短按 / 配网 / OTA。本机必须拆开,禁止把 5s / 12s 做在喷淋键上。
+
+#### PB1 喷淋键
+
+| 事件 | 行为 |
+|---|---|
+| 单击(松开且按下时长 < 5000 ms) | 空闲 → 用上次有效配置开一轮(第 10 章);作业中 → 立刻停(第 10.4) |
+| 按住 ≥ 5s | **无动作**(不配网、不进 OTA) |
+
+#### PA0 配网键
+
+| 按住时长 | 行为 |
+|---|---|
+| < 5000 ms 松开 | 短按:无动作 |
+| 满 5000 ms(仍按住) | **只**切红灯 100ms 快闪,**不清绑定** |
+| 满 5s 后、12s 前松开 | 提交配对:`usr_goto_pair_mode()` + `app_pair_start()` 120s |
+| 满 12000 ms(到点即触发) | 进 OTA,不等松手;同时撤销本次配对 |
+
+配对刻意不在 5s 到点提交 —— 用户可能继续按到 12s 进 OTA。
+
+⚠ 20ms ISR 只准置标志 + 切灯效。清绑定与 `usr_app_ota_init` 延后到 `app_function_loop()`。
+
+### 5.3 会话中的按键
+
+| 场景 | PB1 | PA0 |
+|---|---|---|
+| 配对进行中 | 仍可启停喷淋 | 忽略再次 5s / 12s |
+| OTA 进行中 | 忽略 | 忽略 |
+
+---
+
+## 6. LED(红绿独立通道,低亮)
+
+灯脚:**PA8 红、PA7 绿**。两路独立,固件改其中一个 GPIO 时禁止顺手改另一个,除非命中下面的「双灯例外」。
+
+### 6.1 绿灯(喷淋通道)
+
+| 状态 | 绿灯 |
+|---|---|
+| 空闲(DP1=关) | 常亮 |
+| 喷淋作业中(含间隔等待,DP1=开) | 500ms 亮 / 500ms 灭 |
+| 上电 | 常亮(泵关) |
+
+绿灯跟 **作业态**(DP1),不是 PA6 / PC2 的瞬时电平。间隔等待也算作业中,绿灯继续闪。
+
+### 6.2 红灯(配网通道)
+
+| 状态 | 红灯 |
+|---|---|
+| 未配对 / 配对失败收尾后 | 灭 |
+| 已配对(含断连重连、上电已配对) | **常亮**(粘住;空闲 / 喷淋都不要关它) |
+| 配网键满 5s 仍按住、以及配对进行中 | 100ms 亮 / 100ms 灭(此时即使已配对也改成闪) |
+| 配对成功 | 常亮(接着当「已配对」) |
+
+配网闪红灯期间,绿灯继续按 6.1 闪或常亮,不要关绿灯。
+
+### 6.3 双灯例外(临时占用两路,结束后各回自己的通道)
+
+| 事件 | 图案 | 结束后 |
+|---|---|---|
+| OTA | 红绿交替,200ms 半周期,直到升级结束 / 复位 | — |
+| 配对失败 / 超时 | 红绿交替 3s | 红灯灭;绿灯回到空闲或喷淋 |
+
+优先级高→低:**OTA → 配对失败动画 → 各走各的通道(红=配对态,绿=作业态)**。
+
+---
+
+## 7. 水泵(双脚同时切)
+
+### 7.1 接口
+
+- `app_pump_init` / `app_pump_set(uint8_t on)` / `app_pump_get()`。
+- `on=1`:PC2=1 且 PA6=1。
+- `on=0`:PC2=0 且 PA6=0。
+- 禁止导出「只改一只脚」的接口。
+- `BOARD_PUMP_ENABLE=0`:`set` 直接 return,GPIO 不配。
+
+### 7.2 谁可以切泵
+
+只有 `app_spray` 作业引擎调用 `app_pump_set`。键 / DP / 联动 / 计划都只请求「开一轮 / 停」,不直接改 GPIO。
+
+---
+
+## 8. 湿度来源(无本机传感器)
+
+内部统一用 `humi`(uint8,单位 %)。联动比较见 11.3。
+
+| 场景 | 湿度从哪来 | 扫描 | DP6 |
+|---|---|---|---|
+| BLE 已连接 | 服务器写 DP6(uint8 %) | **停** | WO 收下,不存 VM,不上报 |
+| BLE 断连且已配对 | 扫描风扇自定义广播 | **500ms 扫描 / 500ms 重连广播** 交替 | 扫描值 **不写** DP6 |
+| 未配对 / 配对中 / OTA | 不扫描 | 重连或绑定 / OTA 广播 | — |
+
+DP5 当前温度:本版 **写忽略**,不参与开停。
+
+### 8.1 风扇侧广播格式(只读约定,喷淋器不发这种包)
+
+风扇 `app_th_adv.c`:
+
+- Complete Local Name:`JBao-{tank_id}`(十进制,无前导零),例如缸体 1 → `JBao-1`
+- Manufacturer `0xFFFF` 后 3 字节:`[配置版本 uint8][温度 int8 ℃][湿度 uint8 %]`
+
+喷淋器匹配规则:
+
+1. 名字等于 `JBao-` + 本机 DP9 缸体编号(uint16 十进制)。
+2. 配置版本等于 DP9 Byte6。
+3. 两者都满足才更新 `humi = 湿度uint8`。
+4. 名字不对、版本不对、解析失败:**当没收到**,保持上一湿度和上一喷淋态(第 11.3)。
+
+缸体编号为 0:视为无有效联动,不扫描匹配。
+
+### 8.2 500ms 时隙
+
+断连已配对:
+
+```
+tick 偶数:见宝重连广播(encode4reconn)
+tick 奇数:关广播、开扫描 ≤500ms,回调里滤 JBao- 前缀
+已连接 / 配对 / OTA:退出该交替,回到风扇同款 adv 策略
+```
+
+实现时若协议栈不能真正双角色并发,**必须时分**,不允许「边广播边扫」把时序写死成阻塞。
+
+湿度丢失(长时间无匹配包):**保持上一态**,不因丢包停喷。
+
+---
+
+## 9. 本版不用的 DP
+
+| DP | 本版 |
+|---|---|
+| 0 power 总开关 | 写忽略 |
+| 2 mode 模式 | 写忽略(单次 / 连续那套预留语义作废,喷淋参数只走 DP7 / 计划自带字段) |
+| 3 once_spray_time | 写忽略 |
+| 4 continuous_spray_time | 写忽略 |
+| 5 current_temp | 写忽略;不上报 |
+| 19 heartbeat | 写忽略 |
+| 21 fault_code | 保持 0 |
+| 23 alarm_code | 保持 0(缺水告警本版无传感器) |
+
+无 `app_mode`。DP20 / DP22 有写入则原样存 VM。
+
+---
+
+## 10. 喷淋作业引擎
+
+设备同一时刻最多跑 **一轮** 作业。一轮 = 按「单次时长、次数、间隔」把泵开停若干次,然后结束。
+
+### 10.1 有效配置
+
+来源优先级(只决定 **参数**,不决定谁有权开):
+
+1. 计划触发:用 **该条计划自己的** 时长 / 次数 / 间隔(第 12.1)。
+2. 其它开喷(PB1、DP1=1、写 DP7、联动):用 RAM / VM 里的 DP7。
+3. DP7 无效或从未收过:默认 **时长 10s、次数 3、间隔 3s**。
+
+首次上电 VM 无效:把默认写入 RAM,并作为 DP7 上报,当作已有有效配置。
+
+### 10.2 DP7 `spray_config`(8 字节)
+
+| 偏移 | 字段 | 编码 |
+|---|---|---|
+| Byte0–1 | 单次喷淋时长 | uint16 **大端**,单位秒,1~65535 |
+| Byte2 | 喷淋次数 | uint8,1~255 |
+| Byte3–4 | 喷淋间隔 | uint16 **大端**,单位秒,0~65535 |
+| Byte5–7 | 预留 | 只存不用 |
+
+默认上电值(十六进制):`00 0A 03 00 03 00 00 00`。
+
+非法包:**时长=0 或 次数=0**。整包丢弃:不改 RAM / VM,不开喷,保持上一份有效配置。
+
+**间隔=0:** 喷完一次马上喷下一次,中间 **不停泵**(两脚保持高,直到整轮次数用完)。
+
+APP 写 DP7(合法包):`memcpy` 8 字节 → 存 VM → **立刻按新参数开一轮**。若正在喷:中止旧轮再开新轮。记入手动抢权(第 13 章),DP1 报开。
+
+### 10.3 一轮怎么跑
+
+```
+count 次循环:
+ 泵开
+ 等待 duration 秒
+ 若还不是最后一次 且 interval > 0:
+ 泵关
+ 等待 interval 秒
+泵关
+作业结束,DP1 报关
+```
+
+- 整轮期间(含间隔等待)DP1 = 1,绿灯按作业态闪。
+- 轮次自然结束:泵关,DP1 = 0。
+- 20ms 节拍里倒数;不要用阻塞 `delay`。
+
+### 10.4 停止
+
+任一来源请求停:
+
+- 立刻两脚拉低,结束作业,DP1 报关。
+- 来源是 PB1 单击(作业中)或 APP 写 DP1=0:手动作废联动(第 13 章)。
+- 若当前轮是 **类型 0 单次计划** 占用的:该条 Byte0 清 0 并写 VM(被手动中途停也要清,避免单次卡死)。
+
+### 10.5 其它开喷入口
+
+| 入口 | 参数 | 是否手动抢权 |
+|---|---|---|
+| PB1 单击(空闲) | 上次有效 DP7 | 是 |
+| APP 写 DP1=1 | 上次有效 DP7 | 是 |
+| APP 写 DP7 | 新 DP7 | 是 |
+| 计划到点 | 该条 Byte5–9 | 否(计划排队,见 12.3) |
+| 联动请求开 | 上次有效 DP7 | 否 |
+
+APP 写 DP1=1 时若已在作业中:保持当前轮,不重开(幂等)。写 DP1=0 时若已空闲:保持关。
+
+---
+
+## 11. 联动引擎
+
+设备只有 **1 条** 20 字节联动配置(DP9)。布局与加热垫 DP8 相同,本版只用湿度字段。
+
+### 11.1 有效配置
+
+同时满足才跑联动开停:
+
+- 缸体编号 ≠ 0(Byte0–1,uint16 大端)
+- `Hmin ≤ Hmax`
+- `BOARD_LINKAGE_ENABLE=1`
+
+否则视为无联动:不自动开;若原先是联动占着作业则停泵。
+
+温度 min / max、配置版本:整包 20B 原样落盘。温度 **不参与开停**。版本只用于扫描匹配(8.1)。Byte7–19 预留,逻辑忽略。
+
+无单独「联动总开关」。停联动:DP8 写 1、或 DP9 缸体写成 0 / min>max、或手动作废。
+
+### 11.2 20 字节布局(DP9 `linkage_config`)
+
+| 偏移 | 字段 | 编码 |
+|---|---|---|
+| Byte0–1 | 缸体编号 | uint16 **大端** |
+| Byte2 | 最低温度 `Tmin` | 有符号 int8 ℃,只存不用 |
+| Byte3 | 最高温度 `Tmax` | 有符号 int8 ℃,只存不用 |
+| Byte4 | 最低湿度 `Hmin` | uint8 % |
+| Byte5 | 最高湿度 `Hmax` | uint8 % |
+| Byte6 | 配置版本 | uint8,扫描匹配用 |
+| Byte7–19 | 预留 | 只存不用 |
+
+比较用 uint8 湿度:`H < Hmin`、`H > Hmax`。**等于不算越界**,走「中间维持上一态」。
+
+已连接:`humi = gDevData.current_humi`。断连扫描:广播第 3 字节。尚无任何有效湿度:联动不改泵(保持上电关 / 上一态)。
+
+### 11.3 开停(只看湿度)
+
+在有效配置、未被手动作废、仲裁允许联动、且已有有效湿度时,每个 500ms 任务 tick 判一次:
+
+| 条件 | 动作 |
+|---|---|
+| `H < Hmin` | 请求进入喷淋:用 DP7 循环开一轮又一轮(一轮正常结束后立刻再开,无额外间隔) |
+| `H > Hmax` | 立刻停泵、结束作业 |
+| `Hmin ≤ H ≤ Hmax`(含等于) | **保持上一态**(上一态在喷就继续循环,上一态停就保持停) |
+
+过干开、过湿关。无有效湿度 / 丢包:不改。
+
+联动只在 **空闲且未被作废** 时开下一轮。泵被手动 / 计划占着时联动不抢(第 13 章)。
+
+### 11.4 联动删除(DP8)
+
+| 写入 | 行为 |
+|---|---|
+| 0 | 无操作 |
+| 1 | 清 RAM + VM 里 20 字节;若当前是联动占着作业 → 停;然后把 `linkage_delete` **清回 0** 再上报(脉冲) |
+
+### 11.5 APP 重写 DP9
+
+`memcpy` 20 字节 → 存 VM → **清手动作废标志** → 立刻按新阈值重判(有有效湿度时)。缸体编号写成 0:按无效配置,联动占着则停。
+
+---
+
+## 12. 喷淋计划引擎(7 组,到点触发一轮)
+
+**不要抄加热垫时间窗。** 加热垫是开始~结束保持开;本机是「到开始时:分,用该条自己的时长 / 次数 / 间隔跑完一轮就结束」。没有结束时:分。
+
+### 12.1 15 字节(DP11~17)
+
+| 偏移 | 字段 |
+|---|---|
+| Byte0 | 使能 0/1 |
+| Byte1 | 定时类型:0=单次,1=每天,2=每周。其它值该条无效,不触发 |
+| Byte2 | 星期位图 Bit0=周一 … Bit6=周日(仅类型 2) |
+| Byte3 | 开始小时 0~23 |
+| Byte4 | 开始分钟 0~59 |
+| Byte5–6 | 单次时长 uint16 **大端** 秒 |
+| Byte7 | 次数 uint8 |
+| Byte8–9 | 间隔 uint16 **大端** 秒 |
+| Byte10–14 | 预留,只存不用 |
+
+本版 **没有** 加热垫那种「Byte1 模式忽略」。Byte1 就是类型。
+
+非法参数(时长=0 或 次数=0):该条本分钟不触发,使能保持原值。
+
+### 12.2 总开关与 RTC 门闩
+
+`DP10 spray_plan_en`:所有计划的总开关。
+
+```
+usr_rtc_get_time() 失败 → 全部计划不新触发(等同 plan_en=0 的「不触发」)
+仅 BLE 已连接但从未对时 → 同上
+plan_en=0 → 不新触发
+```
+
+`plan_en=0` 或 RTC 无效:**不中断** 已经在跑的一轮(手动 / APP / 联动仍可停)。
+
+无电池,拔电后 RTC 丢失。开机后必须等 APP / 网关 `usr_rtc_set_from_unix` 成功。
+
+对时成功瞬间:若 **当前时:分刚好等于** 某条有效计划的开始时:分,补触发一次;**过了这一分钟不再补**(避免下午计划在晚上对时被补喷)。
+
+### 12.3 触发与排队
+
+每个 500ms 任务 tick:
+
+1. RTC 无效或 `plan_en=0`:不入队。
+2. 类型 1:每天该时:分触发一次。
+3. 类型 2:仅位图置位的星期,该时:分触发一次。
+4. 类型 0:该时:分触发一次;该轮 **正常跑完或被手动停** 后把 Byte0 清 0 并写 VM。
+5. 去重:用「上次触发的本地日期 + 时 + 分 + 槽位」防 500ms 任务在同一分钟内连打。
+6. 多条同一分钟:**排队**,做完一条再做下一条,不重叠喷。
+7. **计划到点时若泵已经在跑**(手动 / 上一计划 / 联动):当前轮先跑完,再跑队列,**不插队中止**。
+8. 新触发只入队,真正 `app_spray_start` 发生在作业空闲且轮到该条时。
+
+### 12.4 一轮结束之后
+
+- 计划轮正常结束:出队;立刻按第 11.3 补判联动(湿度仍 `< Hmin` 且未作废 → 联动接着循环)。
+- 被手动停:作废联动,不再立刻补开;若是单次计划,仍清该条使能。
+
+---
+
+## 13. 仲裁(唯一决策表)
+
+优先级 **高 → 低**:
+
+1. **手动**:PB1、APP 写 DP1、APP 写 DP7
+2. **计划队列**(RTC 有效且 `plan_en=1` 且已到点入队 / 正在跑计划轮)
+3. **联动**(有效配置且本 tick 满足开 / 关条件)
+
+| 冲突 | 胜者 | 副作用 |
+|---|---|---|
+| 手动开(含写 DP7)vs 正在跑的任何轮 | 手动 | 中止当前轮,按新来源参数重开;若原是联动占着 → **本轮联动作废**(RAM,不写 VM) |
+| 手动停 vs 正在跑的任何轮 | 手动 | 立刻停;联动作废;单次计划清使能 |
+| 计划到点 vs 正在跑 | 当前轮先跑完 | 计划入队,不插队 |
+| 计划轮 vs 联动 | 计划 | 计划占着时联动不开下一轮;计划结束收口后立刻按 11.3 重判 |
+| 联动 vs 空闲 | 联动 | 开则开一轮(循环),关则停 |
+
+作废标志不存 VM。
+
+**手动作废后的再武装:** 必须先走到第 11.3 的「关」条件(`H > Hmax`),再重新出现 `H < Hmin`,才允许联动再开。作废期间丢广播仍保持上一态。
+
+**冷启动:** 作废标志是 RAM,上电为空。泵保持关。有有效湿度且无计划占用时,允许联动按 11.3 立刻改(过干则开喷)。
+
+`plan_en` 写成 0:立刻停新触发(不拆当前轮)。无联动总开关;无效配置 / DP8 删除即停联动。
+
+---
+
+## 14. 持久化(VM)
+
+存(冷启动保留):
+
+- `spray_config`(8B,含首次默认 10 / 3 / 3)
+- `linkage_config`(20B)
+- `spray_plan_en`、`spray_plan1` + `time_config2~7`(各 15B)
+- `fault_en`、`alarm_en`(若有写入)
+- `run_record`:31 × uint32 秒 + slot[0] 对应的本地日期(对时后才有意义)
+
+不存:
+
+- DP0 / DP2 / DP3 / DP4 / DP5、DP1 作业开关、DP6 当前湿度、联动作废标志、泵 GPIO
+- `linkage_delete`(脉冲)
+- BLE 三元组(`CFG_USER_PAIR_INFO`,出厂重置 **不清**)
+
+上电:加载上述字段,泵保持关,DP1=0,再让联动按第 13 章冷启动规则判(无湿度则不动)。
+
+首次上电(VM 无效):DP7=默认 10s / 3 次 / 3s,联动 20B 全 0,计划全 0,`spray_plan_en=0`,31 天运行记录全 0,DP1=0。
+
+写 VM 时机:
+
+- APP 改联动 / 计划 / 喷淋配置、单次计划自毁:500ms 防抖
+- 运行记录:累计泵转满 **10 分钟** 写一次;**跨天旋转时立刻写**
+
+### 14.1 运行记录(DP18 `run_record`,RO,124B)
+
+31 天 × 4 字节,每格 uint32 **大端**,单位秒:
+
+| 偏移 | 含义 |
+|---|---|
+| Byte0–3 | 今天喷淋器运行总秒数 |
+| Byte4–7 | 昨天 |
+| … | … |
+| Byte120–123 | 前 30 天 |
+
+**什么叫在运行:** `app_pump_get()==1`(PC2 与 PA6 **都已导通**)。间隔等待、只开了一只脚、作业态为开但泵已关,**都不计**。手动 / 计划 / 联动只要泵真正在转都算。
+
+**日期轴:** 与加热垫 / 风扇相同 —— 未对时只往 slot[0] 加、不旋转;对时后按本地日历左移;掉电再上电先灌 VM。
+
+**落盘:** 每累计运行满 600 秒写一次 VM。跨天立刻写。拔电接受最多丢不满 10 分钟的秒数。
+
+`BOARD_RUNREC_ENABLE=0`:不累加、DP18 报全 0。
+
+---
+
+## 15. 出厂重置(DP24 写 1)
+
+- **清全部业务 VM**(第 14 章列表,含 DP7 默认恢复、联动 20B、7 条计划、31 天运行记录)。
+- **保留 BLE 配对,不断开、不软复位。**
+- RAM 中泵停、DP1=0,联动 / 计划停止,作废标志清掉;DP7 回到默认 10 / 3 / 3。
+- 换机:运行中 **按住配网键 5s 后松手** 清配对进绑定广播(见 5.2)。
+
+---
+
+## 16. BLE / DP 行为摘要
+
+身份:`CBR2` / `PRCBR2`。帧格式见 `jb_protocol.h`(`MAX_PACKAGE_LEN=512`)。
+
+| DP | 处理 |
+|---|---|
+| 0 power | 本版写忽略 |
+| 1 spray_switch | 写 1 开一轮(已在喷则保持);写 0 立刻停。上报 **作业态**(整轮含间隔为 1) |
+| 2–4 | 本版写忽略 |
+| 5 current_temp | 本版写忽略;不上报 |
+| 6 current_humi | **WO**。已连接时服务器下发 → 更新联动用湿度。不上报。断连扫描不填 |
+| 7 spray_config | 8 字节;合法则存 VM + 立刻开一轮(第 10.2) |
+| 8 linkage_delete | 第 11.4 节,脉冲 |
+| 9 linkage_config | 20 字节 memcpy + 存 VM + 第 11.5 节立刻重判 |
+| 10 spray_plan_en | 见第 12 章 |
+| 11–17 计划 | 15 字节 + 存 VM |
+| 18 run_record | RO,由 `app_runrec` 填 124B,禁止 APP 写 |
+| 19 heartbeat | 本版忽略 |
+| 20 fault_en | 原样存,P0 不做位级过滤 |
+| 21 fault_code | 保持 0 |
+| 22 alarm_en | 原样存 |
+| 23 alarm_code | 保持 0 |
+| 24 factory_reset | 第 15 章 |
+| 25–27 reserva | 存 RAM,可不实现动作 |
+
+OTA:运行态配网键按住 12s 走 `usr_app_ota_init`(任务上下文,红绿交替)。协议 OTA(`jbOnOtaNotify`)**本版 stub**,禁止假装升级成功后擦用户区;加热垫同款直接 `JB_OTA_STATE_ERR` 也可。
+
+配对超时 120s,失败保持开机,灯回「红灭 + 绿按作业 / 空闲」。
+
+断连已配对:500ms 见宝重连广播 ↔ 500ms 扫描(第 8.2)。绑定广播仍用 `CBR2` / `PRCBR2`,不要改成 `JBao-` 名(那是风扇温湿度窗)。
+
+---
+
+## 17. 建议文件清单(新建,加热垫 / 风扇已有的拷贝改)
+
+```
+apps/usr_periph/
+ board_pin.h # 引脚、时序、BOARD_xxx_ENABLE
+ bsp_hw.c/.h # LED 双通道 / KEY×2 / 泵双脚 GPIO
+ usr_rtc.c/.h # 已有则保留
+
+apps/usr_app/
+ key.c/.h, key_manager.c/.h # 拷风扇;两实例:喷淋短按 + 配网 5s/12s
+ led.c/.h, led_manager.c/.h # 拷门夹双通道 + 红绿交替;事件表按第 6 章
+ app_led.c/.h # 绿=作业,红=配对;OTA / 失败占双灯
+ app_defer.c/.h # 拷加热垫 / 风扇
+ app_pair.c/.h # 拷加热垫,红灯通道跟配对态
+ app_pump.c/.h # 双脚成对切,禁止单脚接口
+ app_spray.c/.h # 第 10 章作业引擎(唯一切泵者)
+ app_th_scan.c/.h # 500ms 交替扫描 JBao-{tank},取湿度
+ app_linkage.c/.h # 第 11 章湿度回差
+ app_runrec.c/.h # 泵实际导通计秒
+ app_timer_task.c/.h # 第 12 章到点触发 + 排队
+ app_nvm.c/.h
+ app_function.c/.h
+ app_arbiter.c/.h
+```
+
+`usr_jb_main.c` 只编排,不写算法。禁止引入 `app_fan` / `app_disp` / `app_th` / `app_mode` / `app_relay` / `app_zc`。
+
+---
+
+## 18. 分阶段交付与验收
+
+### P0 — 台上 Bring-up(不依赖 APP)
+
+**目标:** 双键、双灯、双脚泵可独立验证。
+
+- [ ] `board_pin.h` + UART TX=PC5,打印正常
+- [ ] 插电即开机(不按键,绿灯常亮,泵两脚低;未配对红灯灭)
+- [ ] 单击 PB1:泵两脚同时高,按默认 10s × 3 次、间隔 3s 跑完;作业中绿灯 500ms 闪;结束后绿灯常亮、泵关
+- [ ] 作业中再点 PB1:立刻双脚低、绿灯回常亮
+- [ ] 示波器 / 逻辑:禁止出现「只高一只脚」
+- [ ] 间隔 3s 期间两脚都低,但绿灯仍闪(作业态未结束)
+- [ ] PA0 满 5s → 红灯 100ms 闪,绿灯不被关掉(仍常亮或仍闪)
+- [ ] 5s 后松手 → 清绑定进配对(红灯继续闪)
+- [ ] 满 5s 不松手到 12s → 不进配对,红绿交替进 OTA
+- [ ] 长按进配对 / OTA 后不复位(动作走任务上下文)
+- [ ] 上电强制 `usr_goto_pair=1` 已删除
+- [ ] 灯脚确认:PA8=红、PA7=绿(不要抄加热垫)
+
+**P0 关闭:** `BOARD_LINKAGE/TIMER/TH_SCAN/RUNREC/NVM_ENABLE=0`
+
+### P1 — 最小产品闭环
+
+- [ ] 5s 配对,APP 能写 DP1 开关喷淋;DP1 报作业态(间隔期间仍为 1)
+- [ ] APP 写合法 DP7:立刻按新参数开喷;PB1 再点能停
+- [ ] 非法 DP7(时长 0 或次数 0):不改配置、不开喷
+- [ ] 短按同步上报 DP1
+- [ ] VM:改 DP7 后断电,重新上电 **不自动喷**,但单击按上次配置喷
+- [ ] 首次上电 DP7 上报默认 `00 0A 03 00 03 00 00 00`
+- [ ] DP0 / 2 / 3 / 4 / 5 / 19 写忽略;设备不关、泵不跟这些 DP
+- [ ] DP6 WO:APP / 服务器能写下湿度;设备全量上报 **不含** DP6 / DP5
+- [ ] 计划 7 组 + `spray_plan_en` 能存;`BOARD_TIMER_ENABLE=0` 时不执行
+- [ ] `MAX_PACKAGE_LEN=512`,APP 能读完整 124B 运行记录(本阶段记录可仍为 0)
+
+### P2 — 自动化
+
+- [ ] 断连:500ms 重连广播 / 500ms 扫描;已连接停扫描
+- [ ] 匹配 `JBao-{缸体号}` 且版本相同才更新湿度;温度不参与开停
+- [ ] 联动:`HHmax` 停,中间保持;无匹配包保持上一态
+- [ ] 缸体号 0 / min>max 不跑联动
+- [ ] 手动作废后须先 `H>Hmax` 再 `HHmax`,再回到 `HHmax` → 停。
+11. APP 写合法 DP7 → 中止当前轮并按新参数重开,联动作废。
+12. 间隔等待期间 DP1 仍为 1,绿灯仍闪,运行记录 **不加** 间隔秒。
+
+---
+
+## 19. `board_pin.h` 时序宏(实现时抄这份数)
+
+```c
+#define BOARD_UART_TX_PIN IO_PORTC_05
+#define BOARD_KEY_SPRAY_PIN IO_PORTB_01 /* 喷淋键,只短按 */
+#define BOARD_KEY_PAIR_PIN IO_PORTA_00 /* 配网键,5s / 12s */
+#define BOARD_LED_RED_PIN IO_PORTA_08 /* 红灯,低亮;与加热垫相反 */
+#define BOARD_LED_GREEN_PIN IO_PORTA_07 /* 绿灯,低亮;与加热垫相反 */
+#define BOARD_PUMP_PWR_PIN IO_PORTC_02 /* 泵 5V 供电,高导通 */
+#define BOARD_PUMP_SW_PIN IO_PORTA_06 /* 泵开关,高导通 */
+
+#define BOARD_LED_ON_LEVEL 0
+#define BOARD_PUMP_ON_LEVEL 1
+#define BOARD_KEY_ACTIVE_LEVEL 0
+
+#define BOARD_KEY_PAIR_MS 5000
+#define BOARD_KEY_OTA_MS 12000
+#define BOARD_PAIR_TIMEOUT_MS (120u * 1000u)
+
+#define BOARD_LED_FAST_ON_MS 100 /* 配网红灯快闪 */
+#define BOARD_LED_FAST_OFF_MS 100
+#define BOARD_LED_SPRAY_ON_MS 500 /* 作业绿灯闪 */
+#define BOARD_LED_SPRAY_OFF_MS 500
+#define BOARD_LED_OTA_ON_MS 200
+#define BOARD_LED_OTA_OFF_MS 200
+#define BOARD_LED_PAIR_FAIL_MS 200 /* 红绿交替半周期 */
+#define BOARD_LED_PAIR_FAIL_HOLD_MS 3000
+
+#define BOARD_ADV_SCAN_SLOT_MS 500 /* 重连广播 / 扫描各占一拍 */
+
+#define BOARD_SPRAY_DEFAULT_DUR_S 10
+#define BOARD_SPRAY_DEFAULT_COUNT 3
+#define BOARD_SPRAY_DEFAULT_INTERVAL_S 3
+
+#define BOARD_RUN_RECORD_DAYS 31
+#define BOARD_RUN_RECORD_SAVE_SEC 600
+```
+
+---
+
+## 20. 风险与不要擅自发挥的点
+
+| 风险 | 要求 |
+|---|---|
+| 只开 PC2 或只开 PA6 | 禁止。泵接口必须成对 |
+| 上电按 VM 自动喷 | 禁止。水会喷出来 |
+| 抄加热垫灯脚(PA8 绿 / PA7 红) | 禁止。本机 PA8 红 / PA7 绿 |
+| 抄加热垫互斥灯(开红关绿) | 禁止。红绿独立,空闲不要关红灯 |
+| 抄加热垫时间窗定时 | 禁止。本机是到点触发一轮 |
+| 抄加热垫过零 / 继电器 | 禁止 |
+| 湿度当温度、或温度参与开停 | 本版禁止 |
+| 丢广播就停喷 | 禁止(已拍板保持上一态) |
+| 计划过点后对时补喷 | 禁止;只补「当前这一分钟」 |
+| 计划到点插队中止手动轮 | 禁止;排队 |
+| 生成协议再导回 LEN=128 | 禁止;必须 512 |
+| `app_main` 上电清配对进绑定 | 必须删 |
+| 20ms ISR 写 VM / 切 BLE | 禁止(风扇已踩过栈溢出) |
+| 一排裸 static / 排障史注释 | 禁止。≥2 个状态收进一个 `s_xxx`。详见第 0.1 节 |
+| 协议 OTA stub 返回 OK 当已升级 | 注释标明未升级,或直接 `JB_OTA_STATE_ERR` |
+| 扫描时改 GATT 名 | 不要改 0x2A00;只解析对方 adv |
+| 把 DP1 理解成「某一只脚的电平」 | DP1 是作业态,间隔期间仍为开 |
+| 把 DP3 / DP4 当喷淋参数 | 禁止;参数只走 DP7 和计划自带字段 |
+| 喷淋键上做 5s / 12s | 禁止;配网只在 PA0 |
+
+---
+
+## 21. 给 WorkBuddy 的推荐开工顺序
+
+1. 改 `MAX_PACKAGE_LEN=512`。`jb_product.c` 只保留 DP 赋值,业务迁 `app_*`。删 `app_main` 上电强制配对。
+2. `board_pin.h` + `bsp_hw`(双灯双键双脚泵);UART TX=PC5。
+3. 从加热垫拷 `usr_jb_main` 框架、defer、pair、nvm 骨架;key 改两实例;LED 拷门夹双通道,事件表按第 6 章重写。
+4. `app_pump` + `app_spray`:P0 验收单击默认 10/3/3、中途停、灯独立。
+5. P1:DP1 / DP7、VM 不恢复开喷、忽略 DP、计划只存。
+6. P2:`app_th_scan`、`app_linkage`、到点计划 + 排队、`app_runrec`、仲裁用例。
+
+每一步都保持对应 `BOARD_xxx_ENABLE` 可关。提交前对照第 0.1 节:一个模块一个 `s_xxx`,注释写为什么,不要排障日记。
+
+---
+
+**文档结束。** 行为争议以本文第 5~14 章为准;字节布局以第 10.2 / 11.2 / 12.1 / 14.1 节为准。
diff --git a/Makefile b/Makefile
index 3b8cbd5..4f317fa 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,6 +398,17 @@ 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_app/app_defer.c \
+ apps/usr_app/app_function.c \
+ apps/usr_app/app_led.c \
+ apps/usr_app/app_pair.c \
+ apps/usr_app/app_pump.c \
+ apps/usr_app/app_spray.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_periph/bsp_hw.c \
apps/usr_periph/usr_rtc.c \
apps/usr_le_code/ble_proto.c \
apps/usr_le_code/bleproto.c \
diff --git a/apps/earphone/app_main.c b/apps/earphone/app_main.c
index 4aca9ac..b032a63 100644
--- a/apps/earphone/app_main.c
+++ b/apps/earphone/app_main.c
@@ -326,14 +326,9 @@ 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();
+ /* 配对只由运行态长按 PA0 5s(key_manager 分级)置 usr_goto_pair;
+ * 禁止上电清三元组(规格书 5.1/5.2/14 章:5V 常电上电即开机,
+ * 已配对走重连广播)。 */
ui_manage_init();
ui_update_status(STATUS_POWERON);
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..f358c63
--- /dev/null
+++ b/apps/usr_app/app_function.c
@@ -0,0 +1,124 @@
+/******************************************************************************
+ * @file app_function.c
+ * @brief 功能总装:喷淋键启停、任务上下文落地配对/OTA(P0 版)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ * @history
+ * - V1.0.0, 2026.09.09, cyWu, P0 首次发布(无 VM/联动/计划;P1+ 再扩展)
+ *
+ * @note ⚠ 硬约束:写 flash(清绑定)、BLE 广播切换(配对/OTA)禁止在
+ * 20ms 中断上下文直跑,一律 app_defer_request 后由本模块在
+ * usr_timer_loop(任务上下文)落地。
+ ******************************************************************************/
+
+#include "system/includes.h" /* 必须先于 app_main.h:u8/u16/u32 等类型由 SDK 定义 */
+#include "app_function.h"
+#include "app_pair.h"
+#include "app_defer.h"
+#include "app_led.h"
+#include "app_spray.h"
+#include "app_main.h"
+#include "usr_le_api.h"
+#include "jb_product.h"
+#include "board_pin.h"
+
+#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)
+{
+ if (app_defer_take(APP_DEFER_OTA)) {
+ app_defer_cancel(APP_DEFER_PAIR);
+ usr_app_ota_init();
+ app_led_request_ota();
+ log_info("pair 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("pair key released after 5s -> pair\n");
+ }
+}
+
+/**
+ * @brief 同步 DP1 = 喷淋作业态(整轮含间隔等待为 1)
+ * @return 无
+ */
+void app_function_poll(void)
+{
+#if BOARD_SPRAY_ENABLE
+ gDevData.spray_switch = app_spray_is_running();
+#endif
+}
+
+/**
+ * @brief 喷淋键短按:空闲用上次有效配置开一轮,作业中立刻停(OTA 中忽略)
+ * @return 无
+ */
+void usr_on_key_short(void)
+{
+ if (app_led_is_ota()) {
+ return; /* OTA 进行中忽略(规格书 5.3) */
+ }
+ if (app_spray_is_running()) {
+ app_spray_stop();
+ log_info("spray key short -> stop\n");
+ } else {
+ app_spray_start();
+ log_info("spray key short -> start\n");
+ }
+}
+
+/**
+ * @brief 配网键按住满 5s:红灯 100ms 快闪提示(不碰绿灯,不清绑定)
+ * @return 无
+ */
+void usr_on_key_pair_hint(void)
+{
+ if (app_led_is_ota() || app_pair_is_active()) {
+ return; /* OTA / 配对会话中忽略再次 5s(规格书 5.3) */
+ }
+ app_led_hint_pair();
+ log_info("pair key hold 5s, red fast blink (release to pair, keep to 12s ota)\n");
+}
+
+/**
+ * @brief 配网键按满 5s 后松手:置配对标志(清绑定在任务上下文)
+ * @return 无
+ */
+void usr_on_key_pair_start(void)
+{
+ if (app_led_is_ota() || app_pair_is_active()) {
+ return; /* OTA / 配对会话中忽略(规格书 5.3) */
+ }
+ app_defer_request(APP_DEFER_PAIR);
+ log_info("pair key 5s released, pair pending\n");
+}
+
+/**
+ * @brief 配网键按住满 12s:置 OTA 标志(BLE 切换在任务上下文)
+ * @return 无
+ */
+void usr_on_key_ota_start(void)
+{
+ if (app_led_is_ota() || app_pair_is_active()) {
+ return; /* OTA / 配对会话中忽略(规格书 5.3) */
+ }
+ app_defer_request(APP_DEFER_OTA);
+ log_info("pair key hold 12s, ota pending\n");
+}
diff --git a/apps/usr_app/app_function.h b/apps/usr_app/app_function.h
new file mode 100644
index 0000000..b3b3b48
--- /dev/null
+++ b/apps/usr_app/app_function.h
@@ -0,0 +1,61 @@
+/******************************************************************************
+ * @file app_function.h
+ * @brief 功能总装:按键动作 + DP 同步(配对/OTA 只在任务上下文落地)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ ******************************************************************************/
+
+#ifndef __APP_FUNCTION_H__
+#define __APP_FUNCTION_H__
+
+#include
+
+/**
+ * @brief 初始化功能模块状态
+ * @return 无
+ */
+void app_function_init(void);
+
+/**
+ * @brief 同步 DP(DP1=喷淋作业态),每个 20ms 业务 tick 调用
+ * @note 上报的是**作业态**(整轮含间隔等待为 1),不是某一只脚的瞬时电平
+ * @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);
+
+#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..0688387
--- /dev/null
+++ b/apps/usr_app/app_led.c
@@ -0,0 +1,198 @@
+/******************************************************************************
+ * @file app_led.c
+ * @brief 灯效:绿=作业态、红=配对态;OTA / 配对失败临时占双灯(规格书 6 章)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ * @history
+ * - V1.0.0, 2026.09.09, cyWu, P0 独立通道版(不同于加热垫互斥灯)
+ *
+ * @note 红绿独立:喷淋/空闲只改绿灯,不要去关红灯;配网闪红灯时不要关绿灯。
+ * 只有 OTA 与配对失败动画临时占用双灯。事件表见 led_manager.c。
+ ******************************************************************************/
+
+#include "app_led.h"
+#include "led_manager.h"
+#include "bsp_hw.h"
+#include "board_pin.h"
+#include "app_pair.h"
+#include "app_spray.h"
+#include "usr_le_api.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_EventDelete(LED_EVENT_RED_PAIRED); /* 快闪盖过"已配对常亮"(同槽换事件) */
+ LED_EventAdd(LED_EVENT_RED_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;
+ uint8_t high_event;
+
+ 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) {
+ s_led.pair_hint = 0;
+ LED_EventDelete(LED_EVENT_RED_PAIRING);
+ LED_EventDelete(LED_EVENT_PAIR_FAIL);
+ log_info("led pair ok -> red solid\n");
+ } else if (pair_evt == APP_PAIR_EVT_FAIL) {
+ s_led.pair_hint = 0;
+ LED_EventDelete(LED_EVENT_RED_PAIRED);
+ LED_EventDelete(LED_EVENT_RED_PAIRING);
+ led_restart(LED_EVENT_PAIR_FAIL); /* 交替 3s,播完自删回常态 */
+ log_info("led pair fail 3s\n");
+ }
+
+ high_event = (uint8_t)(LED_IsEventExist(LED_EVENT_PAIR_FAIL)
+ || LED_IsEventExist(LED_EVENT_OTA));
+
+ /* 常态双通道(高事件占双灯期间不 add,播完自删后下拍自动恢复) */
+ if (!high_event) {
+ /* 红灯通道:快闪(提示/配对中) > 常亮(已配对) > 灭(未配对) */
+ if (s_led.pair_hint || app_pair_is_active()) {
+ LED_EventAdd(LED_EVENT_RED_PAIRING);
+ } else if (usr_get_pair_flag()) {
+ LED_EventAdd(LED_EVENT_RED_PAIRED);
+ } else {
+ LED_EventDelete(LED_EVENT_RED_PAIRED);
+ LED_EventDelete(LED_EVENT_RED_PAIRING);
+ }
+ /* 绿灯通道:作业态(含间隔)闪,否则常亮 */
+ if (app_spray_is_running()) {
+ LED_EventAdd(LED_EVENT_GRN_SPRAY);
+ } else {
+ LED_EventAdd(LED_EVENT_GRN_IDLE);
+ }
+ }
+
+ 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..8505e9f
--- /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.09
+ * @version V1.0.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 绿灯跟作业态(app_spray_is_running,含间隔等待);红灯跟配对态。
+ * 同时推进配对会话计时(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_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_pump.c b/apps/usr_app/app_pump.c
new file mode 100644
index 0000000..cf13b2e
--- /dev/null
+++ b/apps/usr_app/app_pump.c
@@ -0,0 +1,90 @@
+/******************************************************************************
+ * @file app_pump.c
+ * @brief 水泵双脚成对控制:PC2 供 5V + PA6 导通,同开同关
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ * @history
+ * - V1.0.0, 2026.09.09, cyWu, P0 首次发布
+ *
+ * @note 禁止只开一只脚;上电两脚默认低、绝不自动开泵。本模块不保存"开",
+ * 是否开由上层 app_spray 作业引擎决定(规格书 7.2,app_pump_set 只
+ * 由 app_spray 调用)。泵无过零/继电器,请求态==实际态。
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "app_pump.h"
+#include "bsp_hw.h"
+#include "board_pin.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[APP_PUMP]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+/** 模块状态:on 只被 app_spray 在 20ms 节拍里改写 */
+typedef struct {
+ uint8_t initialized;
+ volatile uint8_t on; /**< 1=泵转(两脚都已导通),0=泵停 */
+} PumpCtx_t;
+
+static PumpCtx_t s_pump;
+
+/**
+ * @brief 初始化泵模块(GPIO 成对低已在 bsp_hw_init 完成)
+ * @return APP_PUMP_OK=成功
+ */
+AppPump_Ret_t app_pump_init(void)
+{
+ if (s_pump.initialized) {
+ return APP_PUMP_OK;
+ }
+#if BOARD_PUMP_ENABLE
+ bsp_pump_set(0); /* 兜底:两脚都低 */
+ s_pump.on = 0;
+ s_pump.initialized = 1;
+#else
+ log_info("pump disabled\n");
+#endif
+ return APP_PUMP_OK;
+}
+
+/**
+ * @brief 成对切泵:on=1 两脚同时高,on=0 两脚同时低
+ * @param on 1=开,0=关
+ * @return 无
+ */
+void app_pump_set(uint8_t on)
+{
+#if BOARD_PUMP_ENABLE
+ if (!s_pump.initialized) {
+ return;
+ }
+ bsp_pump_set(on ? 1 : 0);
+ s_pump.on = on ? 1 : 0;
+#else
+ (void)on;
+#endif
+}
+
+/**
+ * @brief 读泵当前态(请求态即实际态)
+ * @return 1=泵转,0=泵停
+ */
+uint8_t app_pump_get(void)
+{
+#if BOARD_PUMP_ENABLE
+ return s_pump.on;
+#else
+ return 0;
+#endif
+}
+
+/**
+ * @brief 查询模块是否已初始化
+ * @return APP_PUMP_OK=已初始化,APP_PUMP_ERR_NOT_INIT=未初始化
+ */
+AppPump_Ret_t app_pump_get_status(void)
+{
+ return s_pump.initialized ? APP_PUMP_OK : APP_PUMP_ERR_NOT_INIT;
+}
diff --git a/apps/usr_app/app_pump.h b/apps/usr_app/app_pump.h
new file mode 100644
index 0000000..560494f
--- /dev/null
+++ b/apps/usr_app/app_pump.h
@@ -0,0 +1,45 @@
+/******************************************************************************
+ * @file app_pump.h
+ * @brief 水泵双脚成对控制(PC2 供 5V + PA6 导通)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ ******************************************************************************/
+
+#ifndef __APP_PUMP_H__
+#define __APP_PUMP_H__
+
+#include
+
+typedef enum {
+ APP_PUMP_OK = 0,
+ APP_PUMP_ERR_NOT_INIT
+} AppPump_Ret_t;
+
+/**
+ * @brief 初始化泵模块:GPIO 由 bsp_hw_init 成对置低,本函数只清状态
+ * @return APP_PUMP_OK=成功
+ */
+AppPump_Ret_t app_pump_init(void);
+
+/**
+ * @brief 成对切泵:on=1 两脚同时高(泵转),on=0 两脚同时低(泵停)
+ * @note ⚠ 唯一允许改泵的出口是 app_spray 作业引擎(规格书 7.2)。
+ * @param on 1=开,0=关
+ * @return 无
+ */
+void app_pump_set(uint8_t on);
+
+/**
+ * @brief 读泵当前态(请求态即实际态:无过零/继电器)
+ * @return 1=泵转(两脚都通),0=泵停
+ */
+uint8_t app_pump_get(void);
+
+/**
+ * @brief 查询模块是否已初始化
+ * @return APP_PUMP_OK=已初始化,APP_PUMP_ERR_NOT_INIT=未初始化
+ */
+AppPump_Ret_t app_pump_get_status(void);
+
+#endif /* __APP_PUMP_H__ */
diff --git a/apps/usr_app/app_spray.c b/apps/usr_app/app_spray.c
new file mode 100644
index 0000000..8db8ef0
--- /dev/null
+++ b/apps/usr_app/app_spray.c
@@ -0,0 +1,208 @@
+/******************************************************************************
+ * @file app_spray.c
+ * @brief 喷淋作业引擎:一轮 = 单次时长 × 次数 + 间隔(规格书第 10 章)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ * @history
+ * - V1.0.0, 2026.09.09, cyWu, P0 首次发布(默认 10s×3 次+3s,只跑一轮)
+ *
+ * @note 本模块是**唯一切泵者**(规格书 7.2):键/DP/联动/计划都只请求
+ * "开一轮 / 停"。20ms 节拍倒数,禁止阻塞 delay。间隔等待也算作业态
+ * (DP1=1、绿灯继续闪),但泵已关、不记运行秒(P2 由 app_runrec 负责)。
+ ******************************************************************************/
+
+#include "system/includes.h"
+#include "app_spray.h"
+#include "app_pump.h"
+#include "jb_product.h"
+#include "board_pin.h"
+
+#define LOG_TAG_CONST APP
+#define LOG_TAG "[APP_SPRAY]"
+#define LOG_INFO_ENABLE
+#include "debug.h"
+
+/** 作业阶段 */
+typedef enum {
+ SPRAY_PHASE_PUMP_ON = 0, /**< 泵开段:倒数单次时长 */
+ SPRAY_PHASE_WAIT /**< 间隔段:泵关,倒数间隔(仅 interval>0 出现) */
+} SprayPhase_t;
+
+/** 模块状态:全程由 20ms 业务节拍(usr_timer_tick)驱动,无跨上下文竞争 */
+typedef struct {
+ uint8_t initialized;
+ uint8_t state; /**< 0=空闲,1=作业中 */
+ uint8_t phase; /**< 当前段(SPRAY_PHASE_*) */
+ uint16_t total_count; /**< 本轮总次数(来自 DP7) */
+ uint16_t round; /**< 已完成的次数 */
+ uint32_t dur_ms; /**< 单次时长 ms */
+ uint32_t wait_ms; /**< 间隔 ms(0=中间不停泵) */
+ uint32_t remain_ms; /**< 当前段剩余 ms */
+} SprayCtx_t;
+
+static SprayCtx_t s_spray;
+
+/**
+ * @brief 从 gDevData.spray_config 解析本轮参数(非法回退默认)
+ * @note DP7 布局(规格书 10.2):Byte0-1 时长 uint16 大端、Byte2 次数、
+ * Byte3-4 间隔 uint16 大端。非法 = 时长 0 或次数 0。
+ * @return 无
+ */
+static void spray_parse_cfg(void)
+{
+ const uint8_t *p = gDevData.spray_config;
+ uint16_t dur = (uint16_t)(((uint16_t)p[0] << 8) | p[1]);
+ uint8_t cnt = p[2];
+ uint16_t itv = (uint16_t)(((uint16_t)p[3] << 8) | p[4]);
+
+ if ((dur == 0) || (cnt == 0)) {
+ /* 从未收到合法 DP7(P0 无 VM,理论上 userInit 已写默认,此处兜底) */
+ dur = BOARD_SPRAY_DEFAULT_DUR_S;
+ cnt = BOARD_SPRAY_DEFAULT_COUNT;
+ itv = BOARD_SPRAY_DEFAULT_INTERVAL_S;
+ log_info("dp7 invalid, use default %us x%u + %us\n",
+ (unsigned)dur, (unsigned)cnt, (unsigned)itv);
+ }
+
+ s_spray.dur_ms = (uint32_t)dur * 1000u;
+ s_spray.wait_ms = (uint32_t)itv * 1000u;
+ s_spray.total_count = cnt;
+}
+
+/**
+ * @brief 初始化作业引擎(泵保持关,参数待 start 时解析)
+ * @return APP_SPRAY_OK=成功
+ */
+AppSpray_Ret_t app_spray_init(void)
+{
+ if (s_spray.initialized) {
+ return APP_SPRAY_OK;
+ }
+#if BOARD_SPRAY_ENABLE
+ app_pump_set(0); /* 上电绝不喷 */
+ s_spray.state = 0;
+ s_spray.phase = SPRAY_PHASE_PUMP_ON;
+ s_spray.round = 0;
+ s_spray.remain_ms = 0;
+ s_spray.initialized = 1;
+#else
+ log_info("spray disabled\n");
+#endif
+ return APP_SPRAY_OK;
+}
+
+/**
+ * @brief 开一轮(幂等:作业中不重开)
+ * @return 无
+ */
+void app_spray_start(void)
+{
+ if (!s_spray.initialized) {
+ return;
+ }
+ if (s_spray.state != 0) {
+ return; /* 已在作业中:保持当前轮(规格书 10.5 幂等) */
+ }
+
+ spray_parse_cfg();
+ s_spray.round = 0;
+ s_spray.phase = SPRAY_PHASE_PUMP_ON;
+ s_spray.remain_ms = s_spray.dur_ms;
+ s_spray.state = 1;
+ app_pump_set(1); /* 两脚同时高 */
+ log_info("spray start dur=%u ms x%u wait=%u ms\n",
+ (unsigned)s_spray.dur_ms, (unsigned)s_spray.total_count,
+ (unsigned)s_spray.wait_ms);
+}
+
+/**
+ * @brief 停:立刻两脚拉低、结束作业
+ * @return 无
+ */
+void app_spray_stop(void)
+{
+ if (!s_spray.initialized) {
+ return;
+ }
+ if (s_spray.state == 0) {
+ return; /* 已空闲,保持关(幂等) */
+ }
+ app_pump_set(0); /* 两脚同时低 */
+ s_spray.state = 0;
+ s_spray.round = 0;
+ s_spray.remain_ms = 0;
+ log_info("spray stop\n");
+}
+
+/**
+ * @brief 作业节拍:段倒数与切泵(20ms 调用)
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void app_spray_poll(uint16_t dt)
+{
+ if (!s_spray.initialized || (s_spray.state == 0)) {
+ return;
+ }
+ if (dt == 0) {
+ return;
+ }
+
+ if (s_spray.phase == SPRAY_PHASE_PUMP_ON) {
+ /* 泵开段倒数 */
+ if (s_spray.remain_ms <= dt) {
+ s_spray.round++;
+ if (s_spray.round >= s_spray.total_count) {
+ /* 次数用尽:作业自然结束,泵关 */
+ app_pump_set(0);
+ s_spray.state = 0;
+ s_spray.remain_ms = 0;
+ log_info("spray round done, pump off\n");
+ return;
+ }
+ if (s_spray.wait_ms > 0) {
+ /* 进入间隔段:泵关等待 */
+ s_spray.phase = SPRAY_PHASE_WAIT;
+ s_spray.remain_ms = s_spray.wait_ms;
+ app_pump_set(0);
+ } else {
+ /* 间隔=0:不停泵,马上喷下一次 */
+ s_spray.remain_ms = s_spray.dur_ms;
+ }
+ } else {
+ s_spray.remain_ms -= dt;
+ }
+ } else {
+ /* 间隔段倒数 */
+ if (s_spray.remain_ms <= dt) {
+ s_spray.phase = SPRAY_PHASE_PUMP_ON;
+ s_spray.remain_ms = s_spray.dur_ms;
+ app_pump_set(1); /* 两脚同时高 */
+ } else {
+ s_spray.remain_ms -= dt;
+ }
+ }
+}
+
+/**
+ * @brief 查询是否处于作业态(含间隔等待,整轮期间为 1)
+ * @return 1=作业中,0=空闲
+ */
+uint8_t app_spray_is_running(void)
+{
+#if BOARD_SPRAY_ENABLE
+ return (uint8_t)(s_spray.state != 0);
+#else
+ return 0;
+#endif
+}
+
+/**
+ * @brief 查询模块是否已初始化
+ * @return APP_SPRAY_OK=已初始化,APP_SPRAY_ERR_NOT_INIT=未初始化
+ */
+AppSpray_Ret_t app_spray_get_status(void)
+{
+ return s_spray.initialized ? APP_SPRAY_OK : APP_SPRAY_ERR_NOT_INIT;
+}
diff --git a/apps/usr_app/app_spray.h b/apps/usr_app/app_spray.h
new file mode 100644
index 0000000..ab6d3bf
--- /dev/null
+++ b/apps/usr_app/app_spray.h
@@ -0,0 +1,57 @@
+/******************************************************************************
+ * @file app_spray.h
+ * @brief 喷淋作业引擎:一轮 = 单次时长 × 次数 + 间隔(规格书第 10 章)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ ******************************************************************************/
+
+#ifndef __APP_SPRAY_H__
+#define __APP_SPRAY_H__
+
+#include
+
+typedef enum {
+ APP_SPRAY_OK = 0,
+ APP_SPRAY_ERR_NOT_INIT
+} AppSpray_Ret_t;
+
+/**
+ * @brief 初始化作业引擎(读 DP7 有效配置,泵保持关)
+ * @return APP_SPRAY_OK=成功
+ */
+AppSpray_Ret_t app_spray_init(void);
+
+/**
+ * @brief 开一轮:用 RAM 里最近有效 DP7 配置(非法回退 10s×3 次+3s)
+ * @note 幂等:已在作业中则保持当前轮不重开(规格书 10.5,PB1/DP1 语义)。
+ * @return 无
+ */
+void app_spray_start(void);
+
+/**
+ * @brief 停:立刻两脚拉低、结束作业(间隔等待中也立即停)
+ * @return 无
+ */
+void app_spray_stop(void);
+
+/**
+ * @brief 作业节拍:时长/间隔倒数,到点切泵(每 20ms 调用,可中断上下文)
+ * @param dt 距上次调用的毫秒数
+ * @return 无
+ */
+void app_spray_poll(uint16_t dt);
+
+/**
+ * @brief 查询是否处于作业态(含间隔等待,整轮期间为 1)
+ * @return 1=作业中,0=空闲
+ */
+uint8_t app_spray_is_running(void);
+
+/**
+ * @brief 查询模块是否已初始化
+ * @return APP_SPRAY_OK=已初始化,APP_SPRAY_ERR_NOT_INIT=未初始化
+ */
+AppSpray_Ret_t app_spray_get_status(void);
+
+#endif /* __APP_SPRAY_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..1d955a7
--- /dev/null
+++ b/apps/usr_app/key.h
@@ -0,0 +1,101 @@
+/******************************************************************************
+ * @file key.h
+ * @brief 按键状态机:短按 / 长按 / 释放(两键)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ * @history
+ * - V1.0.0, 2026.09.09, cyWu, CBR2 两键版(PB1 喷淋 / PA0 配网),FSM 同加热垫
+ ******************************************************************************/
+
+#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_SPRAY = 0, /**< PB1 喷淋键:只短按(启/停喷淋) */
+ USR_KEY_PAIR, /**< PA0 配网键:长按 5s 提示 / 12s OTA */
+ 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..bbccc09
--- /dev/null
+++ b/apps/usr_app/key_manager.c
@@ -0,0 +1,201 @@
+/******************************************************************************
+ * @file key_manager.c
+ * @brief 双键管理:PB1 喷淋短按;PA0 按住分级 5s 配对 / 12s OTA
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ * @history
+ * - V1.0.0, 2026.09.09, cyWu, P0 双键版(FSM 复用 key.c,逻辑同加热垫单键)
+ *
+ * @note 规格书 5.2:喷淋键只短按(配网/OTA 分级只做在配网键上,禁止混淆)。
+ * PB1 满 5s 后松手被 FSM 归为 RELEASE_Event,回调为空 = 无动作;
+ * PA0 满 5s LONG_Event 只切红灯快闪,清绑定等动作延后到任务上下文。
+ ******************************************************************************/
+
+#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[USR_KEY_COUNT]; /**< 1=该键上电时还按着,等松开才认短按 */
+ uint8_t ota_fired; /**< 1=PA0 已到点触发 OTA,本次松手不再提交配对 */
+} KeyManagerCtx_t;
+
+static KeyManagerCtx_t s_km;
+
+/**
+ * @brief 读喷淋键:1=按下
+ * @return 1=按下,0=松开
+ */
+static uint8_t key_spray_read(void)
+{
+ return bsp_key_spray_is_pressed();
+}
+
+/**
+ * @brief 读配网键:1=按下
+ * @return 1=按下,0=松开
+ */
+static uint8_t key_pair_read(void)
+{
+ return bsp_key_pair_is_pressed();
+}
+
+/**
+ * @brief 喷淋键短按(SHORT_Event,松手 <5s):上电未松手前忽略,否则启/停喷淋
+ * @return 无
+ */
+static void key_spray_short(void)
+{
+ if (s_km.boot_key_lock[USR_KEY_SPRAY]) {
+ s_km.boot_key_lock[USR_KEY_SPRAY] = 0; /* 上电松手当成释放,不当短按开喷 */
+ log_info("spray key released at boot\n");
+ return;
+ }
+ usr_on_key_short();
+}
+
+/**
+ * @brief 配网键长按到点(LONG_Event,满 5s 仍按住):挂红灯快闪提示
+ * @note ⚠ 跑在 20ms 硬件定时器中断上下文:只准打标志 + 切灯效事件(纯 RAM)。
+ * 清绑定(写 VM)留给 app_function_loop(任务上下文)。
+ * @return 无
+ */
+static void key_pair_hint(void)
+{
+ usr_on_key_pair_hint();
+}
+
+/**
+ * @brief 配网键连按到点(LAST_Event,满 12s 仍按住,到点即触发不等松手):进 OTA
+ * @note ⚠ 同上,只置标志;usr_app_ota_init 由 app_function_loop 执行。
+ * key.c 对 LAST_Event 不清 eventType,松手前每 20ms 都会再派发一次,
+ * 用 ota_fired 挡住重复调用。
+ * @return 无
+ */
+static void key_pair_ota_start(void)
+{
+ if (s_km.ota_fired) {
+ return;
+ }
+ s_km.ota_fired = 1;
+ usr_on_key_ota_start();
+}
+
+/**
+ * @brief 配网键释放(RELEASE_Event,按满 5s~12s 间松手):未进 OTA 则提交配对
+ * @note 按满 12s 已触发 OTA 的这次按住,松手不再提交配对(ota_fired 拦住)。
+ * @return 无
+ */
+static void key_pair_release(void)
+{
+ s_km.boot_key_lock[USR_KEY_PAIR] = 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_SPRAY] = {
+ /* 只短按:<5s 松手 SHORT_Event;满 5s 的 LONG/RELEASE、12s 的 LAST 全部空回调 */
+ .fsm.keyShield = KEY_ENABLE,
+ .fsm.keyDownLevel = Bit_SET,
+ .fsm.eventType = NULL_Event,
+ .fsm.keyLongTime = BOARD_KEY_SPRAY_SHORT_MS, /* 短按窗口上限 */
+ .fsm.keyLastTime = BOARD_KEY_OTA_MS,
+ .fsm.keyReadValue = key_spray_read,
+ .func.ShortPressCb = key_spray_short,
+ },
+ [USR_KEY_PAIR] = {
+ .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_pair_read,
+ .func.releasePressCb = key_pair_release,
+ .func.longPressCb = key_pair_hint,
+ .func.lastPressCb = key_pair_ota_start,
+ },
+};
+
+/**
+ * @brief 装载按键表
+ * @return 无
+ */
+void user_key_init(void)
+{
+ if (s_km.initialized) {
+ return;
+ }
+ keyParaInit(s_keys);
+ s_km.boot_key_lock[USR_KEY_SPRAY] = 0;
+ s_km.boot_key_lock[USR_KEY_PAIR] = 0;
+ s_km.ota_fired = 0;
+ s_km.initialized = 1;
+ log_info("key init spray=PB1 pair=PA0\n");
+}
+
+/**
+ * @brief 上电后:键还按着则锁住短按,松开才认短按
+ * @note 只锁短按,**不锁长按分级**:上电就按住 PA0 5s 进配对是受支持的用法。
+ * @return 无
+ */
+void user_key_lock_if_held(void)
+{
+ uint8_t i;
+
+ for (i = 0; i < USR_KEY_COUNT; i++) {
+ reset_key_Status((keyList)i);
+ }
+ if (bsp_key_spray_is_pressed()) {
+ s_km.boot_key_lock[USR_KEY_SPRAY] = 1; /* 防一上电就开喷 */
+ log_info("spray key held at boot, wait release\n");
+ }
+ if (bsp_key_pair_is_pressed()) {
+ s_km.boot_key_lock[USR_KEY_PAIR] = 1; /* PA0 无短按动作,仅保持状态一致 */
+ log_info("pair key held at boot\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..207ab1c
--- /dev/null
+++ b/apps/usr_app/key_manager.h
@@ -0,0 +1,68 @@
+/******************************************************************************
+ * @file key_manager.h
+ * @brief 双键:PB1 喷淋短按;PA0 按住 5s 配对 / 12s OTA
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.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 装载按键表(两键:PB1 喷淋 / PA0 配网)
+ * @return 无
+ */
+void user_key_init(void);
+
+/**
+ * @brief 上电后:某键还按着则锁该键短按,松开才认短按(不挡长按分级)
+ * @note 规格书 5.1:上电若键仍按着只锁该键短按(防一上电就开喷),
+ * 不锁配网键的 5s / 12s。
+ * @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..81a26cd
--- /dev/null
+++ b/apps/usr_app/led.h
@@ -0,0 +1,124 @@
+/******************************************************************************
+ * @file led.h
+ * @brief LED 事件框架:按通道掩码独立开关,红绿可同时亮
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ * @history
+ * - V1.0.0, 2026.09.09, cyWu, CBR2 独立通道版:绿=喷淋作业,红=配对态,
+ * 两路互不关闭(区别于加热垫互斥灯)
+ ******************************************************************************/
+
+#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;
+
+/* 事件枚举数值 = 事件表下标(led.c LED_EventDelete 依赖),追加事件只能插尾部 */
+typedef enum {
+ LED_EVENT_NONE = 0,
+ LED_EVENT_GRN_IDLE, /**< 绿灯常亮:空闲(DP1=0) */
+ LED_EVENT_GRN_SPRAY, /**< 绿灯闪:作业中(含间隔等待,DP1=1) */
+ LED_EVENT_RED_PAIRED, /**< 红灯常亮:已配对(粘住,空闲/喷淋都不关) */
+ LED_EVENT_RED_PAIRING, /**< 红灯快闪:配网键满 5s 提示 / 配对进行中 */
+ LED_EVENT_PAIR_FAIL, /**< 配对失败:红绿交替 3s(临时占双灯,自删) */
+ LED_EVENT_OTA, /**< OTA:红绿交替,直到升级结束/复位(最高) */
+ LED_EVENT_MAX
+} LED_Event_t;
+
+/**
+ * 优先级:高者覆盖低者(handler 返回 1 会拦下更低优先级)。
+ * 红绿独立通道:绿灯(作业)占 PRIORITY_1、红灯(配对)占 PRIORITY_2,
+ * 红灯 handler 返回 0 放行绿灯;只有 OTA / 配对失败临时占双灯(返回 1)。
+ */
+typedef enum {
+ PRIORITY_1 = 0, /**< 绿灯通道:作业态(空闲常亮 / 作业闪) */
+ PRIORITY_2, /**< 红灯通道:配对态(已配常亮 / 配网快闪) */
+ PRIORITY_3, /**< 配对失败动画(红绿交替,临时占双灯) */
+ PRIORITY_4, /**< 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..07f6344
--- /dev/null
+++ b/apps/usr_app/led_manager.c
@@ -0,0 +1,201 @@
+/******************************************************************************
+ * @file led_manager.c
+ * @brief CBR2 红绿 LED 事件表:绿=作业态,红=配对态(规格书第 6 章)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0,P0 Bring-up(独立通道,区别于加热垫互斥灯)
+ *
+ * @note 红绿独立:绿灯事件只动绿灯、红灯事件只动红灯,handler 返回 0
+ * 放行另一通道;仅 OTA / 配对失败(PAIR_FAIL)临时占双灯(返回 1)。
+ ******************************************************************************/
+
+#include "led_manager.h"
+#include "system/includes.h"
+#include "bsp_hw.h"
+#include "board_pin.h"
+
+static uint8_t led_grn_idle_handler(LED_Config_t *led_config);
+static uint8_t led_grn_spray_handler(LED_Config_t *led_config);
+static uint8_t led_red_paired_handler(LED_Config_t *led_config);
+static uint8_t led_red_pairing_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 绿灯常亮(空闲):只动绿灯,放行红灯通道
+ * @param led_config 配置
+ * @return 0=放行其它优先级
+ */
+static uint8_t led_grn_idle_handler(LED_Config_t *led_config)
+{
+ (void)LED_Update(led_config);
+ return 0;
+}
+
+/**
+ * @brief 绿灯闪(作业中 500ms):只动绿灯,放行红灯通道
+ * @param led_config 配置
+ * @return 0=放行其它优先级
+ */
+static uint8_t led_grn_spray_handler(LED_Config_t *led_config)
+{
+ (void)LED_Update(led_config);
+ return 0;
+}
+
+/**
+ * @brief 红灯常亮(已配对,粘住):只动红灯,放行绿灯通道
+ * @param led_config 配置
+ * @return 0=放行其它优先级
+ */
+static uint8_t led_red_paired_handler(LED_Config_t *led_config)
+{
+ (void)LED_Update(led_config);
+ return 0;
+}
+
+/**
+ * @brief 红灯快闪(满 5s 提示 / 配对中 100ms):只动红灯,放行绿灯通道
+ * @param led_config 配置
+ * @return 0=放行其它优先级
+ */
+static uint8_t led_red_pairing_handler(LED_Config_t *led_config)
+{
+ (void)LED_Update(led_config);
+ return 0;
+}
+
+/**
+ * @brief 配对失败红绿交替 3s:到点自删,交回双通道常态
+ * @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 注册回调并装载 CBR2 事件表(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_GRN_IDLE] = {
+ /* 空闲(DP1=0):绿灯常亮,不碰红灯 */
+ .event = LED_EVENT_GRN_IDLE,
+ .priority = PRIORITY_1,
+ .config.ledMask = LED_CHANNEL(LED_CH_GREEN),
+ .config.state = LED_LIGHT,
+ .config.runNum = 0, /* 0=一直亮 */
+ .ledEventHandler = led_grn_idle_handler,
+ },
+ [LED_EVENT_GRN_SPRAY] = {
+ /* 作业中(含间隔等待):绿灯 500ms 闪,不碰红灯 */
+ .event = LED_EVENT_GRN_SPRAY,
+ .priority = PRIORITY_1,
+ .config.ledMask = LED_CHANNEL(LED_CH_GREEN),
+ .config.state = LED_BLINKING,
+ .config.blinkParams.orDer = 0,
+ .config.blinkParams.onTime = BOARD_LED_SPRAY_ON_MS,
+ .config.blinkParams.offTime = BOARD_LED_SPRAY_OFF_MS,
+ .config.runNum = 0,
+ .ledEventHandler = led_grn_spray_handler,
+ },
+ [LED_EVENT_RED_PAIRED] = {
+ /* 已配对(含断连重连、上电已配对):红灯常亮粘住,不碰绿灯 */
+ .event = LED_EVENT_RED_PAIRED,
+ .priority = PRIORITY_2,
+ .config.ledMask = LED_CHANNEL(LED_CH_RED),
+ .config.state = LED_LIGHT,
+ .config.runNum = 0,
+ .ledEventHandler = led_red_paired_handler,
+ },
+ [LED_EVENT_RED_PAIRING] = {
+ /* 配网键满 5s 提示 / 配对进行中:红灯 100ms 快闪,不碰绿灯 */
+ .event = LED_EVENT_RED_PAIRING,
+ .priority = PRIORITY_2,
+ .config.ledMask = LED_CHANNEL(LED_CH_RED),
+ .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_red_pairing_handler,
+ },
+ [LED_EVENT_PAIR_FAIL] = {
+ /* 配对失败:红绿交替 3s 后自删(半周期 BOARD_LED_PAIR_FAIL_MS) */
+ .event = LED_EVENT_PAIR_FAIL,
+ .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_PAIR_FAIL_MS * 2u),
+ .config.runNum = (uint16_t)((BOARD_LED_PAIR_FAIL_HOLD_MS
+ + (BOARD_LED_PAIR_FAIL_MS * 2u) - 1u)
+ / (BOARD_LED_PAIR_FAIL_MS * 2u)),
+ .ledEventHandler = led_pair_fail_handler,
+ },
+ [LED_EVENT_OTA] = {
+ /* OTA:红绿交替一直闪(200ms 半周期),直到升级结束/复位 */
+ .event = LED_EVENT_OTA,
+ .priority = PRIORITY_4,
+ .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_OTA_ON_MS
+ + BOARD_LED_OTA_OFF_MS),
+ .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..38af004
--- /dev/null
+++ b/apps/usr_app/led_manager.h
@@ -0,0 +1,32 @@
+/******************************************************************************
+ * @file led_manager.h
+ * @brief CBR2 红绿 LED 通道与事件表(绿=作业 / 红=配对 / OTA、失败占双灯)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0,P0 Bring-up
+ ******************************************************************************/
+
+#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 回调并装载 CBR2 事件表(GPIO 已在 bsp_hw_init 配置)
+ * @return 无
+ */
+void user_led_init(void);
+
+/**
+ * @brief 跑一轮 LED 事件
+ * @return 无
+ */
+void user_led_handle(void);
+
+#endif /* __LED_MANAGER_H__ */
diff --git a/apps/usr_jb_proto/Protocol/jb_product.c b/apps/usr_jb_proto/Protocol/jb_product.c
index e94f21e..57e7e06 100644
--- a/apps/usr_jb_proto/Protocol/jb_product.c
+++ b/apps/usr_jb_proto/Protocol/jb_product.c
@@ -13,6 +13,7 @@
*/
#include "jb_product.h"
+#include "board_pin.h"
/* ════════════════════════════════════════════════════════════════
* 全局变量
@@ -36,6 +37,13 @@ void userInit(void)
gDevData.current_temp = 0.0f; /* DP5 当前温度 (float) */
gDevData.current_humi = 0; /* DP6 当前湿度 */
memset(gDevData.spray_config, 0, 8); /* DP7 喷淋配置 */
+ /* 首次上电(VM 无效)DP7 默认 10s × 3 次 + 间隔 3s(规格书 10.1/10.2):
+ * Byte0-1 时长 / Byte2 次数 / Byte3-4 间隔,均大端;5-7 预留置 0 */
+ gDevData.spray_config[0] = (uint8_t)(BOARD_SPRAY_DEFAULT_DUR_S >> 8);
+ gDevData.spray_config[1] = (uint8_t)(BOARD_SPRAY_DEFAULT_DUR_S & 0xFFu);
+ gDevData.spray_config[2] = BOARD_SPRAY_DEFAULT_COUNT;
+ gDevData.spray_config[3] = (uint8_t)(BOARD_SPRAY_DEFAULT_INTERVAL_S >> 8);
+ gDevData.spray_config[4] = (uint8_t)(BOARD_SPRAY_DEFAULT_INTERVAL_S & 0xFFu);
gDevData.linkage_delete = 0; /* DP8 联动删除 0=关 1=开 */
memset(gDevData.linkage_config, 0, 20); /* DP9 联动配置 */
gDevData.spray_plan_en = 0; /* DP10 喷淋计划总开关 0=关 1=开 */
diff --git a/apps/usr_jb_proto/Protocol/jb_protocol.h b/apps/usr_jb_proto/Protocol/jb_protocol.h
index 91eb7f6..da80740 100644
--- a/apps/usr_jb_proto/Protocol/jb_protocol.h
+++ b/apps/usr_jb_proto/Protocol/jb_protocol.h
@@ -23,7 +23,7 @@
* ════════════════════════════════════════════════════════════════ */
#define PKT_HEAD_0 0x55
#define PKT_HEAD_1 0xAA
-#define MAX_PACKAGE_LEN 128
+#define MAX_PACKAGE_LEN 512 /* 512:DP18 run_record 124B 需要;禁止改回 128 */
#define JB_OTA_MAX_PAYLOAD 96 /* OTA 单包 Payload 上限(Byte); 须 <= MAX_PACKAGE_LEN - 11(0x32 帧开销) */
#define DP_COUNT 28
diff --git a/apps/usr_jb_proto/usr_jb_main.c b/apps/usr_jb_proto/usr_jb_main.c
index ebf80b1..26b82c2 100644
--- a/apps/usr_jb_proto/usr_jb_main.c
+++ b/apps/usr_jb_proto/usr_jb_main.c
@@ -1,18 +1,43 @@
+/******************************************************************************
+ * @file usr_jb_main.c
+ * @brief CBR2 业务组合根:只编排调用顺序(P0 双键/双灯/双脚泵)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ * @history
+ * - V1.0.0, 2026.09.09, cyWu, P0 组合根(对齐加热垫框架,删过零/继电器)
+ *
+ * @note jb_product.c 不写业务。20ms tick 禁止写 VM / 切 BLE;
+ * 配对、OTA、落盘在 500ms usr_timer_loop(任务上下文)。
+ * P0 关闭 LINKAGE/TIMER/TH_SCAN/RUNREC/NVM(board_pin.h)。
+ ******************************************************************************/
+
#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 "app_pair.h"
+#include "app_function.h"
+#include "app_led.h"
+#include "app_pump.h"
+#include "app_spray.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 +47,95 @@ 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 / 跑联动与计划引擎
+ * @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(); /* 协议 1ms 时基:实际循环 dt 次对齐真实时间 */
+ }
+
+ user_key_scan(dt); /* 双键扫描(内部完成长按分级,只置标志) */
+ user_key_handle(); /* 派发按键回调:喷淋启停 / 配对提示 / OTA 标志 */
+ app_spray_poll(dt); /* 作业引擎:时长/间隔倒数,到点切泵(唯一切泵者) */
+ app_function_poll(); /* 同步 DP1 = 作业态(含间隔等待) */
+ app_led_run(dt); /* 绿=作业态、红=配对态;内部推进配对会话 */
+}
+
+/**
+ * @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 业务初始化(5V 常电上电即开机,无按键等待)
+ * @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);
+ userInit(); /* DP7 默认 10s×3+3s 已就位 */
+
+ app_pump_init(); /* 泵两脚低(bsp_hw 已配),状态清零 */
+ app_spray_init(); /* 作业引擎:空闲不喷 */
+ app_function_init();
+
+ user_key_init(); /* 双键:PB1 喷淋短按 / PA0 配网 5s-12s */
+ /* 上电时键若还按着:只锁该键短按(防一上电就开喷),长按 5s/12s 分级照常,
+ * 所以"插电时就按住 PA0 5s 进配对"这一用法仍然成立 */
+ user_key_lock_if_held();
+
+ /* priority=1 常驻注册,永不重挂(5V 常电不需要低功耗) */
+ usr_timer_add(NULL, usr_timer_tick, USR_TIMER_IDLE_MS, 1);
+
+ log_info("sprayer P0 up: led=%d key=%d pump=%d spray=%d\n",
+ BOARD_LED_ENABLE, BOARD_KEY_ENABLE,
+ BOARD_PUMP_ENABLE, BOARD_SPRAY_ENABLE);
}
-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_function_loop(); /* 配对/OTA 动作落地(app_defer 消费) */
}
diff --git a/apps/usr_periph/board_pin.h b/apps/usr_periph/board_pin.h
new file mode 100644
index 0000000..c6695cb
--- /dev/null
+++ b/apps/usr_periph/board_pin.h
@@ -0,0 +1,101 @@
+/******************************************************************************
+ * @file board_pin.h
+ * @brief CBR2 板级引脚、电平与时序(换板只改本文件)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ *
+ * @note 调试 UART0_TX=PC5 在 board_jl701n_demo_cfg.h。
+ * 泵脚成对操作,禁止只开 PC2 或 PA6(PC2 供 5V,PA6 导通泵)。
+ * 本机为 5V 常电、无电池;红灯 PA8 / 绿灯 PA7(低亮),
+ * 与加热垫(PA8 绿 / PA7 红)相反,禁止照抄加热垫灯脚。
+ ******************************************************************************/
+
+#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(PA8 红 / PA7 绿,低亮) */
+#define BOARD_KEY_ENABLE 1 /**< 1=双键(PB1 喷淋 / PA0 配网,低按下) */
+#define BOARD_PUMP_ENABLE 1 /**< 1=水泵双脚(PC2 供 5V / PA6 导通,高有效) */
+#define BOARD_SPRAY_ENABLE 1 /* 喷淋作业引擎;=0 时键/DP 不转泵 */
+#define BOARD_LINKAGE_ENABLE 0 /* 湿度联动(第 11 章);P2 开 */
+#define BOARD_TIMER_ENABLE 0 /* 到点喷淋计划(第 12 章);P2 开 */
+#define BOARD_TH_SCAN_ENABLE 0 /* 断连扫描风扇广播(第 8 章);P2 开 */
+#define BOARD_RUNREC_ENABLE 0 /* 31 天运行记录(第 14 章);P2 开 */
+#define BOARD_NVM_ENABLE 0 /* VM 持久化(第 14 章);P1/P2 开 */
+
+/*============================================================================*/
+/* GPIO */
+/*============================================================================*/
+
+#define BOARD_UART_TX_PIN IO_PORTC_05 /**< 调试 UART TX(cfg: TCFG_UART0_TX_PORT) */
+#define BOARD_KEY_SPRAY_PIN IO_PORTB_01 /**< 喷淋键,低电平按下,只短按 */
+#define BOARD_KEY_PAIR_PIN IO_PORTA_00 /**< 配网键,低电平按下,5s / 12s */
+#define BOARD_LED_RED_PIN IO_PORTA_08 /**< 红灯,低电平亮(与加热垫灯脚相反) */
+#define BOARD_LED_GREEN_PIN IO_PORTA_07 /**< 绿灯,低电平亮(与加热垫灯脚相反) */
+#define BOARD_PUMP_PWR_PIN IO_PORTC_02 /**< 泵总供电:高把 5V 导到泵 VCC */
+#define BOARD_PUMP_SW_PIN IO_PORTA_06 /**< 泵开关:高导通泵;须与 PC2 同开同关 */
+
+#define BOARD_LED_ON_LEVEL 0 /**< LED 点亮电平:0=低亮 */
+#define BOARD_PUMP_ON_LEVEL 1 /**< 泵有效电平:1=高导通 */
+#define BOARD_KEY_ACTIVE_LEVEL 0 /**< 按键有效电平:0=按下为低 */
+
+/*============================================================================*/
+/* 按键 / 配对 / OTA */
+/*============================================================================*/
+
+/**
+ * 直接作为 key.c 状态机的 keyLongTime/keyLastTime 使用:
+ * PB1 喷淋键:<5s 松手 → SHORT_Event(启/停喷淋);满 5s 后松手 → RELEASE_Event
+ * (无动作,规格书 5.2:按住 ≥5s 无动作);12s 到点 LAST_Event 无动作
+ * PA0 配网键:满 5s → LONG_Event(红灯 100ms 快闪提示);5~12s 间松手 →
+ * RELEASE_Event(提交配对);满 12s → LAST_Event(进 OTA)
+ */
+#define BOARD_KEY_SPRAY_SHORT_MS 5000 /**< PB1:短按窗口上限(≥5s 无动作) */
+#define BOARD_KEY_PAIR_MS 5000 /**< PA0 keyLongTime:满 5s 挂配对提示灯 */
+#define BOARD_KEY_OTA_MS 12000 /**< PA0 keyLastTime:满 12s 到点直接进 OTA */
+#define BOARD_KEY_DEBOUNCE_MS 20 /**< 按键消抖(参考值;key.c 内部以 30 为准) */
+#define BOARD_PAIR_TIMEOUT_MS (120u * 1000u) /**< 配对超时 120s,失败后保持开机 */
+
+/*============================================================================*/
+/* LED(红绿独立通道,低亮;事件表见 led_manager.c) */
+/*============================================================================*/
+
+#define BOARD_LED_SPRAY_ON_MS 500 /**< 作业绿灯闪:亮 */
+#define BOARD_LED_SPRAY_OFF_MS 500 /**< 作业绿灯闪:灭 */
+#define BOARD_LED_FAST_ON_MS 100 /**< 配网红灯快闪:亮 */
+#define BOARD_LED_FAST_OFF_MS 100 /**< 配网红灯快闪:灭 */
+#define BOARD_LED_OTA_ON_MS 200 /**< OTA 红绿交替半周期:亮 */
+#define BOARD_LED_OTA_OFF_MS 200 /**< OTA 红绿交替半周期:灭 */
+#define BOARD_LED_PAIR_FAIL_MS 200 /**< 配对失败红绿交替半周期 */
+#define BOARD_LED_PAIR_FAIL_HOLD_MS 3000 /**< 配对失败动画总时长 */
+
+/*============================================================================*/
+/* 断连扫描(P2,第 8.2 节) */
+/*============================================================================*/
+
+#define BOARD_ADV_SCAN_SLOT_MS 500 /* 重连广播 / 扫描各占一拍 */
+
+/*============================================================================*/
+/* 喷淋作业(第 10 章:首次上电 DP7 默认 10s × 3 次 + 间隔 3s) */
+/*============================================================================*/
+
+#define BOARD_SPRAY_DEFAULT_DUR_S 10 /**< DP7 默认单次喷淋时长(秒) */
+#define BOARD_SPRAY_DEFAULT_COUNT 3 /**< DP7 默认喷淋次数 */
+#define BOARD_SPRAY_DEFAULT_INTERVAL_S 3 /**< DP7 默认喷淋间隔(秒) */
+
+/*============================================================================*/
+/* 运行记录(P2,第 14.1 节:31 天 × uint32 秒) */
+/*============================================================================*/
+
+#define BOARD_RUN_RECORD_DAYS 31 /**< 记录天数 */
+#define BOARD_RUN_RECORD_SAVE_SEC 600 /**< 泵实际导通累计 10 分钟写一次 VM */
+
+#endif /* __BOARD_PIN_H__ */
diff --git a/apps/usr_periph/bsp_hw.c b/apps/usr_periph/bsp_hw.c
new file mode 100644
index 0000000..bcba404
--- /dev/null
+++ b/apps/usr_periph/bsp_hw.c
@@ -0,0 +1,195 @@
+/******************************************************************************
+ * @file bsp_hw.c
+ * @brief 板级 GPIO:红绿 LED、双键、泵双脚(成对切)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.0
+ * @history
+ * - V1.0.0, 2026.09.09, cyWu, P0 Bring-up 首次发布
+ *
+ * @note 泵两脚 PC2(5V 供) + PA6(导通) 必须同开同关,bsp_pump_set 是
+ * 唯一写泵 GPIO 的出口;上电默认两脚都低,绝不按 VM 自动开泵。
+ ******************************************************************************/
+
+#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 写单个输出引脚到指定逻辑电平
+ * @param pin GPIO 编号
+ * @param value 电平值
+ * @return 无
+ */
+static void bsp_pin_write(u32 pin, int value)
+{
+ gpio_direction_output(pin, value);
+}
+
+/**
+ * @brief 初始化 GPIO
+ * @note 上电顺序:泵两脚先保持低(PC2=0 且 PA6=0),灯全灭(由事件表接管)。
+ * @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_SPRAY_PIN, 1); /* 上拉输入,低按下 */
+ bsp_pin_input(BOARD_KEY_PAIR_PIN, 1);
+#else
+ log_info("hw key disabled\n");
+#endif
+
+#if BOARD_PUMP_ENABLE
+ /* 泵两脚初始都低:上电绝不喷(规格书 2.2 安全) */
+ bsp_pin_output(BOARD_PUMP_PWR_PIN, BOARD_PUMP_ON_LEVEL ? 0 : 1);
+ bsp_pin_output(BOARD_PUMP_SW_PIN, BOARD_PUMP_ON_LEVEL ? 0 : 1);
+#else
+ log_info("hw pump disabled\n");
+#endif
+
+ s_initialized = 1;
+ log_info("hw init led=%d key=%d pump=%d\n",
+ BOARD_LED_ENABLE, BOARD_KEY_ENABLE, BOARD_PUMP_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 红灯开关(PA8,低电平亮)
+ * @param on 1=亮,0=灭
+ * @return 无
+ */
+void bsp_led_red_set(uint8_t on)
+{
+#if BOARD_LED_ENABLE
+ bsp_pin_write(BOARD_LED_RED_PIN,
+ on ? BOARD_LED_ON_LEVEL : (BOARD_LED_ON_LEVEL ? 0 : 1));
+#else
+ (void)on;
+#endif
+}
+
+/**
+ * @brief 绿灯开关(PA7,低电平亮)
+ * @param on 1=亮,0=灭
+ * @return 无
+ */
+void bsp_led_green_set(uint8_t on)
+{
+#if BOARD_LED_ENABLE
+ bsp_pin_write(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_spray_is_pressed(void)
+{
+#if BOARD_KEY_ENABLE
+ return (uint8_t)(gpio_read(BOARD_KEY_SPRAY_PIN) == BOARD_KEY_ACTIVE_LEVEL);
+#else
+ return 0;
+#endif
+}
+
+/**
+ * @brief 读配网键(PA0)
+ * @return 1=按下,0=未按下
+ */
+uint8_t bsp_key_pair_is_pressed(void)
+{
+#if BOARD_KEY_ENABLE
+ return (uint8_t)(gpio_read(BOARD_KEY_PAIR_PIN) == BOARD_KEY_ACTIVE_LEVEL);
+#else
+ return 0;
+#endif
+}
+
+/**
+ * @brief 成对切泵:两脚同开同关
+ * @param on 1=泵转,0=泵停
+ * @return 无
+ */
+void bsp_pump_set(uint8_t on)
+{
+#if BOARD_PUMP_ENABLE
+ bsp_pin_write(BOARD_PUMP_PWR_PIN,
+ on ? BOARD_PUMP_ON_LEVEL : (BOARD_PUMP_ON_LEVEL ? 0 : 1));
+ bsp_pin_write(BOARD_PUMP_SW_PIN,
+ on ? BOARD_PUMP_ON_LEVEL : (BOARD_PUMP_ON_LEVEL ? 0 : 1));
+#else
+ (void)on;
+#endif
+}
diff --git a/apps/usr_periph/bsp_hw.h b/apps/usr_periph/bsp_hw.h
new file mode 100644
index 0000000..fb436be
--- /dev/null
+++ b/apps/usr_periph/bsp_hw.h
@@ -0,0 +1,79 @@
+/******************************************************************************
+ * @file bsp_hw.h
+ * @brief 板级 GPIO:红绿 LED、双键、水泵双脚(低层原语)
+ * @author cyWu <1917507415@qq.com>
+ * @date 2026.09.09
+ * @version V1.0.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 红灯开关(PA8,低亮)
+ * @param on 1=亮,0=灭
+ * @return 无
+ */
+void bsp_led_red_set(uint8_t on);
+
+/**
+ * @brief 绿灯开关(PA7,低亮)
+ * @param on 1=亮,0=灭
+ * @return 无
+ */
+void bsp_led_green_set(uint8_t on);
+
+/**
+ * @brief 红绿全灭
+ * @return 无
+ */
+void bsp_led_all_off(void);
+
+/* ═══ 按键(低按下)═══ */
+
+/**
+ * @brief 读喷淋键(PB1)
+ * @return 1=按下,0=未按下
+ */
+uint8_t bsp_key_spray_is_pressed(void);
+
+/**
+ * @brief 读配网键(PA0)
+ * @return 1=按下,0=未按下
+ */
+uint8_t bsp_key_pair_is_pressed(void);
+
+/* ═══ 水泵(PC2 供 5V + PA6 导通,**成对操作**)═══ */
+
+/**
+ * @brief 成对切泵:on=1 时 PC2 与 PA6 同时置高;on=0 时同时拉低
+ * @note ⚠ 禁止只开其中一只脚。本接口是唯一能改泵 GPIO 的出口,
+ * 调用方只准是 app_pump(上层业务经 app_spray 间接请求)。
+ * @param on 1=泵转,0=泵停
+ * @return 无
+ */
+void bsp_pump_set(uint8_t on);
+
+#endif /* __BSP_HW_H__ */