Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions Integrations/ESPHome/CAST-1.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Apollo CAST-1 — unified firmware with WiFi + Ethernet in one image.
# Ethernet is preferred when plugged in; WiFi (and its provisioning hotspot)
# takes over automatically when it is not. See Core.yaml for all device config.
# Apollo CAST-1 unified firmware: WiFi and Ethernet in one image
# Device config lives in Core.yaml

esphome:
name: "apollo-cast-1"
Expand Down
118 changes: 32 additions & 86 deletions Integrations/ESPHome/Core.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
substitutions:
version: "26.8.28.1"
# Manifest bases: stable=Pages, beta=release assets
stable_manifest_base: "https://apolloautomation.github.io/CAST-1"
beta_manifest_base: "https://github.com/ApolloAutomation/CAST-1/releases/download/beta-fw"
stable_manifest_dir: "firmware"
Expand All @@ -27,13 +26,9 @@ esphome:
- lambda: "return id(runTest);"
then:
- lambda: "id(testScript).execute();"
# A device whose WiFi was released while on Ethernet boots with WiFi
# (and its hotspot) off: with no STA credentials the fallback AP would
# otherwise broadcast until Ethernet gets its DHCP lease. Runs after
# globals restore (800), before wifi setup (245). Safe mode skips
# on_boot entirely, so WiFi always starts there for recovery. ESP-NOW
# self-inits on channel 1 (legacy Ethernet-firmware default) when WiFi
# is off at setup.
# Keep WiFi off when Ethernet is expected, so the fallback hotspot can't
# broadcast while Ethernet waits for DHCP
# Must run after globals restore (800) and before wifi setup (245)
- priority: 600
then:
- if:
Expand All @@ -43,11 +38,9 @@ esphome:
- lambda: |-
id(wifi_1).set_enable_on_boot(false);
id(espnow_component).set_wifi_channel(1);
# No Ethernet HAT: the W5500 driver's helper task is created before chip
# verification and survives the failed install; left running it polls
# the floating SPI bus and eventually corrupts memory (Cache error
# panics every ~100s, observed on hardware). Park it. Runs right after
# ethernet's setup (250).
# Park the W5500 helper task when no HAT is present, so it stops polling a
# floating SPI bus and panicking the device every ~100s
# Must run after ethernet setup (250)
- priority: 200
then:
- if:
Expand All @@ -65,12 +58,10 @@ esphome:
- text_sensor.template.publish:
id: apollo_firmware_version
state: "${version}"
# Point update entity at selected manifest
- priority: -100
then:
- script.execute: apply_ota_source
# Ethernet was expected but never came up: bring WiFi back so the
# device can rejoin a network or open the provisioning hotspot.
# Ethernet never came up, so bring WiFi back for rejoin or provisioning
- priority: -100
then:
- if:
Expand All @@ -88,7 +79,6 @@ esphome:
then:
- lambda: "id(ethernet_expected) = false;"
- wifi.enable:
# Re-apply persisted Bluetooth Proxy state
- priority: -300
then:
- if:
Expand Down Expand Up @@ -118,8 +108,7 @@ update:
name: Firmware Update
source: ${stable_manifest_base}/${stable_manifest_dir}/manifest.json

