节点能睡,还得能叫醒并完成升级
电池节点最容易在维护时暴露问题:刚找到IP,它又睡了;更新失败以后,仍在倒计时关机。本例重点是正常睡眠与维护窗口的转换。普通窗口运行60秒、计划深睡5分钟;实体GPIO5、API保持、OTA进行中和OTA错误都能阻止入睡。
用同一块官方DevKit验证流程,不把板换成电池专板,也不声称微安或续航。深睡唤醒重新启动应用,本例没有跨复位样本队列或RTC计数;RAM维护状态会清零,实体LOW仍可保持。尚未实测深睡、OTA、HA或电池供电。
参考:[1] ESPHome2026.9.1深睡配置/KEEP_AWAKE校验[2] 固定深睡调度/prevent/allow逻辑[3] S3 EXT0电平与深睡入口[14] IDF5.5.5 sleep API/wakeup cause
先让一个开关不依赖网络
GPIO5/J1-5外接10kΩ到3V3,SPST接GPIO5与GND:开路HIGH正常,闭合LOW触发EXT0唤醒并KEEP_AWAKE。YAML共享此脚给实体状态与深睡入口,不依靠内部上拉。SHT40固定PID4885,VIN3V3/GND、SDA8/SCL9/100kHz、0x44,3V输出留空。
首次维护台架用桥USB。独立电池实验固定PID2011受保护单节Li-ion,经匹配极性线束接S7V8F5 #2123 VIN/GND,VOUT标称5V接J1-21/GND;两根USB都拔掉后才接外供,不能并供或把单节电池直接3V3。先量极性和输出,再观察联网负载下陷。稳压器没有充电功能,充电前断开设备,用适配Li-ion CC/CV充电器。
稳压输出能力随电压/温度/负载变化,DevKit、模块与稳压器都计入整机电流。下载包电流记录表分USB台架和独立电池,保持空白;容量标称值不能代替整周期能量测量。
参考:[9] 官方v1.1引脚与供电入口[10] PID4885引脚/供电[11] Pololu #2123固定5V、输入范围与负载条件[12] Adafruit PID2011保护电池与充电条件
保持、退出和失败锁定都在完整配置里
下载包提供完整YAML、h09_runtime.h、Python锁和HA包。使用Python3.12.13/ESPHome2026.9.1/IDF5.5.5,复制secrets.example.yaml并生成独立32字节base64密钥;示例占位SSID和密钥不用于真实节点。先config再compile,桥USB首次刷写。
OTA开始先prevent;传输期间退出请求被拒绝。on_error/on_abort切换OTA_ERROR_HOLD,修复/重试后显式退出;成功升级重启会清RAM保持。GPIO5仍LOW时,API关闭和Exit都不会使芯片睡下。运行期限已过后allow可能立即入睡,不会自动再赠送60秒。
esphome:
name: hixinwu-h09
friendly_name: Hixinwu H09
min_version: 2026.9.1
includes:
- h09_runtime.h
on_boot:
priority: -100
then:
- text_sensor.template.publish:
id: wake_report
state: !lambda return h09::wake_info();
- logger.log: "H09 boot: normal 60s window; hold GPIO5 LOW for maintenance"
esp32:
board: esp32-s3-devkitc-1
variant: ESP32S3
flash_size: 8MB
flash_mode: qio
flash_frequency: 80MHz
toolchain: esp-idf
framework:
type: esp-idf
version: 5.5.5
psram:
mode: octal
speed: 80MHz
logger:
baud_rate: 115200
hardware_uart: UART0
level: DEBUG
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
encryption:
key: !secret api_key
on_begin:
then:
- lambda: h09::runtime().ota_begin();
- deep_sleep.prevent: sleep_control
on_error:
then:
- lambda: h09::runtime().ota_failed();
- deep_sleep.prevent: sleep_control
- logger.log: "H09 OTA_ERROR_HOLD; repair/retry, then explicitly release maintenance"
on_abort:
then:
- lambda: h09::runtime().ota_failed();
- deep_sleep.prevent: sleep_control
on_end:
then:
- logger.log: "H09 OTA complete callback; reboot clears RAM holds, physical switch still applies"
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
reboot_timeout: 0s
ap:
ssid: "hixinwu-h09 Maintenance"
password: !secret fallback_password
ap_timeout: 90s
captive_portal:
i2c:
sda: GPIO8
scl: GPIO9
frequency: 100kHz
scan: true
deep_sleep:
id: sleep_control
run_duration: 60s
sleep_duration: 5min
wakeup_pin:
number: GPIO5
inverted: true
allow_other_uses: true
mode: INPUT
wakeup_pin_mode: KEEP_AWAKE
binary_sensor:
- platform: gpio
id: physical_maintenance
name: "Physical Maintenance"
pin:
number: GPIO5
inverted: true
allow_other_uses: true
mode: INPUT
filters:
- delayed_on_off: 25ms
- platform: template
id: sample_valid
name: "Sample Valid"
entity_category: diagnostic
switch:
- platform: template
id: maintenance_mode
name: "API Maintenance Hold"
lambda: return h09::runtime().api_hold || h09::runtime().ota_error;
turn_on_action:
- lambda: h09::runtime().hold_api();
- deep_sleep.prevent: sleep_control
turn_off_action:
- lambda: |-
if(!h09::runtime().request_exit())ESP_LOGW("H09","Exit refused during active OTA");
button:
- platform: template
name: "Exit Maintenance"
on_press:
then:
- lambda: |-
if(!h09::runtime().request_exit())ESP_LOGW("H09","Exit refused during active OTA");
sensor:
- platform: sht4x
address: 0x44
precision: High
heater_max_duty: 0
update_interval: 5s
temperature:
id: room_temperature
name: "Last Temperature"
accuracy_decimals: 2
filters:
- timeout:
timeout: 15s
value: !lambda return NAN;
on_value:
then:
- lambda: h09::runtime().set_temperature(x,h09::now_ms());
humidity:
id: room_humidity
name: "Last Humidity"
accuracy_decimals: 2
filters:
- timeout:
timeout: 15s
value: !lambda return NAN;
on_value:
then:
- lambda: h09::runtime().set_humidity(x,h09::now_ms());
text_sensor:
- platform: template
id: wake_report
name: "Wake Reason"
update_interval: never
- platform: template
id: maintenance_phase
name: "Maintenance Phase"
interval:
- interval: 100ms
then:
- lambda: |-
auto &r=h09::runtime();
const bool physical=id(physical_maintenance).has_state()&&id(physical_maintenance).state;
if(r.blocked(physical))id(sleep_control).prevent_deep_sleep();else id(sleep_control).allow_deep_sleep();
id(sample_valid).publish_state(r.sample_valid(h09::now_ms()));
id(maintenance_phase).publish_state(r.mode(physical));
- interval: 5s
then:
- if:
condition:
api.connected:
state_subscription_only: true
then:
- homeassistant.event:
event: esphome.hixinwu_h09_report
data:
node: h09
valid: !lambda |-
return h09::runtime().sample_valid(h09::now_ms()) ? "true" : "false";
wake: !lambda return h09::wake_info();
mode: !lambda return std::string(h09::runtime().mode(id(physical_maintenance).state));
- logger.log: "H09 report dispatched; confirm actual receipt in HA event listener"
else:
- logger.log: "H09 no API state subscriber; no HA receipt claim, no offline report buffer"
- 做到这里,你应该看到
- 先闭合实体开关,上电观察PHYSICAL_HOLD,超过60秒仍醒着后再配置网络维护。
参考:[1] ESPHome2026.9.1深睡配置/KEEP_AWAKE校验[2] 固定深睡调度/prevent/allow逻辑[3] S3 EXT0电平与深睡入口[4] 固定OTA on_begin/on_error/on_abort配置
HA显示着旧温度,不等于刚收到样本
HA的ESPHome深睡预期断连可能保留旧实体为available;不能仅凭在线颜色或上次数值判断新鲜。本例每5秒向已有API状态订阅者发送esphome.hixinwu_h09_report,含node、valid字符串、wake、mode;设备日志dispatched只表示尝试发送,不证明HA收到。
下载ha-package.yaml在实际收到匹配device_id的事件时更新last_report,仅valid=true更新last_good。ever_reported/ever_good守住首次无收件的情况,因为input_datetime初次可能默认午夜。helper无initial,HA重启恢复旧收件历史;时间倒退到记录之前时年龄不可用,900秒是本项目报告过期策略。年龄模板按分钟更新,不是精确每秒倒计时。
- HA添加ESPHome集成,填真实IP/key并核实体ID。
- 本例仅发送esphome.*事件,无须开启直接HA服务调用权限;在开发者工具监听该事件,记录真实device_id。
- 把HA包放/config/packages,合并packages配置,只替换真实device_id过滤器;已有homeassistant项只合并子项。
- 运行HA检查配置成功后重启,核Report Age/Good Report Age/Report Overdue;本轮没有运行HA检查器或登录HA。
# HA Core2026.10.2 source-checked; actual HA config/runtime pending.
# Replace the device_id filter using the real event listener output.
# No initial value: helpers restore previous actual receipt data after HA restarts.
input_boolean:
h09_ever_reported:
name: "H09 Ever Reported"
h09_ever_good:
name: "H09 Ever Good Sample"
input_datetime:
h09_last_report:
name: "H09 Last Actual Report"
has_date: true
has_time: true
h09_last_good:
name: "H09 Last Good Report"
has_date: true
has_time: true
automation:
- id: hixinwu_h09_report_v1
alias: "H09 remember real received events"
triggers:
- trigger: event
event_type: esphome.hixinwu_h09_report
event_data:
node: h09
conditions:
- condition: template
value_template: "{{ trigger.event.data.device_id == 'replace_with_real_device_id' }}"
actions:
- action: input_datetime.set_datetime
target:
entity_id: input_datetime.h09_last_report
data:
timestamp: "{{ now().timestamp() }}"
- action: input_boolean.turn_on
target:
entity_id: input_boolean.h09_ever_reported
- if:
- condition: template
value_template: "{{ trigger.event.data.valid == 'true' }}"
then:
- action: input_datetime.set_datetime
target:
entity_id: input_datetime.h09_last_good
data:
timestamp: "{{ now().timestamp() }}"
- action: input_boolean.turn_on
target:
entity_id: input_boolean.h09_ever_good
mode: queued
max: 10
template:
- sensor:
- name: "H09 Report Age"
unique_id: hixinwu_h09_report_age_v1
unit_of_measurement: s
availability: >-
{% set ts = state_attr('input_datetime.h09_last_report','timestamp') %}
{{ is_state('input_boolean.h09_ever_reported','on') and ts is number and now().timestamp() >= ts }}
state: >-
{% set ts = state_attr('input_datetime.h09_last_report','timestamp') %}
{{ ((now().timestamp() - ts) | int) if ts is number else none }}
- name: "H09 Good Report Age"
unique_id: hixinwu_h09_good_age_v1
unit_of_measurement: s
availability: >-
{% set ts = state_attr('input_datetime.h09_last_good','timestamp') %}
{{ is_state('input_boolean.h09_ever_good','on') and ts is number and now().timestamp() >= ts }}
state: >-
{% set ts = state_attr('input_datetime.h09_last_good','timestamp') %}
{{ ((now().timestamp() - ts) | int) if ts is number else none }}
binary_sensor:
- name: "H09 Report Overdue"
unique_id: hixinwu_h09_overdue_v1
device_class: problem
availability: "{{ is_number(states('sensor.h09_report_age')) }}"
state: "{{ states('sensor.h09_report_age') | float(0) > 900 }}"
参考:[5] HA2026.10.2 expected_disconnect实体可用语义[6] HA2026.10.2事件和service分支[7] HA2026.10.2 input_datetime首次默认与恢复[8] HA2026.10.2布尔helper恢复
一轮成功升级,要验证它真的回到可睡状态
想立即维护,闭合实体开关把睡着的节点唤醒;或等TIMER醒来,在60秒窗内打开API Maintenance Hold。睡着时HA命令到不了芯片,不能把开关前的旧状态当唤醒命令已执行。
保持实体LOW,执行esphome upload到真实IP。记录OTA_ACTIVE、完成后的新启动版本与唤醒语义;台架中断测试后确认OTA_ERROR_HOLD并重试。网络断电未必经过错误回调,仍需实体/串口恢复。确认新固件后关闭API保持、显式Exit、打开实体开关;期限过了可立即睡,下一次TIMER应重新取新样本。
- 先记固件SHA、密钥备份、桥USB与恢复方式。
- 物理LOW保持>60秒,确认窗口不会先关掉。
- 成功OTA一次,再仅在有串口恢复的台架做中断测试并记录。
- 错误处理完按Exit,实体开关回HIGH;观察TIMER与EXT0分别唤醒。
- 单独RESET/重新上电,确认非深睡语义和RAM保持清零。
参考:[2] 固定深睡调度/prevent/allow逻辑[3] S3 EXT0电平与深睡入口[4] 固定OTA on_begin/on_error/on_abort配置[14] IDF5.5.5 sleep API/wakeup cause
网络失联不等于有一份补传历史
正常窗口没连上Wi-Fi也会按计划睡,下一次重新尝试;本例没有离线事件缓冲。恢复热点超时90秒,晚于60秒窗口,所以配网失败时先闭合实体开关保持醒着,再等热点。API/Wi-Fi reboot_timeout=0,避免离线直接触发循环重启。
SHT40固定实现读失败不发布新值,因此温湿度都加15秒timeout→NaN,Runtime再检查两路都有限、湿度范围和完整64位年龄。冷启动模块failed需要断电检查后RESET,不能把HA缓存值当修复。读数有效后必须看到新的valid事件才更新Good Report Age。
| 现象 | 检查 | 恢复 |
|---|---|---|
| 无IP/无热点 | 60s窗口早于90s热点 | 先实体LOW,再等/配网 |
| API关仍不睡 | 实体/OTA/错误保持 | 完成传输、显式Exit并松实体 |
| 升级后离线 | RAM/API保持清零 | 实体LOW + RESET,看串口版本 |
| 旧值仍available | expected disconnect缓存 | 看实际事件年龄,不仅看数值 |
| valid=false | 15s陈旧/CRC/冷启失败 | 断电核供电I²C、RESET后取新事件 |
参考:[5] HA2026.10.2 expected_disconnect实体可用语义[13] ESPHome2026.9.1 SHT4x失败不发布新值
把维护窗口能量与正常周期分开记录
5分钟是睡眠时间,启动、连接、60秒运行和关机耗时都在周期之外;ESPHome还会进行组件关机,不能称精确6分钟采样周期。维护保持可能无限醒着,OTA耗电也属于不同场景。
用power-observations.csv分整机USB台架、独立电池供电;每组记录仪器、输出电压、起止时刻、awake/sleep/maintenance/OTA能量和复位原因。未测前不给µA/天数,也不拿厂商典型效率当本机实测。电池保护触发会掉电,不是一次计划深睡。
参考:[2] 固定深睡调度/prevent/allow逻辑[3] S3 EXT0电平与深睡入口[11] Pololu #2123固定5V、输入范围与负载条件[12] Adafruit PID2011保护电池与充电条件
构建和主机已过,物理维护表留空待做
本轮config和固件编译Exit0;同源Runtime主机覆盖实体/API保持、传输拒绝退出、错误保持、完整32位周期后的陈旧和RAM重启。实际HA包的Jinja分支/事件字段/helper门也在主机夹具渲染,但没有运行HA自动化引擎。最初YAML未引号三元表达式造成的config失败和修复日志保留。
按observations.csv依序做上电、物理保持、API保持、成功OTA、中断、退出、TIMER、EXT0、RESET、断网两周期与HA重启。每项都填真实串口wake/mode、HA收件时刻和供电条件;未执行格保持pending。下载README列有故障恢复和许可,不把同源测试当深睡或电池通过。
参考:[1] ESPHome2026.9.1深睡配置/KEEP_AWAKE校验[2] 固定深睡调度/prevent/allow逻辑[4] 固定OTA on_begin/on_error/on_abort配置[5] HA2026.10.2 expected_disconnect实体可用语义[14] IDF5.5.5 sleep API/wakeup cause
资料来源与版本说明
已实际阅读文末固定版本的关键实现与指定厂商资料,来源/构建/主机夹具分别留证。具体板卡、固件和依赖范围见版本记录;指定实板、HA与现场结果尚未执行。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。
- ESPHome2026.9.1深睡配置/KEEP_AWAKE校验
- 固定深睡调度/prevent/allow逻辑
- S3 EXT0电平与深睡入口
- 固定OTA on_begin/on_error/on_abort配置
- HA2026.10.2 expected_disconnect实体可用语义
- HA2026.10.2事件和service分支
- HA2026.10.2 input_datetime首次默认与恢复
- HA2026.10.2布尔helper恢复
- 官方v1.1引脚与供电入口
- PID4885引脚/供电
- Pololu #2123固定5V、输入范围与负载条件
- Adafruit PID2011保护电池与充电条件
- ESPHome2026.9.1 SHT4x失败不发布新值
- IDF5.5.5 sleep API/wakeup cause