Skip to content
Open
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
102 changes: 102 additions & 0 deletions .github/workflows/build-beta.yml
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."
Comment on lines +48 to +91

Copy link
Copy Markdown

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-fw release publish.

publish-beta uploads manifest.json and .bin assets to the same rolling beta-fw release 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 to beta, or a manual workflow_dispatch racing 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 a concurrency: 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build-beta.yml around lines 48 - 91, Add workflow-level
concurrency control for the beta publishing workflow, using a stable group keyed
to the beta workflow or branch so overlapping runs cannot execute `publish-beta`
uploads concurrently. Configure the concurrency behavior to serialize runs while
preserving the intended latest-run handling, and leave the existing release
upload steps unchanged.


- 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 }}"
6 changes: 5 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ jobs:
pull-requests: write
with:
device-name: btn-1
# BTN-1_Minimal.yaml is the end-user image served at firmware/ (OTA
# updates and dashboard adoption). BTN-1.yaml (improv + BLE) is only
# used for first flashes via the web installer.
yaml-files: |
Integrations/ESPHome/BTN-1_Minimal.yaml
Integrations/ESPHome/BTN-1.yaml
firmware-names: "1:firmware"
firmware-names: "1_Minimal:firmware,1:firmware-factory"
core-yaml-path: Integrations/ESPHome/Core.yaml
esphome-version: stable
# Bypass check if manually triggered with bypass option
Expand Down
1 change: 1 addition & 0 deletions Integrations/ESPHome/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
# You can modify this file to suit your needs.
/.esphome/
/secrets.yaml
beta-channel/.esphome/
7 changes: 2 additions & 5 deletions Integrations/ESPHome/BTN-1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,17 @@ ota:
- platform: http_request
id: ota_managed

http_request:
verify_ssl: true

safe_mode:

update:
- platform: http_request
id: firmware_update
id: update_http_request
name: Firmware Update
source: https://apolloautomation.github.io/BTN-1/firmware/manifest.json

wifi:
on_connect:
- component.update: firmware_update
- component.update: update_http_request
ap:
ssid: "Apollo BTN1 Hotspot"

Expand Down
12 changes: 12 additions & 0 deletions Integrations/ESPHome/BTN-1_Minimal.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,20 @@ dashboard_import:
ota:
- platform: esphome
id: ota_esphome
- platform: http_request
id: ota_managed

safe_mode:

update:
- platform: http_request
id: update_http_request
name: Firmware Update
source: https://apolloautomation.github.io/BTN-1/firmware/manifest.json

wifi:
on_connect:
- component.update: update_http_request
ap:
ssid: "Apollo BTN-1 Hotspot"

Expand Down
118 changes: 118 additions & 0 deletions Integrations/ESPHome/Core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: |-
Expand Down Expand Up @@ -86,6 +116,7 @@ esphome:
- switch.turn_off: power_latch

api:
encryption:
reboot_timeout: 0s
actions:
- action: play_buzzer
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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:

ESPHome http_request update platform component.update asynchronous manifest fetch source_url update_info semantics

💡 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:

ESPHome http_request update entity available state update_info latest_version perform(true) semantics

💡 Result:

In ESPHome, the interaction between http_request and the update entity (specifically the http_request platform for updates) involves managing firmware metadata and deployment states [1][2][3]. The update_info_ structure and perform(bool force) method are central to how these updates function [3][4]: 1. update_info_: This internal member holds metadata about the available update, including fields such as latest_version, title, firmware_url, and md5 [3][5]. When the component checks for updates (typically by reading a JSON manifest), it parses these details and populates the update_info_ structure [1][3]. 2. perform(bool force): This method is used to initiate the installation process [3][4]. - When called, the component checks the current state of the update entity [3]. - Unless the force parameter is set to true, the component will generally only proceed if the state is UPDATE_STATE_AVAILABLE [3]. - If triggered, it sets the entity state to UPDATE_STATE_INSTALLING, configures the parent OTA component with the firmware URL and MD5 hash extracted from update_info_, and begins the download and installation [3]. 3. Semantics: The perform() action is defined in the base UpdateEntity class and implemented by HttpRequestUpdate [4]. The latest_version is updated during the background check task as part of the UpdateInfo object, which is then moved into update_info_ on the main execution loop to ensure thread safety when updating the entity's state [3]. The update() method handles the periodic check for new versions, while perform() handles the execution of the update process itself [3][6].

Citations:


Replace the fixed 5s sleep with a real refresh-complete check.
component.update: update_http_request refreshes the manifest in the background, so this blind delay can still reach perform(true) while the previous manifest is active. Wait on a concrete completion signal with a timeout before forcing the install.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Integrations/ESPHome/Core.yaml` around lines 319 - 346, Replace the fixed
5-second delay in the Firmware Update on_press flow with a wait for the manifest
refresh initiated by update_http_request to complete, using an explicit
completion signal and a bounded timeout. Ensure perform(true) runs only after
refresh completion or timeout, while preserving the existing deep-sleep rearming
logic.

# Button entities for physical buttons
binary_sensor:
- platform: status
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions Integrations/ESPHome/beta-channel/BTN-1_Minimal.yaml
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
2 changes: 1 addition & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
<h1>Apollo BTN-1 Installer</h1>

<p class="button-row" align="center">
<esp-web-install-button manifest="./firmware/manifest.json">
<esp-web-install-button manifest="./firmware-factory/manifest.json">
</esp-web-install-button>
</p>

Expand Down