# Both interfaces live in one firmware (ESPHome 2026.8+); Ethernet is
# preferred whenever connected and ESPHome arbitrates the default route.
# Prefer Ethernet whenever it is connected; ESPHome arbitrates the route
network:
id: network_1
priority:
Expand All @@ -137,12 +126,8 @@ ethernet:
interrupt_pin: GPIO13
reset_pin: GPIO21
on_connect:
# Ethernet is up. If WiFi is (or is about to be) connected to a network,
# keep it: the device runs dual-homed — Ethernet carries the routed
# traffic (network: priority:) while Home Assistant's connection to the
# WiFi address survives, so no rediscovery is ever needed. WiFi is turned
# off only when it has nothing to offer (hotspot active or nothing to
# join), which also guarantees the hotspot is never up while on Ethernet.
# Keep WiFi up alongside Ethernet so Home Assistant's connection to the
# WiFi address survives; release it only when WiFi has no network to offer
- if:
condition:
wifi.enabled:
Expand All @@ -154,11 +139,8 @@ ethernet:
# Fallback hotspot is broadcasting: WiFi has no network.
- script.execute: wifi_release_radio
else:
# Boot race: WiFi may still be associating; give it a moment.
# The wait also ends the instant the fallback AP starts (WiFi
# lost 60-90s before the cable arrived), so the hotspot is
# torn down within a loop pass instead of broadcasting for
# the rest of the window.
# WiFi may still be associating at boot, so wait it out
# Ends early if the fallback AP starts
- wait_until:
condition:
or:
Expand All @@ -171,8 +153,7 @@ ethernet:
then:
- lambda: "id(ethernet_expected) = false;"
else:
# Guard: if the cable vanished during the wait, leave
# WiFi alone — it is the only path left.
# Leave WiFi alone if the cable vanished during the wait
- if:
condition:
ethernet.connected:
Expand All @@ -184,28 +165,21 @@ ethernet:
- component.update: update_http_request
- component.update: network_connection
on_disconnect:
# Cable gone: make sure WiFi is up so the device rejoins a saved network
# or, with none configured, opens the provisioning hotspot.
# Cable gone, so bring WiFi up to rejoin or open the provisioning hotspot
- lambda: "id(ethernet_expected) = false;"
- wifi.enable:
- component.update: network_connection

wifi:
id: wifi_1
on_connect:
# Remember that working credentials exist: gates the recovery probe
# below so it never runs (and can never pop the hotspot) on a device
# that was never provisioned. Survives reboots; factory reset clears it.
# Must stay set: gates the recovery probe so it never pops the hotspot on
# a device that was never provisioned
- lambda: "id(wifi_provisioned) = true;"
- component.update: update_http_request
on_disconnect:
# WiFi lost its network while Ethernet carries the device: after up to
# 60s for reassociation, turn WiFi off rather than let the fallback
# hotspot appear (ap_timeout is 90s; deciding by 60s keeps it from ever
# starting). A reconnect-then-drop during the wait can release earlier
# than the full 60s — the 10-minute recovery probe restores dual-home in
# that case. Harmless when triggered by wifi.disable itself — the
# wifi.enabled check below skips the release then.
# Turn WiFi off after 60s without reassociation, which keeps the 90s
# fallback hotspot from ever starting while Ethernet carries the device
- if:
condition:
ethernet.connected:
Expand Down Expand Up @@ -254,13 +228,13 @@ esp32:
CONFIG_SPIRAM_RODATA: "y"
CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y"

# Allocate BT in PSRAM so internal RAM stays free for audio
CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST: "y"
CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY: "y"

CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC: "y"
CONFIG_MBEDTLS_SSL_PROTO_TLS1_3: "y"

# BT allocs in PSRAM; RAM free for audio
esp32_ble_tracker:
id: ble_tracker
scan_parameters:
Expand All @@ -271,11 +245,8 @@ bluetooth_proxy:

api:
id: api_1
# A CAST-1 can legitimately run without a Home Assistant API client —
# streaming from Music Assistant uses sendspin's own outbound connection.
# The default 15-minute no-client reboot would restart such a device mid-
# playback every cycle (observed in the field), so it is disabled; crash
# recovery remains covered by safe_mode.
# Disable the no-client reboot: sendspin streams from Music Assistant over
# its own connection, so a device with no API client is normal here
reboot_timeout: 0s
encryption:

Expand All @@ -299,16 +270,13 @@ globals:
- id: announcement_triggered
type: bool
initial_value: 'false'
# True when WiFi was released while Ethernet carried the device (WiFi had
# nothing to offer); the next boot then starts WiFi-silent. Cleared when
# the cable is lost, Ethernet fails to appear at boot, or the device goes
# dual-homed.
# True when WiFi was released while Ethernet carried the device, which makes
# the next boot start WiFi-silent
- id: ethernet_expected
type: bool
restore_value: yes
initial_value: 'false'
# True once WiFi has ever connected (credentials known-good). Gates the
# WiFi recovery probe. Cleared only by factory reset.
# True once WiFi has ever connected, which gates the WiFi recovery probe
- id: wifi_provisioned
type: bool
restore_value: yes
Expand Down Expand Up @@ -426,10 +394,7 @@ switch:
- esp32_ble_tracker.stop_scan:

