diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 73f35a7..7192fb7 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,12 +1,12 @@ substitutions: - version: "26.8.25.1" + version: "26.10.12.1" 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,18 +66,21 @@ 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} 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 @@ -101,10 +102,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 +151,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 +240,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 +250,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 +281,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 +306,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 +336,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 @@ -372,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: @@ -379,10 +363,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 +387,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 @@ -430,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: @@ -437,9 +414,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 +438,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,23 +460,17 @@ 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. 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 + channel_colors: GRB rmt_symbols: 48 - # One data line = one correction for every segment (head parts were 30%). - color_correct: [40%, 40%, 40%] effects: &led_effects - pulse: name: "Slow Pulse" @@ -763,8 +727,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++) { @@ -789,8 +753,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); @@ -802,8 +766,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 @@ -811,7 +774,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 @@ -820,7 +783,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 @@ -829,7 +792,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 @@ -838,7 +801,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 @@ -847,20 +810,20 @@ light: name: "Hat Light" default_transition_length: 0s segments: - - { id: led_chain, from: 32, to: 36 } + - { id: all_lights, from: 32, to: 36 } effects: *led_effects 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 - 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 +836,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 +914,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 +923,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 +962,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;' @@ -1032,6 +972,10 @@ script: - id: play_song_1 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 @@ -1055,10 +999,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);' @@ -1068,6 +1009,10 @@ script: - id: play_song_2 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 @@ -1091,10 +1036,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);' @@ -1104,6 +1046,10 @@ script: - id: play_song_3 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 @@ -1127,10 +1073,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);' @@ -1140,6 +1083,10 @@ script: - id: play_song_4 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 @@ -1163,10 +1110,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);' @@ -1176,6 +1120,10 @@ script: - id: play_song_5 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 @@ -1199,10 +1147,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);' @@ -1212,6 +1157,10 @@ script: - id: play_song_6 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 @@ -1235,10 +1184,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);' @@ -1248,6 +1194,10 @@ script: - id: play_song_x 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 @@ -1271,10 +1221,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 +1229,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 +1265,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..6ff5688 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,15 @@ 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. - min_version: 2025.11.0 + # Hard floor is 2024.6.0; tracks the fleet + min_version: 2026.8.0 on_boot: - # Point the update entity at the selected channel manifest. + - priority: 500 + then: + - text_sensor.template.publish: + id: apollo_firmware_version + state: "${version}" + # Point update entity at selected manifest - priority: -100 then: - script.execute: apply_ota_source @@ -32,9 +35,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 +51,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 +62,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: @@ -74,6 +74,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 @@ -95,41 +96,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 +125,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 +149,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 +170,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 +228,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,22 +239,22 @@ 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 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 @@ -325,9 +297,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 +320,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. +# Wi-Fi diagnostics, disabled by default text_sensor: - platform: wifi_info ip_address: @@ -378,17 +347,22 @@ 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 - # 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"; - 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 @@ -441,7 +415,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..8b03ef7 100644 --- a/Integrations/ESPHome/H-3D.yaml +++ b/Integrations/ESPHome/H-3D.yaml @@ -11,13 +11,11 @@ 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 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 @@ -30,34 +28,29 @@ 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 - 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 +87,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: