From cfa2c83b517136e01d8e7ed2e6f9613f05e78647 Mon Sep 17 00:00:00 2001 From: Justin Bunton <130115204+Bunton33@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:24:45 -0400 Subject: [PATCH 1/8] Unify WiFi + Ethernet into one firmware with automatic switchover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One firmware now serves both connection types using ESPHome 2026.8's network: priority: multi-interface support — the community no longer has to pick a variant or reflash to switch (requested in ApolloAutomation/R_PRO-1#69). Behavior: - network: priority: [ethernet, wifi]; ESPHome arbitrates the default route when both are up. - ethernet.on_connect turns WiFi (and the fallback hotspot) off, shuts down the captive portal, and bounces ESP-NOW so the WizMote keeps working standalone on the radio (channel scan now guarded to run only while WiFi is disabled). - ethernet.on_disconnect re-enables WiFi (saved network or provisioning hotspot). - A persisted ethernet_expected global makes devices last seen on Ethernet boot with WiFi off entirely, so the open hotspot never broadcasts during the boot-to-DHCP window; a 20s boot fallback enables WiFi if the cable is gone. Safe mode skips on_boot, so WiFi always starts there for OTA recovery. Migration: - New canonical Integrations/ESPHome/CAST-1.yaml (ApolloAutomation.CAST-1, publishes to firmware/); the installer page offers this single build. - CAST-1_ETH.yaml / CAST-1_W.yaml are now thin shims (name, project, dashboard_import, manifest URLs) over the shared Core.yaml, so the deployed fleet OTAs into the unified firmware from its original manifest URLs with no reflash and no rename. - "Firmware Type" select removed; apply_ota_source only switches Stable/Beta via per-variant substitutions. "IP Address" sensor split into "Ethernet IP Address" + "WiFi IP Address" diagnostics. - improv_serial + esp32_improv + captive_portal now in every variant (Made for ESPHome requirement for WiFi-capable configs); explicit ids on all new components; min_version 2026.8.0. - Workflows build/publish all three yamls (stable Pages dirs firmware/, firmware-e/, firmware-w/; beta assets manifest.json, manifest-e.json, manifest-w.json). Note: the canonical Beta manifest 404s until the beta branch runs the new matrix once — merge to beta (or dispatch build-beta.yml) promptly after this lands. All three configs validate and the canonical firmware compiles clean on ESPHome 2026.8.1 (RAM 48.9%, Flash 49.2%). Co-Authored-By: Claude Fable 5 --- .github/workflows/build-beta.yml | 25 ++-- .github/workflows/build.yml | 3 +- .github/workflows/ci.yml | 1 + .github/workflows/weekly.yml | 1 + Integrations/ESPHome/CAST-1.yaml | 16 +++ Integrations/ESPHome/CAST-1_ETH.yaml | 108 ++-------------- Integrations/ESPHome/CAST-1_W.yaml | 92 ++------------ Integrations/ESPHome/Core.yaml | 176 ++++++++++++++++++++++++--- Integrations/ESPHome/wizmote.yaml | 28 +++++ README.md | 15 +++ static/index.html | 13 +- 11 files changed, 266 insertions(+), 212 deletions(-) create mode 100644 Integrations/ESPHome/CAST-1.yaml diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 4dc1634..c50b628 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -4,6 +4,12 @@ name: Build and Publish Beta # rolling "beta" pre-release. The on-device "Firmware Channel" select points OTA # updates at these assets. Stable firmware is built/published separately by # build.yml (push to main -> GitHub Pages). +# +# NOTE: the canonical (unified) firmware's Beta manifest is the manifest.json +# asset, which only exists once this workflow has run with the three-entry +# matrix below. After the unified-firmware change lands on main, merge it to +# beta (or run this workflow manually) promptly — until then, canonical-build +# devices switched to the Beta channel get a 404 on their update manifest. on: push: @@ -37,6 +43,7 @@ jobs: strategy: matrix: include: + - { yaml: Integrations/ESPHome/CAST-1.yaml, name: firmware } - { yaml: Integrations/ESPHome/CAST-1_W.yaml, name: firmware-w } - { yaml: Integrations/ESPHome/CAST-1_ETH.yaml, name: firmware-e } uses: esphome/workflows/.github/workflows/build.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 @@ -73,22 +80,24 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | BASE="https://github.com/${{ github.repository }}/releases/download/beta-fw" - for v in w e; do - man=$(find "fw/firmware-$v" -name manifest.json | head -1) + for pair in "firmware:manifest.json" "firmware-w:manifest-w.json" "firmware-e:manifest-e.json"; do + dir="${pair%%:*}" + asset="${pair##*:}" + man=$(find "fw/$dir" -name manifest.json | head -1) if [ -z "$man" ]; then - echo "::error::manifest.json not found for firmware-$v" + echo "::error::manifest.json not found for $dir" exit 1 fi - echo "Rewriting $man" + echo "Rewriting $man -> $asset" # Make ota.path and parts[].path absolute release-asset URLs so the # device never has to resolve a relative path against a redirected URL. jq --arg base "$BASE" ' .builds[0].ota.path = ($base + "/" + (.builds[0].ota.path | sub(".*/"; ""))) | .builds[0].parts |= map(.path = ($base + "/" + (.path | sub(".*/"; "")))) - ' "$man" > "manifest-$v.json" - cat "manifest-$v.json" - gh release upload beta-fw "manifest-$v.json" -R "${{ github.repository }}" --clobber - find "fw/firmware-$v" -name '*.bin' -print -exec \ + ' "$man" > "$asset" + cat "$asset" + gh release upload beta-fw "$asset" -R "${{ github.repository }}" --clobber + find "fw/$dir" -name '*.bin' -print -exec \ gh release upload beta-fw {} -R "${{ github.repository }}" --clobber \; done echo "Beta assets published." diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5fa440..acfda93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,9 +25,10 @@ jobs: with: device-name: cast-1 yaml-files: | + Integrations/ESPHome/CAST-1.yaml Integrations/ESPHome/CAST-1_ETH.yaml Integrations/ESPHome/CAST-1_W.yaml - firmware-names: "1_ETH:firmware-e,1_W:firmware-w" + firmware-names: "1:firmware,1_ETH:firmware-e,1_W:firmware-w" core-yaml-path: Integrations/ESPHome/Core.yaml esphome-version: stable # Bypass check if manually triggered with bypass option diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92a99e8..6c30623 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ jobs: uses: ApolloAutomation/Workflows/.github/workflows/esphome-ci.yml@main with: yaml-files: | + Integrations/ESPHome/CAST-1.yaml Integrations/ESPHome/CAST-1_ETH.yaml Integrations/ESPHome/CAST-1_W.yaml # dev excluded until the TemplateSelect .state incompatibility is fixed diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index 63891fc..8d45bd6 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -13,6 +13,7 @@ jobs: uses: ApolloAutomation/Workflows/.github/workflows/esphome-ci.yml@main with: yaml-files: | + Integrations/ESPHome/CAST-1.yaml Integrations/ESPHome/CAST-1_ETH.yaml Integrations/ESPHome/CAST-1_W.yaml # dev excluded until the TemplateSelect .state incompatibility is fixed diff --git a/Integrations/ESPHome/CAST-1.yaml b/Integrations/ESPHome/CAST-1.yaml new file mode 100644 index 0000000..96d0880 --- /dev/null +++ b/Integrations/ESPHome/CAST-1.yaml @@ -0,0 +1,16 @@ +# 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. + +esphome: + name: "apollo-cast-1" + project: + name: "ApolloAutomation.CAST-1" + version: ${version} + +dashboard_import: + package_import_url: github://ApolloAutomation/CAST-1/Integrations/ESPHome/CAST-1.yaml + import_full_config: false + +packages: + core: !include Core.yaml diff --git a/Integrations/ESPHome/CAST-1_ETH.yaml b/Integrations/ESPHome/CAST-1_ETH.yaml index 0ffabc5..2fb333a 100644 --- a/Integrations/ESPHome/CAST-1_ETH.yaml +++ b/Integrations/ESPHome/CAST-1_ETH.yaml @@ -1,112 +1,22 @@ +# Legacy Ethernet-variant shim. The firmware is identical to CAST-1.yaml +# (WiFi + Ethernet in one image); only the device/project name and the OTA +# manifest URLs differ, so devices originally flashed with the Ethernet +# firmware keep updating from their original manifest without a reflash. +# New installs should use CAST-1.yaml. + +substitutions: + stable_manifest_dir: "firmware-e" + beta_manifest_file: "manifest-e.json" esphome: name: "apollo-cast-1-eth" - friendly_name: Apollo CAST-1 - comment: Apollo CAST-1 - name_add_mac_suffix: true - platformio_options: - board_build.flash_mode: dio - project: name: "ApolloAutomation.CAST-1-ETH" version: ${version} - min_version: 2026.6.0 - - on_boot: - - priority: 800 - then: - - if: - condition: - - lambda: "return id(runTest);" - then: - - lambda: "id(testScript).execute();" - - priority: 500 - then: - - text_sensor.template.publish: - id: apollo_firmware_version - state: "${version}" - dashboard_import: package_import_url: github://ApolloAutomation/CAST-1/Integrations/ESPHome/CAST-1_ETH.yaml import_full_config: false -ota: - - platform: esphome - id: ota_esphome - - platform: http_request - id: ota_managed - -safe_mode: - id: safe_mode_1 - -update: - - platform: http_request - id: update_http_request - name: Firmware Update - source: https://apolloautomation.github.io/CAST-1/firmware-e/manifest.json - -logger: - id: logger_1 - -ethernet: - id: ethernet_1 - type: W5500 - clock_speed: 40MHz - clk_pin: GPIO7 - mosi_pin: GPIO12 - miso_pin: GPIO11 - cs_pin: GPIO6 - interrupt_pin: GPIO13 - reset_pin: GPIO21 - -select: - - platform: template - id: firmware_selector - name: Firmware Type - icon: "mdi:test-tube" - entity_category: config - optimistic: true - restore_value: true - options: - - "WiFi" - - "Ethernet" - initial_option: "Ethernet" - on_value: - then: - - script.execute: apply_ota_source - -text_sensor: - - platform: ethernet_info - ip_address: - name: "IP Address" - id: eth_ip - entity_category: "diagnostic" - -# No WiFi: scan channels until WizMote heard -espnow: - channel: 1 - -interval: - - id: wizmote_channel_scan - interval: 2s - then: - - if: - condition: - lambda: |- - std::string mac = id(wizmote_mac_address).state; - bool want = id(wizmote_discovery_mode).state || - (mac != "00:00:00:00:00:00" && mac != ""); - return want && (millis() - id(wizmote_last_packet_ms) > 30000); - then: - - lambda: |- - id(wizmote_scan_channel)++; - if (id(wizmote_scan_channel) > 13) id(wizmote_scan_channel) = 1; - - espnow.set_channel: - channel: !lambda 'return id(wizmote_scan_channel);' - - logger.log: - format: "WizMote channel scan: trying channel %d" - args: ['id(wizmote_scan_channel)'] - packages: core: !include Core.yaml diff --git a/Integrations/ESPHome/CAST-1_W.yaml b/Integrations/ESPHome/CAST-1_W.yaml index 511bebb..9313ae0 100644 --- a/Integrations/ESPHome/CAST-1_W.yaml +++ b/Integrations/ESPHome/CAST-1_W.yaml @@ -1,96 +1,22 @@ +# Legacy WiFi-variant shim. The firmware is identical to CAST-1.yaml +# (WiFi + Ethernet in one image); only the device/project name and the OTA +# manifest URLs differ, so devices originally flashed with the WiFi firmware +# keep updating from their original manifest without a reflash. +# New installs should use CAST-1.yaml. + +substitutions: + stable_manifest_dir: "firmware-w" + beta_manifest_file: "manifest-w.json" esphome: name: "apollo-cast-1-w" - friendly_name: Apollo CAST-1 - comment: Apollo CAST-1 - name_add_mac_suffix: true - platformio_options: - board_build.flash_mode: dio - project: name: "ApolloAutomation.CAST-1-W" version: ${version} - min_version: 2026.6.0 - - on_boot: - - priority: 800 - then: - - if: - condition: - - lambda: "return id(runTest);" - then: - - lambda: "id(testScript).execute();" - - priority: 500 - then: - - text_sensor.template.publish: - id: apollo_firmware_version - state: "${version}" - dashboard_import: package_import_url: github://ApolloAutomation/CAST-1/Integrations/ESPHome/CAST-1_W.yaml import_full_config: false -ota: - - platform: esphome - id: ota_esphome - - platform: http_request - id: ota_managed - -safe_mode: - id: safe_mode_1 - -improv_serial: - id: improv_serial_1 - -esp32_improv: - id: esp32_improv_1 - authorizer: none - -update: - - platform: http_request - id: update_http_request - name: Firmware Update - source: https://apolloautomation.github.io/CAST-1/firmware-w/manifest.json - -logger: - id: logger_1 - -wifi: - on_connect: - - component.update: update_http_request - id: wifi_1 - # Workaround: roam scans drop active audio - post_connect_roaming: false - ap: - ssid: "Apollo CAST 1 Hotspot" - power_save_mode: none - -captive_portal: - id: captive_portal_1 - -select: - - platform: template - id: firmware_selector - name: Firmware Type - icon: "mdi:test-tube" - entity_category: config - optimistic: true - restore_value: true - options: - - "WiFi" - - "Ethernet" - initial_option: "WiFi" - on_value: - then: - - script.execute: apply_ota_source - -text_sensor: - - platform: wifi_info - ip_address: - name: "IP Address" - id: wifi_ip - entity_category: "diagnostic" - packages: core: !include Core.yaml diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index fa4ae74..a4a16c9 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,20 +1,77 @@ substitutions: - version: "26.8.25.1" + version: "26.8.26.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" + # Manifest targets for this build; legacy variant files override these so + # already-deployed devices keep updating from their original URLs. + stable_manifest_dir: "firmware" + beta_manifest_file: "manifest.json" packages: wizmote: !include wizmote.yaml esphome: - # List form so variant on_boot entries merge + friendly_name: Apollo CAST-1 + comment: Apollo CAST-1 + name_add_mac_suffix: true + # network: priority: (WiFi + Ethernet in one firmware) needs 2026.8 + min_version: 2026.8.0 + platformio_options: + board_build.flash_mode: dio + # List form so package on_boot entries (wizmote.yaml) merge on_boot: + - priority: 800 + then: + - if: + condition: + - lambda: "return id(runTest);" + then: + - lambda: "id(testScript).execute();" + # A device last seen 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. + - priority: 600 + then: + - if: + condition: + lambda: "return id(ethernet_expected);" + then: + - lambda: |- + id(wifi_1).set_enable_on_boot(false); + id(espnow_component).set_wifi_channel(1); + - 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 + # Ethernet was expected but never came up: bring WiFi back so the + # device can rejoin a network or open the provisioning hotspot. + - priority: -100 + then: + - if: + condition: + lambda: "return id(ethernet_expected);" + then: + - wait_until: + condition: + ethernet.connected: + timeout: 20s + - if: + condition: + not: + ethernet.connected: + then: + - lambda: "id(ethernet_expected) = false;" + - wifi.enable: # Re-apply persisted Bluetooth Proxy state - priority: -300 then: @@ -27,6 +84,85 @@ esphome: else: - esp32_ble_tracker.stop_scan: +ota: + - platform: esphome + id: ota_esphome + - platform: http_request + id: ota_managed + +safe_mode: + id: safe_mode_1 + +logger: + id: logger_1 + +update: + - platform: http_request + id: update_http_request + 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. +network: + id: network_1 + priority: + - ethernet + - wifi + +ethernet: + id: ethernet_1 + type: W5500 + clock_speed: 40MHz + clk_pin: GPIO7 + mosi_pin: GPIO12 + miso_pin: GPIO11 + cs_pin: GPIO6 + interrupt_pin: GPIO13 + reset_pin: GPIO21 + on_connect: + # Ethernet is up: turn WiFi (and the fallback hotspot) off. ESP-NOW rides + # the WiFi driver, so bounce it to re-init standalone; it re-adds the + # paired WizMote peer and the channel scan in wizmote.yaml takes over. + # ethernet_expected persists so the next boot starts with WiFi off. + - lambda: "id(ethernet_expected) = true;" + - wifi.disable: + - lambda: |- + // wifi.disable() tears down the AP but not the captive portal; + // left running it would keep answering DNS and /wifisave on the + // Ethernet IP until the next reboot. + if (id(captive_portal_1).is_active()) { + id(captive_portal_1).end(); + } + id(espnow_component).disable(); + id(espnow_component).enable(); + - component.update: update_http_request + on_disconnect: + # Cable gone: bring WiFi back so the device rejoins a saved network or, + # with none configured, opens the provisioning hotspot. + - lambda: "id(ethernet_expected) = false;" + - wifi.enable: + +wifi: + id: wifi_1 + on_connect: + - component.update: update_http_request + # Workaround: roam scans drop active audio + post_connect_roaming: false + ap: + ssid: "Apollo CAST 1 Hotspot" + power_save_mode: none + +captive_portal: + id: captive_portal_1 + +improv_serial: + id: improv_serial_1 + +esp32_improv: + id: esp32_improv_1 + authorizer: none + esp32: variant: ESP32S3 flash_size: 8MB @@ -81,6 +217,12 @@ globals: - id: announcement_triggered type: bool initial_value: 'false' + # True once Ethernet has connected; cleared when the cable is lost or + # Ethernet fails to appear at boot. Gates the WiFi-off-at-boot path. + - id: ethernet_expected + type: bool + restore_value: yes + initial_value: 'false' psram: id: psram_1 @@ -118,7 +260,7 @@ button: icon: mdi:cloud-download entity_category: config on_press: - - logger.log: "Applying firmware update for the selected type and channel" + - logger.log: "Applying firmware update for the selected channel" - delay: 3s - script.execute: apply_ota_source - script.wait: apply_ota_source @@ -194,6 +336,18 @@ switch: - esp32_ble_tracker.stop_scan: text_sensor: + - platform: ethernet_info + ip_address: + name: "Ethernet IP Address" + id: eth_ip + entity_category: "diagnostic" + + - platform: wifi_info + ip_address: + name: "WiFi IP Address" + id: wifi_ip + entity_category: "diagnostic" + - platform: sendspin id: sendspin_track_title type: title @@ -404,21 +558,13 @@ media_player: script: - id: apply_ota_source - # Set OTA manifest from type + channel selectors + # Set OTA manifest from the channel selector then: - lambda: |- - const bool eth = id(firmware_selector).current_option() == "Ethernet"; const bool beta = id(firmware_channel).current_option() == "Beta"; - std::string url; - if (beta) { - url = eth - ? "${beta_manifest_base}/manifest-e.json" - : "${beta_manifest_base}/manifest-w.json"; - } else { - url = eth - ? "${stable_manifest_base}/firmware-e/manifest.json" - : "${stable_manifest_base}/firmware-w/manifest.json"; - } + std::string url = beta + ? "${beta_manifest_base}/${beta_manifest_file}" + : "${stable_manifest_base}/${stable_manifest_dir}/manifest.json"; ESP_LOGI("firmware", "OTA manifest set to: %s", url.c_str()); id(update_http_request).set_source_url(url); - component.update: update_http_request diff --git a/Integrations/ESPHome/wizmote.yaml b/Integrations/ESPHome/wizmote.yaml index 287df37..51cfd3c 100644 --- a/Integrations/ESPHome/wizmote.yaml +++ b/Integrations/ESPHome/wizmote.yaml @@ -388,6 +388,34 @@ script: format: "unmapped button %d" args: ['button'] +# While Ethernet carries the network, WiFi is disabled and ESP-NOW owns the +# radio: scan channels until the WizMote is heard. With WiFi enabled the +# channel is pinned by the WiFi connection, so scanning is skipped (and +# espnow.set_channel would be refused anyway). +interval: + - id: wizmote_channel_scan + interval: 2s + then: + - if: + condition: + and: + - not: + wifi.enabled: + - lambda: |- + std::string mac = id(wizmote_mac_address).state; + bool want = id(wizmote_discovery_mode).state || + (mac != "00:00:00:00:00:00" && mac != ""); + return want && (millis() - id(wizmote_last_packet_ms) > 30000); + then: + - lambda: |- + id(wizmote_scan_channel)++; + if (id(wizmote_scan_channel) > 13) id(wizmote_scan_channel) = 1; + - espnow.set_channel: + channel: !lambda 'return id(wizmote_scan_channel);' + - logger.log: + format: "WizMote channel scan: trying channel %d" + args: ['id(wizmote_scan_channel)'] + espnow: id: espnow_component auto_add_peer: false diff --git a/README.md b/README.md index 9077424..a7c4aa3 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,21 @@ Easily make your speakers smart and cast to multiple devices using Music Assistant and Sendspin! +## Connectivity + +One firmware covers both WiFi and Ethernet — there is no need to pick a +variant or reflash to switch: + +- **Ethernet plugged in:** the device uses Ethernet and turns WiFi (including + the setup hotspot) off. +- **No Ethernet:** WiFi turns on automatically. The device rejoins its saved + network, or opens the "Apollo CAST 1 Hotspot" for setup if none is + configured (Improv via Bluetooth or USB works too). + +Devices flashed with the older WiFi-only or Ethernet-only firmware receive +this unified behavior automatically through their normal firmware update — no +reflash needed. + ## WizMote The CAST-1 gives you full Home Assistant control over a paired Wiz WizMote. Each of the nine buttons (On, Off, Night, Brightness Up/Down, and 1–4) is a configurable select on the device — assign it Play, Pause, Play / Pause, Next/Previous Track, Volume Up/Down, Toggle Light, or Send HA Event. The defaults give you media controls out of the box. diff --git a/static/index.html b/static/index.html index a6a7aae..fecb974 100644 --- a/static/index.html +++ b/static/index.html @@ -84,14 +84,15 @@

Apollo CAST-1 Installer

-

CAST-1 Wifi Firmware

- -
-
-

CAST-1 Ethernet Firmware

- +

CAST-1 Firmware (WiFi & Ethernet)

+
+

+ One firmware for both connection types: Ethernet is used whenever a + cable is plugged in, and WiFi (with its setup hotspot) takes over + automatically when it is not. +