text_sensor:
# Which path is carrying the device right now — for troubleshooting and
# HA notifications (e.g. alert when a wired device falls back to WiFi).
# Ethernet + WiFi = dual-home: traffic on the wire, WiFi held so Home
# Assistant never loses the device.
# Which path is carrying the device, for troubleshooting and HA notifications
- platform: template
name: "Network Connection"
id: network_connection
Expand Down Expand Up @@ -559,7 +524,7 @@ speaker:
- id: media_mixer_input
timeout: never

# Hot audio tasks: stacks stay in internal RAM
# These stacks must stay in internal RAM, so leave task_stack_in_psram unset
- platform: resampler
id: announcement_resampling_speaker
output_speaker: announcement_mixer_input
Expand All @@ -581,7 +546,6 @@ sendspin:
media_source:
- platform: sendspin
id: sendspin_source
# decode_memory: component default

- platform: audio_http
id: http_media_source
Expand Down Expand Up @@ -666,11 +630,8 @@ media_player:



# A released WiFi (router outage while dual-homed, or slow association at
# cable-connect) must not stay silent until the next cable pull: while
# Ethernet is up and the device has known-good credentials, retry WiFi
# periodically. The 60s bound stays under the 90s hotspot timer (which
# restarts at wifi.enable), so the probe can never broadcast the AP.
# Retry a released WiFi while Ethernet is up, so an outage doesn't leave the
# radio silent until the next cable pull
interval:
- id: wifi_recovery_interval
interval: 10min
Expand Down Expand Up @@ -708,30 +669,16 @@ script:
- script.execute: wifi_release_radio

- id: wifi_release_radio
# Turn WiFi off while Ethernet carries the device: ends AP+STA, shuts
# down the captive portal (wifi.disable leaves it answering DNS and
# /wifisave on the Ethernet IP), and bounces ESP-NOW so it re-inits
# standalone for the WizMote (the channel scan in wizmote.yaml takes
# over). ethernet_expected persists so the next boot starts WiFi-silent.
#
# If the fallback hotspot is LIVE, reboot instead: ESPHome cannot
# relaunch a once-started AP within the same boot (ap_setup_ latch), so
# merely disabling WiFi would leave the next cable-out provisioning
# attempt with a half-dead hotspot (SSID joins, but 192.168.4.1 serves
# nothing — observed on hardware). The reboot lands Ethernet-only in
# seconds with the latch cleared. It fires only when a live hotspot must
# be killed: a fresh device's first cable-connect, or an unprovisioned
# wired device whose cable was out long enough (>90s) for the hotspot to
# appear — in both cases the device had no network, so no playback is
# interrupted. A device on a regularly-failing switch repeats this per
# outage cycle; kept in preference to suppressing the cable-out hotspot,
# which is a documented provisioning path.
# Turn WiFi off while Ethernet carries the device, ending AP+STA and the
# captive portal, and bounce ESP-NOW so it re-inits standalone for the WizMote
then:
- lambda: "id(ethernet_expected) = true;"
- if:
condition:
wifi.ap_active:
then:
# Must reboot to clear a live hotspot: ESPHome can't relaunch a
# once-started AP in the same boot, leaving it serving nothing
- lambda: |-
ESP_LOGW("network", "Rebooting to fully release the active hotspot (one-time)");
- delay: 500ms
Expand All @@ -746,7 +693,6 @@ script:
id(espnow_component).enable();

- id: apply_ota_source
# Set OTA manifest from the channel selector
then:
- lambda: |-
const bool beta = id(firmware_channel).current_option() == "Beta";
Expand Down
6 changes: 2 additions & 4 deletions Integrations/ESPHome/wizmote.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -388,10 +388,8 @@ script:
format: "unmapped button %d"
args: ['button']

# When WiFi has been released (device on Ethernet with no WiFi network),
# ESP-NOW owns the radio: scan channels until the WizMote is heard. While
# WiFi is enabled the channel is pinned by the WiFi connection, so scanning
# is skipped (and espnow.set_channel would be refused anyway).
# Scan channels for the WizMote once ESP-NOW owns the radio
# Must skip while WiFi is enabled, which pins the channel
interval:
- id: wizmote_channel_scan
interval: 2s
Expand Down