家庭自动化 / 项目实战发布 内容更新

ESPHome 深睡节点:留出可靠的 OTA 维护窗口

用实体唤醒、API保持和OTA失败锁定维护睡眠节点,再以HA实际收件年龄识别旧值,明确独立电池供电与未测边界。

读完能做什么
构建一个能进入、保持并退出维护的睡眠节点,完成报告年龄HA配置与台架恢复步骤,明确何时必须按实体开关。
开始之前
先完成所列前置,能检查低压接线、串口与工具版本;准备独立台架和备份。
所需器材
官方S3 v1.1 N8R8、PID4885 SHT40、SPST、10kΩ外部上拉、桥USB、现有HA实验环境、独立供电候选:Pololu S7V8F5 #2123与Adafruit PID2011、正确极性线束
版本组合
开发框架:ESPHome2026.9.1ESP-IDF:5.5.5板卡:DevKitC-1 v1.1 / WROOM-1-N8R8Python / HA参考源码:3.12.13 / Core2026.10.2
适用范围

ESPHome2026.9.1/IDF5.5.5;60s运行/5min深睡,GPIO5 LOW外唤醒;同一DevKit整机,不宣称电池续航;HA/OTA/深睡实板pending。

这篇验证到了哪一步

资料核对已通过
作者逐章核查及固定源码比对见 docs/esp32-evidence/home-station.md 与 home-station-sources.json;不是独立复核结论。验证日期:
构建 / 配置已通过
实际 config/固件编译原日志与当前源/生成CPP/固件SHA见 docs/esp32-evidence/home-station-builds.json。对象:esp32s3 / N8R8版本:ESPHome2026.9.1 / IDF5.5.5验证日期:
仿真已通过
同源主机模型及明确范围内的本机服务/模板夹具通过;不是芯片仿真、HA引擎或硬件,见 home-station-host.json。对象:macOS arm64 host fixture验证日期:
本站实板待验证
尚未完成指定板、模块、电源、HA、OTA或长时间现场实验;空观察表保留待填。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 项目实战

节点能睡,还得能叫醒并完成升级

电池节点最容易在维护时暴露问题:刚找到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

02 / 项目实战

先让一个开关不依赖网络

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保护电池与充电条件

03 / 项目实战

保持、退出和失败锁定都在完整配置里

下载包提供完整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配置

04 / 项目实战

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秒是本项目报告过期策略。年龄模板按分钟更新,不是精确每秒倒计时。

  1. HA添加ESPHome集成,填真实IP/key并核实体ID。
  2. 本例仅发送esphome.*事件,无须开启直接HA服务调用权限;在开发者工具监听该事件,记录真实device_id。
  3. 把HA包放/config/packages,合并packages配置,只替换真实device_id过滤器;已有homeassistant项只合并子项。
  4. 运行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恢复

05 / 项目实战

一轮成功升级,要验证它真的回到可睡状态

想立即维护,闭合实体开关把睡着的节点唤醒;或等TIMER醒来,在60秒窗内打开API Maintenance Hold。睡着时HA命令到不了芯片,不能把开关前的旧状态当唤醒命令已执行。

保持实体LOW,执行esphome upload到真实IP。记录OTA_ACTIVE、完成后的新启动版本与唤醒语义;台架中断测试后确认OTA_ERROR_HOLD并重试。网络断电未必经过错误回调,仍需实体/串口恢复。确认新固件后关闭API保持、显式Exit、打开实体开关;期限过了可立即睡,下一次TIMER应重新取新样本。

  1. 先记固件SHA、密钥备份、桥USB与恢复方式。
  2. 物理LOW保持>60秒,确认窗口不会先关掉。
  3. 成功OTA一次,再仅在有串口恢复的台架做中断测试并记录。
  4. 错误处理完按Exit,实体开关回HIGH;观察TIMER与EXT0分别唤醒。
  5. 单独RESET/重新上电,确认非深睡语义和RAM保持清零。

参考:[2] 固定深睡调度/prevent/allow逻辑[3] S3 EXT0电平与深睡入口[4] 固定OTA on_begin/on_error/on_abort配置[14] IDF5.5.5 sleep API/wakeup cause

06 / 项目实战

网络失联不等于有一份补传历史

正常窗口没连上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,看串口版本
旧值仍availableexpected disconnect缓存看实际事件年龄,不仅看数值
valid=false15s陈旧/CRC/冷启失败断电核供电I²C、RESET后取新事件

参考:[5] HA2026.10.2 expected_disconnect实体可用语义[13] ESPHome2026.9.1 SHT4x失败不发布新值

07 / 项目实战

把维护窗口能量与正常周期分开记录

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保护电池与充电条件

08 / 项目实战

构建和主机已过,物理维护表留空待做

本轮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

BACK TO THE SOURCE

资料来源与版本说明

已实际阅读文末固定版本的关键实现与指定厂商资料,来源/构建/主机夹具分别留证。具体板卡、固件和依赖范围见版本记录;指定实板、HA与现场结果尚未执行。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. ESPHome2026.9.1深睡配置/KEEP_AWAKE校验
  2. 固定深睡调度/prevent/allow逻辑
  3. S3 EXT0电平与深睡入口
  4. 固定OTA on_begin/on_error/on_abort配置
  5. HA2026.10.2 expected_disconnect实体可用语义
  6. HA2026.10.2事件和service分支
  7. HA2026.10.2 input_datetime首次默认与恢复
  8. HA2026.10.2布尔helper恢复
  9. 官方v1.1引脚与供电入口
  10. PID4885引脚/供电
  11. Pololu #2123固定5V、输入范围与负载条件
  12. Adafruit PID2011保护电池与充电条件
  13. ESPHome2026.9.1 SHT4x失败不发布新值
  14. IDF5.5.5 sleep API/wakeup cause