-
Notifications
You must be signed in to change notification settings - Fork 3
Unified firmware: runtime Bluetooth Proxy switch + Stable/Beta channel OTA #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: beta
Are you sure you want to change the base?
Changes from all commits
cc47aaa
9bdf143
a1138fa
f44aab5
c58cca3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| name: Build and Publish Beta | ||
|
|
||
| # Builds BTN-1 firmware from the beta branch and publishes it as assets on a | ||
| # rolling "beta-fw" 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). | ||
|
|
||
| on: | ||
| push: | ||
| branches: [beta] | ||
| paths: | ||
| - 'Integrations/ESPHome/**' | ||
| workflow_dispatch: | ||
|
|
||
| # Least privilege: read-only by default; only publish-beta is elevated to write. | ||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| version: | ||
| name: Read version | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| v: ${{ steps.read.outputs.v }} | ||
| steps: | ||
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | ||
| with: | ||
| persist-credentials: false | ||
| - id: read | ||
| run: | | ||
| v=$(awk '/substitutions:/ {f=1} f && /version:/ {print $2; exit}' \ | ||
| Integrations/ESPHome/Core.yaml | tr -d '"') | ||
| echo "v=$v" >> "$GITHUB_OUTPUT" | ||
| echo "Beta version: $v" | ||
|
|
||
| build: | ||
| name: Build firmware | ||
| needs: version | ||
| # Beta serves OTA updates only, so it builds the end-user image | ||
| # (BTN-1_Minimal.yaml), not the first-flash improv image. | ||
| uses: esphome/workflows/.github/workflows/build.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1 | ||
| with: | ||
| files: Integrations/ESPHome/beta-channel/BTN-1_Minimal.yaml | ||
| esphome-version: stable | ||
| combined-name: firmware-beta | ||
| release-version: ${{ needs.version.outputs.v }} | ||
|
|
||
| publish-beta: | ||
| name: Publish beta release assets | ||
| needs: [version, build] | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write | ||
| steps: | ||
| - name: Download firmware artifacts | ||
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | ||
| with: | ||
| path: fw | ||
| pattern: firmware* | ||
|
|
||
| - name: Ensure rolling 'beta-fw' pre-release exists | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| gh release view beta-fw -R "${{ github.repository }}" >/dev/null 2>&1 \ | ||
| || gh release create beta-fw -R "${{ github.repository }}" \ | ||
| --prerelease --title "Beta (rolling)" \ | ||
| --notes "Latest BTN-1 beta firmware. Auto-updated on every push to the beta branch." | ||
|
|
||
| - name: Rewrite manifest to absolute URLs and upload assets | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| BASE="https://github.com/${{ github.repository }}/releases/download/beta-fw" | ||
| man=$(find fw/firmware-beta -name manifest.json | head -1) | ||
| if [ -z "$man" ]; then | ||
| echo "::error::manifest.json not found" | ||
| exit 1 | ||
| fi | ||
| echo "Rewriting $man" | ||
| # Make ota.path and parts[].path absolute release-asset URLs so the | ||
| # device never has to resolve a relative path against a redirect. | ||
| jq --arg base "$BASE" ' | ||
| .builds[0].ota.path = ($base + "/" + (.builds[0].ota.path | sub(".*/"; ""))) | ||
| | .builds[0].parts |= map(.path = ($base + "/" + (.path | sub(".*/"; "")))) | ||
| ' "$man" > manifest.json | ||
| cat manifest.json | ||
| gh release upload beta-fw manifest.json -R "${{ github.repository }}" --clobber | ||
| find fw/firmware-beta -name '*.bin' -print -exec \ | ||
| gh release upload beta-fw {} -R "${{ github.repository }}" --clobber \; | ||
| echo "Beta assets published." | ||
|
|
||
| - name: Point beta-fw tag at the built commit | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| # gh release create tags default-branch HEAD, and uploads never move | ||
| # the tag, so without this the release's source commit drifts away | ||
| # from the assets actually published. | ||
| gh api -X PATCH "repos/${{ github.repository }}/git/refs/tags/beta-fw" \ | ||
| -f sha="${{ github.sha }}" -F force=true | ||
| echo "beta-fw -> ${{ github.sha }}" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,3 +3,4 @@ | |
| # You can modify this file to suit your needs. | ||
| /.esphome/ | ||
| /secrets.yaml | ||
| beta-channel/.esphome/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,18 @@ substitutions: | |
| version: "26.8.27.1" | ||
|
|
||
| device_description: ${name} made by Apollo Automation - version ${version}. | ||
| # Default update channel on first boot (no stored user choice yet, i.e. a | ||
| # fresh flash). The beta-channel builds override this to "Beta" (see | ||
| # Integrations/ESPHome/beta-channel/) so firmware obtained from the beta | ||
| # channel keeps tracking it instead of offering a stable "downgrade". | ||
| firmware_channel_default: "Stable" | ||
| # Manifest URL bases. Stable = GitHub Pages (main branch builds). | ||
| # Beta = rolling "beta-fw" pre-release assets (beta branch builds). | ||
| stable_manifest_base: "https://apolloautomation.github.io/BTN-1" | ||
| beta_manifest_base: "https://github.com/ApolloAutomation/BTN-1/releases/download/beta-fw" | ||
| # OTA manifest URLs picked by apply_ota_source. | ||
| ota_stable_manifest: "${stable_manifest_base}/firmware/manifest.json" | ||
| ota_beta_manifest: "${beta_manifest_base}/manifest.json" | ||
|
|
||
|
|
||
| esphome: | ||
|
|
@@ -35,6 +47,24 @@ esphome: | |
| ESP_LOGW("Apollo", "Preventing Deep Sleep Due To OTA On Boot"); | ||
| id(deep_sleep_1).prevent_deep_sleep(); | ||
|
|
||
| # Point the update entity at the selected channel's manifest. | ||
| - priority: -100 | ||
| then: | ||
| - script.execute: apply_ota_source | ||
|
|
||
| # Re-apply the Bluetooth Proxy switch after all components set up, so BLE | ||
| # scanning matches the persisted switch (proxy stays off by default). | ||
| - priority: -300 | ||
| then: | ||
| - if: | ||
| condition: | ||
| switch.is_on: bluetooth_proxy_switch | ||
| then: | ||
| - esp32_ble_tracker.start_scan: | ||
| continuous: true | ||
| else: | ||
| - esp32_ble_tracker.stop_scan: | ||
|
|
||
| - priority: -900 | ||
| then: | ||
| - lambda: |- | ||
|
|
@@ -86,6 +116,7 @@ esphome: | |
| - switch.turn_off: power_latch | ||
|
|
||
| api: | ||
| encryption: | ||
| reboot_timeout: 0s | ||
| actions: | ||
| - action: play_buzzer | ||
|
|
@@ -149,6 +180,13 @@ esp32: | |
| framework: | ||
| type: esp-idf | ||
|
|
||
| esp32_ble_tracker: | ||
| id: ble_tracker | ||
| scan_parameters: | ||
| continuous: true | ||
|
|
||
| bluetooth_proxy: | ||
|
|
||
| globals: | ||
| - id: cycleCounter | ||
| type: int | ||
|
|
@@ -175,6 +213,17 @@ web_server: | |
| port: 80 | ||
| version: 3 | ||
|
|
||
| http_request: | ||
| 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". | ||
| 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. | ||
| buffer_size_tx: 2048 | ||
|
|
||
| i2c: | ||
| id: i2c_bus | ||
| sda: GPIO1 | ||
|
|
@@ -292,6 +341,34 @@ button: | |
| icon: mdi:power-cycle | ||
| name: "ESP Reboot" | ||
|
|
||
| - platform: template | ||
| name: "Firmware Update" | ||
| id: update_firmware | ||
| icon: mdi:cloud-download | ||
| entity_category: "config" | ||
| on_press: | ||
| - logger.log: "Applying firmware update for the selected channel" | ||
| # OTA download needs the device awake for its whole duration. | ||
| - lambda: |- | ||
| id(deep_sleep_1).prevent_deep_sleep(); | ||
| - 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). | ||
| - delay: 5s | ||
| - lambda: id(update_http_request).perform(true); | ||
| # Only reached if the update did not start (e.g. manifest unreachable). | ||
| # Re-arm deep sleep unless something else is holding the device awake. | ||
| - if: | ||
| condition: | ||
| and: | ||
| - switch.is_off: prevent_sleep | ||
| - binary_sensor.is_off: ota_mode | ||
| then: | ||
| - lambda: |- | ||
| id(deep_sleep_1).allow_deep_sleep(); | ||
|
|
||
|
Comment on lines
+344
to
+371
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== File outline ==\n'
wc -l Integrations/ESPHome/Core.yaml
sed -n '280,390p' Integrations/ESPHome/Core.yaml
printf '\n== Symbol search ==\n'
rg -n "apply_ota_source|update_http_request|ota_mode|prevent_sleep|deep_sleep_1|wait_until|perform\\(true\\)|ota.path|latest_version|manifest" Integrations/ESPHome/Core.yaml
printf '\n== Nearby referenced files ==\n'
git ls-files | rg '(^|/)(Core\.yaml|.*\.yaml|.*\.yml)$'Repository: ApolloAutomation/BTN-1 Length of output: 6141 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== apply_ota_source and update_http_request definition ==\n'
sed -n '730,790p' Integrations/ESPHome/Core.yaml
printf '\n== Search for update_http_request ==\n'
rg -n "id:\s*update_http_request|platform:\s*http_request|platform:\s*update|component.update:\s*update_http_request|set_source_url|latest_version|update_info" Integrations/ESPHome/Core.yaml Integrations/ESPHome/*.yaml
printf '\n== ESPHome docs / source clues in repo ==\n'
rg -n "component.update|http_request.*update|update entity|manifest" README.md Integrations/ESPHome -g '!**/*.png'Repository: ApolloAutomation/BTN-1 Length of output: 5113 🌐 Web query:
💡 Result: The ESPHome http_request update platform allows devices to automatically check for and install firmware updates by fetching a JSON manifest file [1][2]. Asynchronous Execution The manifest fetch and update checking process is executed in a dedicated background task (specifically on ESP32-based devices) [3][4]. This asynchronous design ensures that the main ESPHome loop is not blocked during HTTP operations, which prevents device watchdog timeouts and allows the system to yield appropriately while waiting for network responses [4]. Manifest Fetch and Semantics The component uses the source_url configuration variable to locate the YAML/JSON manifest file [2][5]. The semantics of the manifest processing are as follows: - The component periodically checks the source_url for updates, with an configurable update_interval (default is 6 hours) [2]. - Network connectivity checks are performed before attempting the fetch; recent improvements ensure that if the network is not connected, the update check is skipped silently to avoid unnecessary error logs during startup [6]. - Once fetched, the manifest (expected to be in ESP-Web-Tools format) is parsed to extract firmware metadata [2]. - Paths within the manifest (such as the firmware binary location) are resolved relative to the source_url or as absolute URLs based on specific prefixes: - URLs starting with http/https are treated as absolute [2]. - Paths starting with a forward slash (/) are appended to the manifest's source hostname [2]. - Other paths are appended to the source_url path after stripping the manifest filename [2]. Internal Handling The HttpRequestUpdate class handles this workflow by managing an update_task [7]. Upon triggering a check, it retrieves the manifest, validates it, and updates internal state (such as update_info_) by deferring the final data write back to the main loop to ensure thread safety [3][8]. The actual installation process is coordinated with the OTA Update via HTTP Request component [2][3]. Citations:
🌐 Web query:
💡 Result: In ESPHome, the interaction between Citations:
Replace the fixed 5s sleep with a real refresh-complete check. 🤖 Prompt for AI Agents |
||
| # Button entities for physical buttons | ||
| binary_sensor: | ||
| - platform: status | ||
|
|
@@ -618,6 +695,21 @@ switch: | |
| setup_priority: 2000 | ||
| internal: true | ||
|
|
||
| # Note: on this deep-sleep device the proxy only forwards while the device | ||
| # is awake - pair it with "Prevent Sleep" to use it continuously. | ||
| - platform: template | ||
| name: "Bluetooth Proxy" | ||
| id: bluetooth_proxy_switch | ||
| icon: mdi:bluetooth | ||
| entity_category: "config" | ||
| restore_mode: RESTORE_DEFAULT_OFF | ||
| optimistic: true | ||
| on_turn_on: | ||
| - esp32_ble_tracker.start_scan: | ||
| continuous: true | ||
| on_turn_off: | ||
| - esp32_ble_tracker.stop_scan: | ||
|
|
||
| light: | ||
| - &common_light | ||
| platform: partition | ||
|
|
@@ -682,7 +774,33 @@ light: | |
| max_brightness: 100% | ||
|
|
||
|
|
||
| select: | ||
| - platform: template | ||
| name: "Firmware Channel" | ||
| id: firmware_channel | ||
| icon: mdi:source-branch | ||
| entity_category: "config" | ||
| optimistic: true | ||
| restore_value: true | ||
| options: | ||
| - "Stable" | ||
| - "Beta" | ||
| initial_option: "${firmware_channel_default}" | ||
| on_value: | ||
| then: | ||
| - script.execute: apply_ota_source | ||
|
|
||
| script: | ||
| - id: apply_ota_source | ||
| # Sets the OTA manifest URL from the Firmware Channel select (Stable/Beta). | ||
| 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()); | ||
| id(update_http_request).set_source_url(url); | ||
| - component.update: update_http_request | ||
|
|
||
| - id: statusCheck | ||
| then: | ||
| - if: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # Beta-channel build of BTN-1_Minimal.yaml: the identical image except the Firmware | ||
| # Channel select defaults to "Beta" on first boot, so firmware obtained from | ||
| # the beta channel keeps tracking it. Built by build-beta.yml only; the | ||
| # stable (GitHub Pages) builds use BTN-1_Minimal.yaml directly. | ||
| substitutions: | ||
| firmware_channel_default: "Beta" | ||
|
|
||
| packages: | ||
| base: !include ../BTN-1_Minimal.yaml |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Missing concurrency control on the rolling
beta-fwrelease publish.publish-betauploadsmanifest.jsonand.binassets to the same rollingbeta-fwrelease with--clobber, and live devices on the Beta channel actively pull from these exact URLs. If two workflow runs overlap (e.g. rapid successive pushes tobeta, or a manualworkflow_dispatchracing a push-triggered run), their uploads can interleave, leaving the release with a manifest from one commit and binaries from another — devices checking for updates mid-publish could fetch a corrupted/mismatched pairing. Add aconcurrency:group to serialize runs for this workflow.🔒 Suggested fix
on: push: branches: [beta] paths: - 'Integrations/ESPHome/**' workflow_dispatch: +concurrency: + group: beta-firmware-release + cancel-in-progress: false + # Least privilege: read-only by default; only publish-beta is elevated to write. permissions: contents: read🤖 Prompt for AI Agents