From caa9b343d812a83c15b5d4bf802664d65f0758c8 Mon Sep 17 00:00:00 2001 From: Bunton33 Date: Tue, 25 Aug 2026 23:56:12 -0400 Subject: [PATCH 01/12] Trim comments to a single line throughout Collapses every multi-line YAML comment across Core.yaml, H-3.yaml and H-3D.yaml to one line of roughly 5-8 words, matching the CAST-1 pass. Comment-only change: the expanded `esphome config` output is an identical line multiset before and after (the only textual difference is nondeterministic key ordering inside logger.log actions). Verified with esphome 2026.7.1: config valid on both variants, and `esphome compile H-3.yaml` succeeds. Co-Authored-By: Claude Fable 5 --- Integrations/ESPHome/Core.yaml | 176 ++++++++------------------------- Integrations/ESPHome/H-3.yaml | 83 +++++----------- Integrations/ESPHome/H-3D.yaml | 23 ++--- 3 files changed, 72 insertions(+), 210 deletions(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 73f35a7..b329755 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -4,9 +4,9 @@ substitutions: run_duration_default: "90s" run_duration_gpio: "90s" timer_wake_enabled: "true" - # Hold confirm after the button's first real state. Felt hold = boot + this. + # Hold confirm after first real state wake_hold_confirm: "400ms" - # Awake equivalent. MUST exceed the 1s ON ceiling of the patterns below. + # Awake equivalent; must exceed 1s ceiling hold_gesture_length: "1.2s" esp32: @@ -25,8 +25,7 @@ output: rtttl: id: rtttl_buzzer output: buzzer - # song_active keeps the play_buzzer API action from blanking the lights or - # triggering a sleep. + # song_active guards play_buzzer from sleeping on_finished_playback: - if: condition: @@ -45,7 +44,7 @@ globals: restore_value: no type: uint32_t initial_value: '0' - # First boot after a flash or factory reset, not yet acknowledged. + # First boot, not yet acknowledged - id: runTest restore_value: yes type: bool @@ -54,13 +53,12 @@ globals: type: bool restore_value: no initial_value: "false" - # Set from is_playing() AFTER each rtttl.play, never before - a malformed - # string never starts, so a pre-set flag would strand the lights on forever. + # Set from is_playing() after play, never before - id: song_active type: bool restore_value: no initial_value: "false" - # Which button caused this wake (1/2/3, 0 = none). + # Which button woke us (0 = none) - id: wake_button type: uint8_t restore_value: no @@ -68,8 +66,7 @@ globals: deep_sleep: id: deep_sleep_1 - # sleep_duration deliberately NOT set here - H-3.yaml adds it (24h), H-3D - # omits it so esp_sleep_enable_timer_wakeup() is never called. + # sleep_duration set per variant, not here run_duration: default: ${run_duration_default} gpio_wakeup_reason: ${run_duration_gpio} @@ -101,10 +98,7 @@ button: id: factory_reset_button name: "Factory Reset" entity_category: diagnostic - # Was internal: true, i.e. a 10s hold on the reset button was the only way - # to clear a unit. Exposed but disabled_by_default so it cannot be hit by - # accident from a dashboard, while still being available to a user who is - # deliberately handing the ornament on. + # Exposed but off by default; avoids accidents disabled_by_default: true - platform: template id: btn_play_song_1 @@ -153,9 +147,7 @@ number: - platform: template name: "Sleep Duration" id: deep_sleep_sleep_duration - # 1, not 0. The on_value lambda below refuses 0 (a 0us timer wake is an - # unrecoverable boot loop), so a settable 0 only produced an entity that - # read "0 h" while the device went on sleeping for the previous duration. + # 1, not 0: a 0us timer wake loops forever min_value: 1 max_value: 800 step: 1 @@ -244,7 +236,7 @@ text: restore_value: true mode: text optimistic: true - # Hook looped 3x: 24.0s, 247 of the 254 chars the NVS preference holds. + # Hook looped 3x; 247 of 254 NVS chars initial_value: "Barbie Girl:o=5,d=8,b=125,b=125:g#,e,g#,c#6,4a,4p,f#,d#,f#,b,4g#,f#,e,4p,e,c#,4f#,4c#,4p,f#,e,4g#,4f#,4p,g#,e,g#,c#6,4a,4p,f#,d#,f#,b,4g#,f#,e,4p,e,c#,4f#,4c#,4p,f#,e,4g#,4f#,4p,g#,e,g#,c#6,4a,4p,f#,d#,f#,b,4g#,f#,e,4p,e,c#,4f#,4c#,4p,f#,e,4g#,4f#" - platform: template name: "Song Text 6" @@ -254,10 +246,7 @@ text: restore_value: true mode: text optimistic: true - # Frozen chorus: hook / answer / hook / closing line. 11.9s, 105 of the - # 254 chars the NVS preference holds. Song 1 is "Frosty the Snowman" - a - # different tune - so this one is named BuildASnowman to keep the two - # apart in the rtttl logs. + # Frozen chorus; song 1 is a different tune initial_value: "BuildASnowman:d=8,o=5,b=136:g,g,g,d,g,b,4a,2b,8p,g,g,d,g,b,2a,4p,g,g,g,d,g,b,4a,2b,8p,g,g,g,g,d,g,b,4a,1g" binary_sensor: @@ -288,20 +277,16 @@ binary_sensor: condition: lambda: 'return (millis() - id(button_press_timestamp)) >= 3000;' then: - # Turn Prevent Sleep On. The explicit prevent call is for - # H-3D, whose prevent_sleep switch has no on_turn_on hook. + # Explicit prevent call is for H-3D - switch.turn_on: prevent_sleep - lambda: 'id(deep_sleep_1).prevent_deep_sleep();' else: - # Only this branch is swallowed - factory reset must always - # work. + # Only this branch swallowed; reset always works - if: condition: lambda: 'return !id(swallow_button_action);' then: - # script.wait, not delay(): a lambda delay blocks - # Application::loop(), so the status colour never - # rendered and lightTest stomped it mid-script. + # script.wait, not delay(): delay blocks loop() - script.execute: statusCheck - script.wait: statusCheck - script.execute: lightTest @@ -317,14 +302,13 @@ binary_sensor: allow_other_uses: true name: "Song Button 1" entity_category: "diagnostic" - # Debounce - required by the multi-click patterns. + # Debounce required by multi-click patterns filters: - delayed_on_off: 20ms on_press: then: - script.execute: dismiss_first_boot - # Same rule as button 3: the inter-click gap (0.4s) MUST stay shorter than - # the single-click wait (0.5s), or a second press in the overlap fires both. + # Inter-click gap must stay under single-click wait on_multi_click: # Double click -> song 4 - timing: @@ -348,12 +332,7 @@ binary_sensor: lambda: 'return !id(swallow_button_action);' then: - script.execute: play_song_1 - # Hold -> play_song_4. Same gesture the deep-sleep wake path uses, so it is - # learned once and works asleep or awake. A single open-ended timing takes - # a fast path in MultiClickTriggerBase that fires mid-hold; the two patterns - # above cap ON at 1s and have already invalidated, so exactly one fires. - # invalid_cooldown: 0s is load-bearing - the default 1s would block matching - # after a short tap, making "tap, then hold" (the obvious retry) do nothing. + # Hold -> song 4; invalid_cooldown 0s is load-bearing - timing: - ON for at least ${hold_gesture_length} invalid_cooldown: 0s @@ -379,10 +358,7 @@ binary_sensor: on_press: then: - script.execute: dismiss_first_boot - # Same rule as buttons 1 and 3: the inter-click gap (0.4s) MUST stay shorter - # than the single-click wait (0.5s), or a second press in the overlap fires - # both. Converted from a plain on_release when song 6 was added, so a single - # click now waits out that 0.5s before playing - matching the other two. + # Inter-click gap must stay under single-click wait on_multi_click: # Double click -> song 6 - timing: @@ -406,12 +382,7 @@ binary_sensor: lambda: 'return !id(swallow_button_action);' then: - script.execute: play_song_2 - # Hold -> play_song_6. Same gesture the deep-sleep wake path uses, so it is - # learned once and works asleep or awake. A single open-ended timing takes - # a fast path in MultiClickTriggerBase that fires mid-hold; the two patterns - # above cap ON at 1s and have already invalidated, so exactly one fires. - # invalid_cooldown: 0s is load-bearing - the default 1s would block matching - # after a short tap, making "tap, then hold" (the obvious retry) do nothing. + # Hold -> song 6; invalid_cooldown 0s is load-bearing - timing: - ON for at least ${hold_gesture_length} invalid_cooldown: 0s @@ -437,9 +408,7 @@ binary_sensor: on_press: then: - script.execute: dismiss_first_boot - # Independent triggers on one pin: the inter-click gap (0.4s) MUST stay - # shorter than the single-click wait (0.5s) or a press in the overlap - # fires both. + # Inter-click gap must stay under single-click wait on_multi_click: # Double click -> song 5 (Barbie Girl) - timing: @@ -463,12 +432,7 @@ binary_sensor: lambda: 'return !id(swallow_button_action);' then: - script.execute: play_song_3 - # Hold -> play_song_5. Same gesture the deep-sleep wake path uses, so it is - # learned once and works asleep or awake. A single open-ended timing takes - # a fast path in MultiClickTriggerBase that fires mid-hold; the two patterns - # above cap ON at 1s and have already invalidated, so exactly one fires. - # invalid_cooldown: 0s is load-bearing - the default 1s would block matching - # after a short tap, making "tap, then hold" (the obvious retry) do nothing. + # Hold -> song 5; invalid_cooldown 0s is load-bearing - timing: - ON for at least ${hold_gesture_length} invalid_cooldown: 0s @@ -490,12 +454,7 @@ sensor: update_interval: 60s light: - # All 37 LEDs on ONE data line on GPIO3, wired: - # body(24) -> mouth(5) -> nose(1) -> eyes(2) -> hat(5) - # Index map (partition from/to are INCLUSIVE): - # body 0-23 mouth 24-28 nose 29 eyes 30-31 hat 32-36 - # The chain is internal on purpose: turning the parent light on/off rewrites - # the whole buffer and stomps whatever the partitions are doing. + # 37 LEDs on one GPIO3 line; chain stays internal - platform: esp32_rmt_led_strip id: led_chain internal: true @@ -505,7 +464,7 @@ light: num_leds: 37 rgb_order: grb rmt_symbols: 48 - # One data line = one correction for every segment (head parts were 30%). + # One data line, one correction per segment color_correct: [40%, 40%, 40%] effects: &led_effects - pulse: @@ -802,8 +761,7 @@ light: } } - # Segment views into the chain. Effects run per segment - an - # addressable_lambda sees only that segment's LEDs. + # Segment views; effects run per segment # LEDs 0-23 - platform: partition @@ -859,8 +817,7 @@ script: - light.turn_off: nose_light - light.turn_off: hat_light - # Acknowledges the first-boot indicator. Every button calls this on_press; - # it is a no-op once runTest is clear. + # Acknowledges first-boot indicator; no-op once clear - id: dismiss_first_boot then: - if: @@ -873,43 +830,23 @@ script: - script.execute: swallow_clear - script.execute: sleep_grace_period - # Hands back the block testScript took, but NOT immediately: run_duration has - # already elapsed and latched next_enter_deep_sleep_, so releasing it here - # would sleep the unit on the next loop. Give the press a normal wake window. + # Delays sleep release so the press gets a window - id: sleep_grace_period - # restart, not the default single: a second execute must push the window - # out, not be dropped on the floor. + # restart: a second execute must extend the window mode: restart then: - delay: ${run_duration_gpio} - script.execute: resumeSleep - # Eats the button action an acknowledging or waking press is about to - # produce. 2s covers the worst case: a 1s ON ceiling plus the 0.5s - # single-click wait, and the 1.2s hold. Time-bounded rather than consumed by - # the handler - a flag the handler clears leaks whenever the expected pattern - # never matches, and would then swallow the NEXT press. + # Eats the acknowledging press; 2s worst case - id: swallow_clear - # restart, not the default single. dismiss_first_boot and wake_decoder can - # both arm this on the same press; with mode single the second execute was - # discarded and the swallow window ended early. + # restart: two callers can arm on one press mode: restart then: - delay: 2s - lambda: "id(swallow_button_action) = false;" - # Deep-sleep wake decoding. BOTH device files call this and do nothing else, - # so their decoders cannot drift apart. Do not inline it back into either. - # - # Do NOT read the button level synchronously here: at on_boot -100 we are still - # in Application::setup() with no scheduler ticks, so the 20ms filter has not - # fired and state/has_state are false for every button. The wait_until below is - # what yields to the loop and lets the real level arrive. - # - # The gesture is a HOLD, not a double-click, and that is forced: boot from the - # ext1 wake outlasts a ~180-350ms double click, so both edges are gone before - # the ISR attaches. ext1 gives a latched mask with no count. A hold is a level, - # and a level survives the boot window. + # Shared wake decoding; hold gesture, not double-click - id: wake_decoder then: - lambda: |- @@ -971,7 +908,7 @@ script: condition: lambda: 'return id(wake_button) == 1;' then: - # ~20-40ms. Timeout is a dead-man switch: falls through to song 1. + # Timeout is a dead-man switch to song 1 - wait_until: condition: lambda: 'return id(back_button_1).has_state();' @@ -980,7 +917,7 @@ script: condition: binary_sensor.is_on: back_button_1 then: - # Releasing inside the window ends this wait early. + # Releasing early ends this wait - wait_until: condition: binary_sensor.is_off: back_button_1 @@ -1019,10 +956,7 @@ script: - button.press: btn_play_song_3 else: - button.press: btn_play_song_3 - # The waking press is still live and its normal handler has not fired - # yet, and no play_song_* script blocks, so mode: single gives no dedup - - # the second execute just restarts the song. Armed here, AFTER the - # gesture resolves, so the window runs from the release, not from boot. + # Armed after the gesture resolves, not at boot - if: condition: lambda: 'return id(wake_button) != 0;' @@ -1055,10 +989,7 @@ script: - rtttl.play: rtttl: !lambda 'return id(song_1).state;' - lambda: "id(song_active) = id(rtttl_buzzer).is_playing();" - # Rtttl::play() returns early on a malformed string without ever - # reaching RUNNING, so on_finished_playback never fires and the lights - # above would stay on forever. Song Text is user-editable; one typo - # should not strand the ornament lit. + # Malformed string never fires on_finished_playback - if: condition: lambda: 'return !id(song_active);' @@ -1091,10 +1022,7 @@ script: - rtttl.play: rtttl: !lambda 'return id(song_2).state;' - lambda: "id(song_active) = id(rtttl_buzzer).is_playing();" - # Rtttl::play() returns early on a malformed string without ever - # reaching RUNNING, so on_finished_playback never fires and the lights - # above would stay on forever. Song Text is user-editable; one typo - # should not strand the ornament lit. + # Malformed string never fires on_finished_playback - if: condition: lambda: 'return !id(song_active);' @@ -1127,10 +1055,7 @@ script: - rtttl.play: rtttl: !lambda 'return id(song_3).state;' - lambda: "id(song_active) = id(rtttl_buzzer).is_playing();" - # Rtttl::play() returns early on a malformed string without ever - # reaching RUNNING, so on_finished_playback never fires and the lights - # above would stay on forever. Song Text is user-editable; one typo - # should not strand the ornament lit. + # Malformed string never fires on_finished_playback - if: condition: lambda: 'return !id(song_active);' @@ -1163,10 +1088,7 @@ script: - rtttl.play: rtttl: !lambda 'return id(song_4).state;' - lambda: "id(song_active) = id(rtttl_buzzer).is_playing();" - # Rtttl::play() returns early on a malformed string without ever - # reaching RUNNING, so on_finished_playback never fires and the lights - # above would stay on forever. Song Text is user-editable; one typo - # should not strand the ornament lit. + # Malformed string never fires on_finished_playback - if: condition: lambda: 'return !id(song_active);' @@ -1199,10 +1121,7 @@ script: - rtttl.play: rtttl: !lambda 'return id(song_5).state;' - lambda: "id(song_active) = id(rtttl_buzzer).is_playing();" - # Rtttl::play() returns early on a malformed string without ever - # reaching RUNNING, so on_finished_playback never fires and the lights - # above would stay on forever. Song Text is user-editable; one typo - # should not strand the ornament lit. + # Malformed string never fires on_finished_playback - if: condition: lambda: 'return !id(song_active);' @@ -1235,10 +1154,7 @@ script: - rtttl.play: rtttl: !lambda 'return id(song_6).state;' - lambda: "id(song_active) = id(rtttl_buzzer).is_playing();" - # Rtttl::play() returns early on a malformed string without ever - # reaching RUNNING, so on_finished_playback never fires and the lights - # above would stay on forever. Song Text is user-editable; one typo - # should not strand the ornament lit. + # Malformed string never fires on_finished_playback - if: condition: lambda: 'return !id(song_active);' @@ -1271,10 +1187,7 @@ script: - rtttl.play: rtttl: Sandstorm:d=16,o=6,b=85:e,e,e,e,8e,e,e,a,a,a,a,g,g,g,d,e,e,e,e, 8e,e,a,e,e,e,e,8e,e,d,e,e,e,e,e,8e,e,e,a,a,a,a,g,g,g,d,e,e,e,e,8e,e, a,e,e,e,e,8e,e,d,e,e,e,e,e,8e,e,e,a,a,a,a,g,g,g,d,e,e,e,e,8e,e,a,e, e,e,e,8e,e,d,e - lambda: "id(song_active) = id(rtttl_buzzer).is_playing();" - # Rtttl::play() returns early on a malformed string without ever - # reaching RUNNING, so on_finished_playback never fires and the lights - # above would stay on forever. Song Text is user-editable; one typo - # should not strand the ornament lit. + # Malformed string never fires on_finished_playback - if: condition: lambda: 'return !id(song_active);' @@ -1282,12 +1195,10 @@ script: - logger.log: "Song did not start - check the RTTTL string" - script.execute: all_lights_off - # First-boot indicator: every zone green at 30%, no timeout. Only - # dismiss_first_boot clears it, so an un-acknowledged unit shows it again on - # every boot. + # First-boot indicator; only dismiss_first_boot clears it - id: testScript then: - # H-3D would otherwise sleep out from under it after run_duration. + # H-3D would otherwise sleep mid-test - lambda: "id(deep_sleep_1).prevent_deep_sleep();" - light.turn_on: id: body_light @@ -1320,8 +1231,7 @@ script: green: 100% blue: 0% - # Manual all-zone check from a short reset-button press. Timed, and never - # touches runTest - it cannot re-arm the first-boot indicator. + # Manual zone check; never touches runTest - id: lightTest then: - light.turn_on: diff --git a/Integrations/ESPHome/H-3.yaml b/Integrations/ESPHome/H-3.yaml index 6d7f9bb..8520cd9 100644 --- a/Integrations/ESPHome/H-3.yaml +++ b/Integrations/ESPHome/H-3.yaml @@ -1,6 +1,5 @@ substitutions: - # Manifest URL bases. Stable = GitHub Pages (main branch builds). - # Beta = rolling "beta-fw" pre-release assets (beta branch builds). + # Manifest bases: stable=Pages, beta=release assets stable_manifest_base: "https://apolloautomation.github.io/H-3" beta_manifest_base: "https://github.com/ApolloAutomation/H-3/releases/download/beta-fw" ota_stable_manifest: "${stable_manifest_base}/firmware/manifest.json" @@ -18,11 +17,10 @@ esphome: name: "ApolloAutomation.H-3" version: "${version}" - # Hard floor is 2024.6.0 (list-form ota:, http_request update, standalone - # safe_mode:); tracks the rest of the fleet rather than the true minimum. + # Hard floor is 2024.6.0; tracks the fleet min_version: 2025.11.0 on_boot: - # Point the update entity at the selected channel manifest. + # Point update entity at selected manifest - priority: -100 then: - script.execute: apply_ota_source @@ -32,9 +30,7 @@ esphome: - lambda: |- id(deep_sleep_1).prevent_deep_sleep(); - # No set_sleep_duration here: Core.yaml's deep_sleep_sleep_duration - # on_value already converts at boot, and its ${timer_wake_enabled} guard - # is the one that matters. + # Core.yaml's on_value already converts at boot - priority: -100 then: - if: @@ -50,7 +46,7 @@ esphome: - lambda: |- ESP_LOGI("Apollo", "Deep sleep opted in - enabling automatic sleep"); id(deep_sleep_1).allow_deep_sleep(); - # Decoding lives in Core.yaml's wake_decoder; this block must stay + # Decoding lives in Core.yaml's wake_decoder - priority: -100 then: - script.execute: wake_decoder @@ -61,8 +57,7 @@ esphome: - lambda: "return id(runTest);" then: - script.execute: testScript - # Re-apply the Bluetooth Proxy switch after all components set up, so BLE - # scanning matches the switch state restored from flash. + # Re-apply Bluetooth Proxy switch after setup - priority: -300 then: - if: @@ -95,41 +90,26 @@ ota: esp32_ble_tracker: id: ble_tracker - # continuous is FALSE on purpose. The Bluetooth Proxy switch owns scanning, - # and the on_boot priority -300 handler restores it. With continuous: true - # the tracker starts a scan inside its own setup(), i.e. before that handler - # runs, so a fresh unit scanned for a moment even with the switch off - which - # is also the window esp32_improv needs to advertise on. + # continuous FALSE on purpose; the switch owns scanning scan_parameters: continuous: false bluetooth_proxy: id: ble_proxy -# Improv over BLE. Required for the Made for ESPHome badge on any Wi-Fi -# project; improv_serial alone does not satisfy it. The component only -# advertises while the device has no Wi-Fi credentials (or has been off the -# network for wifi_timeout), and stops itself once wifi connects, so it costs -# nothing on a provisioned unit. Measured cost of adding it: +432 B RAM and -# +21 KB flash, because esp32_ble was already linked in for the proxy. +# Improv over BLE; required for the M4E badge esp32_improv: id: ble_improv - # 'none' = no physical authorization step, matching ESPHome's own reference - # ESP32-C6 factory config. Swap in a binary_sensor id here if the ornament - # should require a button press before it will accept credentials. + # 'none' = no physical authorization step authorizer: none status_indicator: improv_status_led http_request: id: http_request_client verify_ssl: true - # GitHub release-asset downloads answer with a redirect carrying a - # ~3.6 KB Content-Security-Policy header; each header line must fit - # this buffer or the request fails with "HTTP_CLIENT: Out of buffer". + # Fits GitHub's ~3.6KB CSP redirect header buffer_size_rx: 5120 - # The redirect target is a signed URL with a ~850-char query string; the - # follow-up request line must fit the TX buffer or esp_http_client_open - # fails with "Out of buffer" before sending anything. + # Fits GitHub's long signed redirect URL buffer_size_tx: 2048 safe_mode: @@ -139,8 +119,7 @@ update: - platform: http_request id: update_http_request name: Firmware Update - # Defaults to the empty device class, which renders as a generic update in - # Home Assistant. This one really is firmware. + # This one really is firmware device_class: firmware source: ${ota_stable_manifest} @@ -164,11 +143,7 @@ web_server: port: 80 version: 3 -# Drives the hat zone for esp32_improv. status_indicator must be a -# BinaryOutput, not a light, and it is the ONLY thing that sets -# improv::CAPABILITY_IDENTIFY - without it the provisioning app cannot ask the -# ornament to identify itself. Improv toggles this at most ~10 Hz and only on a -# real change, so it never floods the LED chain. +# Drives hat zone; must be a BinaryOutput output: - platform: template type: binary @@ -189,15 +164,9 @@ output: api: id: api_server - # No key on purpose. A bare 'encryption:' compiles in Noise support without - # baking a secret into a public config (Made for ESPHome forbids secrets and - # passwords in the published YAML), and ESPHome keeps the plaintext helper - # alongside it precisely so an adopting dashboard can hand the device a key - # over plaintext on first adoption. + # No key on purpose; M4E forbids baked secrets encryption: - # 'actions' is the current spelling; ESPHome still rename_key's the old - # 'services' onto it, and the exposed Home Assistant service names are - # unchanged either way. + # 'actions' is the current spelling for 'services' actions: - action: play_buzzer variables: @@ -253,10 +222,7 @@ select: - script.execute: apply_ota_source button: - # Renamed from "Firmware Update": the update: entity above already carries - # that name, so Home Assistant showed the device two different entities with - # one label. This one force-installs the selected channel even when the - # update entity reports nothing available. + # Renamed: the update: entity already uses that name - platform: template name: "Reinstall Firmware" id: update_firmware @@ -267,13 +233,11 @@ button: - delay: 3s - script.execute: apply_ota_source - script.wait: apply_ota_source - # The manifest fetch runs in its own task; give it a fixed window to land - # (update.is_available stays false for same-version switches). + # Give async manifest fetch time to land - delay: 5s - lambda: id(update_http_request).perform(true); - # Recovery entities. Both are diagnostic and both are things a support agent - # will ask for before suggesting a reflash. + # Recovery entities; support asks for these first - platform: restart name: "Restart" id: restart_button @@ -325,9 +289,7 @@ binary_sensor: - platform: homeassistant name: "OTA Mode" id: ota_mode - # Mirrors a Home Assistant helper back into HA, so it is never dashboard - # material. Kept as an entity rather than internal: true so existing - # automations that reference it do not break. + # Mirrors an HA helper; never dashboard material entity_category: "diagnostic" entity_id: input_boolean.apollo_ota_mode on_press: @@ -350,8 +312,7 @@ sensor: update_interval: 60s entity_category: "diagnostic" -# Support diagnostics. All disabled by default so they cost nothing on a -# normal install, but are one toggle away when someone reports a problem. +# Diagnostics, all disabled by default text_sensor: - platform: wifi_info ip_address: @@ -383,7 +344,7 @@ text_sensor: script: - id: apply_ota_source - # Sets the OTA manifest URL from the Firmware Channel select (Stable/Beta). + # Set OTA manifest from the channel select then: - lambda: |- const bool beta = id(firmware_channel).current_option() == "Beta"; @@ -441,7 +402,7 @@ script: else: - deep_sleep.enter: id: deep_sleep_1 - # Same guards as prevent_sleep's on_turn_off. Normally a no-op on H-3. + # Same guards as prevent_sleep on_turn_off - id: resumeSleep then: - if: diff --git a/Integrations/ESPHome/H-3D.yaml b/Integrations/ESPHome/H-3D.yaml index c2d760f..8c72abf 100644 --- a/Integrations/ESPHome/H-3D.yaml +++ b/Integrations/ESPHome/H-3D.yaml @@ -13,11 +13,9 @@ esphome: # Must match H-3.yaml. min_version: 2025.11.0 on_boot: - # Prevent Sleep is ALWAYS_OFF here, so no boot-time check is needed. - # Deliberately NO set_sleep_duration: H-3D has no timer wake. + # Prevent Sleep is ALWAYS_OFF; no timer wake - # Decoding lives in Core.yaml's wake_decoder; this block must stay - # byte-identical in H-3.yaml and H-3D.yaml. + # Decoding lives in Core.yaml's wake_decoder - priority: -100 then: - script.execute: wake_decoder @@ -36,28 +34,22 @@ esphome: - light.turn_off: nose_light - light.turn_off: hat_light -# 15s bounds a USB/cold power-on only - no timer wake exists here. Button -# wakes keep the full 90s, which must stay above Rudolph's 30.5s. +# 15s bounds cold power-on; button wakes keep 90s substitutions: run_duration_default: "15s" timer_wake_enabled: "false" -# Skips the bootloader SHA-256 of the whole image on every wake - shortens the -# wake and the felt hold. TRADE-OFF: image corruption is no longer caught, and -# with safe_mode off and no OTA that means USB recovery. H-3 keeps validation. +# Skips boot image validation; USB recovery only esp32: framework: sdkconfig_options: CONFIG_BOOTLOADER_SKIP_VALIDATE_IN_DEEP_SLEEP: y -# Disabled: it can never recover (no wifi/OTA) and costs a flash write per boot. -# NOTE: re-enabling it re-imposes a 60s floor on run_duration - boot_is_good_after -# defaults to 1min, and anything shorter trips safe mode after 10 wakes. +# Disabled: cannot recover without wifi/OTA safe_mode: disabled: true -# NOTE: this removes the ONLY debug channel on H-3D - no api, no web_server, -# no OTA. Debugging a field unit means reflashing with a logging build first. +# Removes the ONLY debug channel on H-3D logger: baud_rate: 0 @@ -94,8 +86,7 @@ script: else: - deep_sleep.enter: id: deep_sleep_1 - # No ota_mode here - H-3D has no api/wifi. prevent_sleep is ALWAYS_OFF, so - # unlike H-3 this really does re-arm sleep. + # No ota_mode; H-3D has no api/wifi - id: resumeSleep then: - if: From e9a039dd951e183518bf0579752bc81ce107e57a Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:55:01 -0500 Subject: [PATCH 02/12] Stop the current song before starting a new one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rtttl::play() returns early when a song is already running, so pressing a second button started the new LED effect while the old song kept playing. Each play_song_* script now stops playback before setting up the new song. Version 26.8.26.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index b329755..412e5e2 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,5 +1,5 @@ substitutions: - version: "26.8.25.1" + version: "26.8.26.1" run_duration_default: "90s" run_duration_gpio: "90s" @@ -966,6 +966,8 @@ script: - id: play_song_1 then: + # Must stop first because Rtttl::play() returns early while a song is running + - rtttl.stop: - light.turn_on: brightness: 80% id: body_light @@ -999,6 +1001,8 @@ script: - id: play_song_2 then: + # Must stop first because Rtttl::play() returns early while a song is running + - rtttl.stop: - light.turn_on: brightness: 80% id: body_light @@ -1032,6 +1036,8 @@ script: - id: play_song_3 then: + # Must stop first because Rtttl::play() returns early while a song is running + - rtttl.stop: - light.turn_on: brightness: 80% id: body_light @@ -1065,6 +1071,8 @@ script: - id: play_song_4 then: + # Must stop first because Rtttl::play() returns early while a song is running + - rtttl.stop: - light.turn_on: brightness: 80% id: body_light @@ -1098,6 +1106,8 @@ script: - id: play_song_5 then: + # Must stop first because Rtttl::play() returns early while a song is running + - rtttl.stop: - light.turn_on: brightness: 80% id: body_light @@ -1131,6 +1141,8 @@ script: - id: play_song_6 then: + # Must stop first because Rtttl::play() returns early while a song is running + - rtttl.stop: - light.turn_on: brightness: 80% id: body_light @@ -1164,6 +1176,8 @@ script: - id: play_song_x then: + # Must stop first because Rtttl::play() returns early while a song is running + - rtttl.stop: - light.turn_on: brightness: 80% id: body_light From 3968d608bc8b236730643fd53c900f420f7f8331 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:56:04 -0500 Subject: [PATCH 03/12] Add an All Lights entity for the whole chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home Assistant exposed only the five zone partitions, so there was no way to control all 37 LEDs at once. Adds a sixth partition spanning 0-36 alongside the zones, leaving the parent chain internal. all_lights_off and both on_shutdown blocks clear it so the entity state matches the LEDs, and each play_song_* turns it off first so a running All Lights effect does not fight the zone effects writing the same buffer. Version 26.8.26.2 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 26 +++++++++++++++++++++++++- Integrations/ESPHome/H-3.yaml | 1 + Integrations/ESPHome/H-3D.yaml | 1 + 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 412e5e2..4f6e167 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,5 +1,5 @@ substitutions: - version: "26.8.26.1" + version: "26.8.26.2" run_duration_default: "90s" run_duration_gpio: "90s" @@ -763,6 +763,15 @@ light: # Segment views; effects run per segment + # All 37 LEDs + - platform: partition + id: all_lights + name: "All Lights" + default_transition_length: 0s + segments: + - { id: led_chain, from: 0, to: 36 } + effects: *led_effects + # LEDs 0-23 - platform: partition id: body_light @@ -811,6 +820,7 @@ light: script: - id: all_lights_off then: + - light.turn_off: all_lights - light.turn_off: body_light - light.turn_off: mouth_light - light.turn_off: eyes_light @@ -968,6 +978,8 @@ script: then: # Must stop first because Rtttl::play() returns early while a song is running - rtttl.stop: + # Zone effects below would fight an All Lights effect writing the same buffer + - light.turn_off: all_lights - light.turn_on: brightness: 80% id: body_light @@ -1003,6 +1015,8 @@ script: then: # Must stop first because Rtttl::play() returns early while a song is running - rtttl.stop: + # Zone effects below would fight an All Lights effect writing the same buffer + - light.turn_off: all_lights - light.turn_on: brightness: 80% id: body_light @@ -1038,6 +1052,8 @@ script: then: # Must stop first because Rtttl::play() returns early while a song is running - rtttl.stop: + # Zone effects below would fight an All Lights effect writing the same buffer + - light.turn_off: all_lights - light.turn_on: brightness: 80% id: body_light @@ -1073,6 +1089,8 @@ script: then: # Must stop first because Rtttl::play() returns early while a song is running - rtttl.stop: + # Zone effects below would fight an All Lights effect writing the same buffer + - light.turn_off: all_lights - light.turn_on: brightness: 80% id: body_light @@ -1108,6 +1126,8 @@ script: then: # Must stop first because Rtttl::play() returns early while a song is running - rtttl.stop: + # Zone effects below would fight an All Lights effect writing the same buffer + - light.turn_off: all_lights - light.turn_on: brightness: 80% id: body_light @@ -1143,6 +1163,8 @@ script: then: # Must stop first because Rtttl::play() returns early while a song is running - rtttl.stop: + # Zone effects below would fight an All Lights effect writing the same buffer + - light.turn_off: all_lights - light.turn_on: brightness: 80% id: body_light @@ -1178,6 +1200,8 @@ script: then: # Must stop first because Rtttl::play() returns early while a song is running - rtttl.stop: + # Zone effects below would fight an All Lights effect writing the same buffer + - light.turn_off: all_lights - light.turn_on: brightness: 80% id: body_light diff --git a/Integrations/ESPHome/H-3.yaml b/Integrations/ESPHome/H-3.yaml index 8520cd9..46ed1a6 100644 --- a/Integrations/ESPHome/H-3.yaml +++ b/Integrations/ESPHome/H-3.yaml @@ -69,6 +69,7 @@ esphome: else: - esp32_ble_tracker.stop_scan: on_shutdown: + - light.turn_off: all_lights - light.turn_off: body_light - light.turn_off: mouth_light - light.turn_off: eyes_light diff --git a/Integrations/ESPHome/H-3D.yaml b/Integrations/ESPHome/H-3D.yaml index 8c72abf..a3f11d9 100644 --- a/Integrations/ESPHome/H-3D.yaml +++ b/Integrations/ESPHome/H-3D.yaml @@ -28,6 +28,7 @@ esphome: then: - script.execute: testScript on_shutdown: + - light.turn_off: all_lights - light.turn_off: body_light - light.turn_off: mouth_light - light.turn_off: eyes_light From a8114aecf3531f63ead5d169ae07a7542745d569 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:02:45 -0500 Subject: [PATCH 04/12] Add the Apollo Firmware Version entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H-3 had no entity reporting which firmware it is running, and its ESPHome Version was disabled_by_default so nobody saw it. Matches the pattern already on CAST-1, AIR-1, MSR-2 and PLT-1. Both live in H-3.yaml rather than Core.yaml because H-3D has no api, no web_server and logger baud_rate 0, so text sensors there have nowhere to surface. No version bump: this rides the bump in the All Lights PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/H-3.yaml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Integrations/ESPHome/H-3.yaml b/Integrations/ESPHome/H-3.yaml index 8520cd9..c3dfcac 100644 --- a/Integrations/ESPHome/H-3.yaml +++ b/Integrations/ESPHome/H-3.yaml @@ -20,6 +20,11 @@ esphome: # Hard floor is 2024.6.0; tracks the fleet min_version: 2025.11.0 on_boot: + - priority: 500 + then: + - text_sensor.template.publish: + id: apollo_firmware_version + state: "${version}" # Point update entity at selected manifest - priority: -100 then: @@ -312,7 +317,7 @@ sensor: update_interval: 60s entity_category: "diagnostic" -# Diagnostics, all disabled by default +# Wi-Fi diagnostics, disabled by default text_sensor: - platform: wifi_info ip_address: @@ -339,8 +344,13 @@ text_sensor: - platform: version name: "ESPHome Version" id: esphome_version + hide_timestamp: true + entity_category: "diagnostic" + - platform: template + name: "Apollo Firmware Version" + id: apollo_firmware_version + update_interval: never entity_category: "diagnostic" - disabled_by_default: true script: - id: apply_ota_source From c1c3e55f85814dbbd53c07adca82052d557ab008 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:30:51 -0500 Subject: [PATCH 05/12] Hide the Restart (Safe Mode) button by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing it skips all component setup on the next boot, leaving the ornament dark and unresponsive to its buttons for 5 minutes. No other Apollo product exposes a safe_mode button at all. Sets disabled_by_default: true, matching Factory Reset on this device. Safe mode itself is unchanged, so automatic boot-loop recovery still works; only the manual trigger is hidden. Version 26.8.26.3 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 2 +- Integrations/ESPHome/H-3.yaml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 4f6e167..eb35445 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,5 +1,5 @@ substitutions: - version: "26.8.26.2" + version: "26.8.26.3" run_duration_default: "90s" run_duration_gpio: "90s" diff --git a/Integrations/ESPHome/H-3.yaml b/Integrations/ESPHome/H-3.yaml index 07524e7..7f02189 100644 --- a/Integrations/ESPHome/H-3.yaml +++ b/Integrations/ESPHome/H-3.yaml @@ -249,10 +249,12 @@ button: id: restart_button entity_category: "diagnostic" + # Skips all component setup for 5 minutes, so it must stay off a dashboard - platform: safe_mode name: "Restart (Safe Mode)" id: safe_mode_button entity_category: "diagnostic" + disabled_by_default: true switch: - platform: template From 9e836a937d7f618655d9ac0c9b18b406a0cf7aa2 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:03:31 -0500 Subject: [PATCH 06/12] Make All Lights a master over the zones instead of raw pixels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0-36 partition added in #17 owned the same pixels as the five zone lights with independent state, so turning All Lights off left a zone either physically lit (its effect repaints on its own interval) or dark while still reporting on. Rebuilds it on partition's single_light_id segment form, which wraps each zone light instead of a pixel range. AddressableLightWrapper::write_state returns early when the wrapped light's remote_values.is_on(), so a zone the user turned on is never painted over, and it drives zones with set_publish(false) so a master-driven zone never falsely reports on. The cleanup wiring from #17 is kept: all_lights_off, both on_shutdown blocks, and each play_song_* still clear the master. TRADE-OFF: the wrapper is one pixel per wrapped light, so effects selected on All Lights render as five blocks rather than a 37-LED gradient. Effects on an individual zone are unaffected. Version 26.8.27.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index eb35445..e2cb8ca 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,5 +1,5 @@ substitutions: - version: "26.8.26.3" + version: "26.8.27.1" run_duration_default: "90s" run_duration_gpio: "90s" @@ -763,15 +763,6 @@ light: # Segment views; effects run per segment - # All 37 LEDs - - platform: partition - id: all_lights - name: "All Lights" - default_transition_length: 0s - segments: - - { id: led_chain, from: 0, to: 36 } - effects: *led_effects - # LEDs 0-23 - platform: partition id: body_light @@ -817,6 +808,19 @@ light: - { id: led_chain, from: 32, to: 36 } effects: *led_effects + # Master over the five zones, so a zone the user turned on is left alone + - platform: partition + id: all_lights + name: "All Lights" + default_transition_length: 0s + segments: + - single_light_id: body_light + - single_light_id: mouth_light + - single_light_id: nose_light + - single_light_id: eyes_light + - single_light_id: hat_light + effects: *led_effects + script: - id: all_lights_off then: From c81aa738da4bf3bc3fd6b2fc6e54c24504b4c113 Mon Sep 17 00:00:00 2001 From: Bunton33 Date: Thu, 27 Aug 2026 23:30:32 -0400 Subject: [PATCH 07/12] Update Since Deprecated --- Integrations/ESPHome/Core.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index e2cb8ca..1452d55 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -462,7 +462,7 @@ light: default_transition_length: 0s chipset: WS2812 num_leds: 37 - rgb_order: grb + channel_colors: GRB rmt_symbols: 48 # One data line, one correction per segment color_correct: [40%, 40%, 40%] From 8ac2c1147cd8132bca092d2e991aa3dd489e90e7 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:19:49 -0500 Subject: [PATCH 08/12] Make All Lights the LED chain instead of a partition over the zones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #21 built All Lights as a partition over the five zone lights, which wraps each zone in an AddressableLightWrapper. That wrapper refuses to write a zone whose own state is on, so All Lights could only drive zones that were already off, and it reports one pixel per wrapped light, so effects on it rendered as five blocks rather than a 37 pixel pattern. All 37 LEDs sit on one chain on GPIO3, so the chain itself is the all-lights light. Drop internal: true, name it All Lights, point the five zone partitions at it, and delete the master block. color_correct goes with it. Partitions substitute their own correction for every pixel they write and nothing ever wrote through the chain, so the 40% never reached an LED. Keeping it would cap All Lights alone at 40% while the zones stayed at 100%. Version 26.8.28.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 42 ++++++++++++---------------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index e2cb8ca..8cf84e7 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,5 +1,5 @@ substitutions: - version: "26.8.27.1" + version: "26.8.28.1" run_duration_default: "90s" run_duration_gpio: "90s" @@ -454,18 +454,17 @@ sensor: update_interval: 60s light: - # 37 LEDs on one GPIO3 line; chain stays internal + # 37 LEDs on one GPIO3 line. The chain itself is the All Lights entity, so + # a whole-ornament effect renders across all 37 pixels. - platform: esp32_rmt_led_strip - id: led_chain - internal: true + id: all_lights + name: "All Lights" pin: GPIO3 default_transition_length: 0s chipset: WS2812 num_leds: 37 rgb_order: grb rmt_symbols: 48 - # One data line, one correction per segment - color_correct: [40%, 40%, 40%] effects: &led_effects - pulse: name: "Slow Pulse" @@ -722,8 +721,8 @@ light: // eyes a per-pixel offset reads as a fault, not a pattern. float tint_step = (it.size() >= 3) ? 0.35f : 0.0f; - // color_correct 40% x brightness 80% = ~0.32, so keep values high - // or the colour goes muddy on the wire. + // Songs run at brightness 80%, so keep values high or the + // colour goes muddy on the wire. int red_value = (int)(255.0f * breath); for (int i = 0; i < it.size(); i++) { @@ -748,8 +747,8 @@ light: const uint32_t now = millis(); // Pale ice blue that drifts brighter and dimmer over ~4s. float drift = 0.5f + 0.5f * sinf((now % 4000u) / 4000.0f * 6.28318530718f); - // color_correct 40% x brightness 80% = ~0.32, so keep the base high - // or the blue reads as dark grey on the wire. + // Songs run at brightness 80%, so keep the base high or the + // blue reads as dark grey on the wire. int base_r = (int)(70.0f + 40.0f * drift); int base_g = (int)(120.0f + 50.0f * drift); @@ -769,7 +768,7 @@ light: name: "Body Light" default_transition_length: 0s segments: - - { id: led_chain, from: 0, to: 23 } + - { id: all_lights, from: 0, to: 23 } effects: *led_effects # LEDs 24-28 @@ -778,7 +777,7 @@ light: name: "Mouth Light" default_transition_length: 0s segments: - - { id: led_chain, from: 24, to: 28 } + - { id: all_lights, from: 24, to: 28 } effects: *led_effects # LED 29 @@ -787,7 +786,7 @@ light: name: "Nose Light" default_transition_length: 0s segments: - - { id: led_chain, from: 29, to: 29 } + - { id: all_lights, from: 29, to: 29 } effects: *led_effects # LEDs 30-31 @@ -796,7 +795,7 @@ light: name: "Eyes Light" default_transition_length: 0s segments: - - { id: led_chain, from: 30, to: 31 } + - { id: all_lights, from: 30, to: 31 } effects: *led_effects # LEDs 32-36 @@ -805,20 +804,7 @@ light: name: "Hat Light" default_transition_length: 0s segments: - - { id: led_chain, from: 32, to: 36 } - effects: *led_effects - - # Master over the five zones, so a zone the user turned on is left alone - - platform: partition - id: all_lights - name: "All Lights" - default_transition_length: 0s - segments: - - single_light_id: body_light - - single_light_id: mouth_light - - single_light_id: nose_light - - single_light_id: eyes_light - - single_light_id: hat_light + - { id: all_lights, from: 32, to: 36 } effects: *led_effects script: From a9470bf8aad314da9fb7b06ae8ec62bc37a02acc Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:27:04 -0500 Subject: [PATCH 09/12] Silence the strapping pin warnings on the GPIO4 and GPIO5 buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPIO4 and GPIO5 are the Song Button 2 and Song Button 3 pins, and each warns twice at the top of every build because each is declared under deep_sleep's esp32_ext1_wakeup and again on its binary_sensor. The warning is about external pullups or pulldowns on a strapping pin. Both buttons run on the C6's internal pulldowns, so it does not apply. GPIO9's reset button already carries the same flag. No version bump: ignore_strapping_warning is read by the pin validator and never passed to codegen, so the compiled firmware is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 1452d55..9b981c6 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -73,10 +73,14 @@ deep_sleep: esp32_ext1_wakeup: mode: ANY_HIGH pins: + # GPIO4 and GPIO5 are C6 strapping pins; the buttons drive them through + # internal pulldowns, not external resistors, so the warning does not apply - number: GPIO4 allow_other_uses: true + ignore_strapping_warning: true - number: GPIO5 allow_other_uses: true + ignore_strapping_warning: true - number: GPIO7 allow_other_uses: true @@ -351,6 +355,7 @@ binary_sensor: input: true pulldown: true allow_other_uses: true + ignore_strapping_warning: true name: "Song Button 2" entity_category: "diagnostic" filters: @@ -401,6 +406,7 @@ binary_sensor: input: true pulldown: true allow_other_uses: true + ignore_strapping_warning: true name: "Song Button 3" entity_category: "diagnostic" filters: From d6a8ecb30424e87b4273bdfcf7caff4805cc6ea8 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:33:41 -0500 Subject: [PATCH 10/12] Require ESPHome 2026.8.0 for channel_colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H-3 already uses `channel_colors` on the LED chain, but both device YAMLs still declared min_version: 2025.11.0. `channel_colors` only exists from ESPHome 2026.8.0 (esphome#18474), so an adopter on an older ESPHome hits "Unknown option channel_colors" inside Core.yaml instead of a clear version error. Brings H-3 in line with the rest of the fleet, which moved to 2026.8.0 alongside the channel_colors migration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/H-3.yaml | 2 +- Integrations/ESPHome/H-3D.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Integrations/ESPHome/H-3.yaml b/Integrations/ESPHome/H-3.yaml index 7f02189..0bd2c19 100644 --- a/Integrations/ESPHome/H-3.yaml +++ b/Integrations/ESPHome/H-3.yaml @@ -18,7 +18,7 @@ esphome: version: "${version}" # Hard floor is 2024.6.0; tracks the fleet - min_version: 2025.11.0 + min_version: 2026.8.0 on_boot: - priority: 500 then: diff --git a/Integrations/ESPHome/H-3D.yaml b/Integrations/ESPHome/H-3D.yaml index a3f11d9..8b03ef7 100644 --- a/Integrations/ESPHome/H-3D.yaml +++ b/Integrations/ESPHome/H-3D.yaml @@ -11,7 +11,7 @@ esphome: version: "${version}" # Must match H-3.yaml. - min_version: 2025.11.0 + min_version: 2026.8.0 on_boot: # Prevent Sleep is ALWAYS_OFF; no timer wake From ac51d359c46af85b8ba98f0ae0805eec8e2a38b1 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:03:29 -0500 Subject: [PATCH 11/12] Fix OTA manifest lambda for ESPHome dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 2 +- Integrations/ESPHome/H-3.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 73f35a7..fff6441 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,5 +1,5 @@ substitutions: - version: "26.8.25.1" + version: "26.9.29.1" run_duration_default: "90s" run_duration_gpio: "90s" diff --git a/Integrations/ESPHome/H-3.yaml b/Integrations/ESPHome/H-3.yaml index 6d7f9bb..c67fe2d 100644 --- a/Integrations/ESPHome/H-3.yaml +++ b/Integrations/ESPHome/H-3.yaml @@ -387,8 +387,8 @@ script: then: - lambda: |- const bool beta = id(firmware_channel).current_option() == "Beta"; - std::string url = beta ? "${ota_beta_manifest}" : "${ota_stable_manifest}"; - ESP_LOGI("firmware", "OTA manifest set to: %s", url.c_str()); + const char *url = beta ? "${ota_beta_manifest}" : "${ota_stable_manifest}"; + ESP_LOGI("firmware", "OTA manifest set to: %s", url); id(update_http_request).set_source_url(url); - component.update: update_http_request From d69e6f6ff7c3ccdd37d0320e41ba5b92719f0423 Mon Sep 17 00:00:00 2001 From: Trevor Schirmer <24777085+TrevorSchirmer@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:46:33 -0400 Subject: [PATCH 12/12] Bump version to 26.10.12.1 --- Integrations/ESPHome/Core.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 6ffd3ac..7192fb7 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,5 +1,5 @@ substitutions: - version: "26.9.29.1" + version: "26.10.12.1" run_duration_default: "90s" run_duration_gpio: "90s"