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
105 changes: 105 additions & 0 deletions .github/workflows/build-beta.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: Build and Publish Beta

# Builds MSR-2 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 ${{ matrix.name }}
needs: version
strategy:
matrix:
include:
# Beta serves OTA updates only, so it builds the end-user image
# (MSR-2.yaml), not the first-flash Factory image.
- { yaml: Integrations/ESPHome/beta-channel/MSR-2.yaml, name: firmware-standard }
- { yaml: Integrations/ESPHome/beta-channel/MSR-2_BLE.yaml, name: firmware-ble-beta }
uses: esphome/workflows/.github/workflows/build.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1
with:
files: ${{ matrix.yaml }}
esphome-version: stable
combined-name: ${{ matrix.name }}
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 MSR-2 beta firmware. Auto-updated on every push to the beta branch."

- name: Rewrite manifests to absolute URLs and upload assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BASE="https://github.com/${{ github.repository }}/releases/download/beta-fw"
declare -A DIRS=( [standard]=firmware-standard [ble]=firmware-ble-beta )
for v in standard ble; do
src="fw/${DIRS[$v]}"
man=$(find "$src" -name manifest.json | head -1)
if [ -z "$man" ]; then
echo "::error::manifest.json not found for ${DIRS[$v]}"
exit 1
fi
# Both variants share a device name, so their bin filenames match.
# Release assets are a flat namespace: prefix per variant.
find "$src" -name '*.bin' | while read -r bin; do
mv "$bin" "$(dirname "$bin")/$v-$(basename "$bin")"
done
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" --arg pfx "$v-" '
.builds[0].ota.path = ($base + "/" + $pfx + (.builds[0].ota.path | sub(".*/"; "")))
| .builds[0].parts |= map(.path = ($base + "/" + $pfx + (.path | sub(".*/"; ""))))
' "$man" > "manifest-$v.json"
cat "manifest-$v.json"
gh release upload beta-fw "manifest-$v.json" -R "${{ github.repository }}" --clobber
find "$src" -name '*.bin' -print -exec \
gh release upload beta-fw {} -R "${{ github.repository }}" --clobber \;
done
echo "Beta assets published."
5 changes: 4 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ jobs:
pull-requests: write
with:
device-name: msr-2
# MSR-2.yaml is the end-user image served at firmware/ (OTA updates). The Factory image (improv +
# factory test) is only used for first flashes via the web installer.
yaml-files: |
Integrations/ESPHome/MSR-2.yaml
Integrations/ESPHome/MSR-2_Factory.yaml
Integrations/ESPHome/MSR-2_BLE.yaml
firmware-names: "2_Factory:firmware,2_BLE:firmware-ble"
firmware-names: "2:firmware,2_Factory:firmware-factory,2_BLE:firmware-ble"
core-yaml-path: Integrations/ESPHome/Core.yaml
esphome-version: stable
# Bypass check if manually triggered with bypass option
Expand Down
111 changes: 109 additions & 2 deletions Integrations/ESPHome/Core.yaml
Original file line number Diff line number Diff line change
@@ -1,20 +1,72 @@
substitutions:
name: apollo-msr-2
version: "26.6.10.1"
version: "26.7.12.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/MSR-2"
beta_manifest_base: "https://github.com/ApolloAutomation/MSR-2/releases/download/beta-fw"
# Per-variant OTA manifest URLs. Core defaults to the unified (standard)
# image; MSR-2_BLE.yaml overrides these so legacy BLE devices stay on the
# firmware-ble manifest.
ota_stable_manifest: "${stable_manifest_base}/firmware/manifest.json"
ota_beta_manifest: "${beta_manifest_base}/manifest-standard.json"

esphome:
# List form so package merging concatenates with each variant's own on_boot
# entries (mapping form would be replaced by the variant's block instead).
on_boot:
- 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:

esp32:
variant: esp32c3
flash_size: 4MB
framework:
type: esp-idf

esp32_ble_tracker:
id: ble_tracker
scan_parameters:
continuous: true

bluetooth_proxy:

captive_portal:

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

globals:
- id: button_press_timestamp
restore_value: no
Expand Down Expand Up @@ -720,6 +772,21 @@ button:
value: 420
id: scd40

- 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"
- 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);
Comment on lines +775 to +788

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

ref="$(gh api repos/esphome/esphome/releases/latest --jq .tag_name)"
curl -fsSL \
  "https://raw.githubusercontent.com/esphome/esphome/${ref}/esphome/components/http_request/update/http_request_update.cpp" |
  sed -n '/HttpRequestUpdate::update()/,/^}/p; /HttpRequestUpdate::perform(bool force)/,/^}/p'

Repository: ApolloAutomation/MSR-2

Length of output: 1061


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Inspect the relevant section in the repo config
file='Integrations/ESPHome/Core.yaml'
wc -l "$file"
sed -n '720,820p' "$file"

# Find definitions/usages related to the update request and channel selection
rg -n 'update_http_request|apply_ota_source|selected channel|manifest|update_info|perform\(true\)|component.update|script.execute: apply_ota_source' "$file"

Repository: ApolloAutomation/MSR-2

Length of output: 4292


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

file='Integrations/ESPHome/Core.yaml'

# Read the OTA script and any state-dependent logic around the update entity.
sed -n '900,950p' "$file"
printf '\n---\n'
rg -n 'update_http_request|update_state|update.is_available|update_info|available|state:' "$file"

# Pull the exact ESPHome source for the update component to check async behavior and state transitions.
ref="$(gh api repos/esphome/esphome/releases/latest --jq .tag_name)"
curl -fsSL "https://raw.githubusercontent.com/esphome/esphome/${ref}/esphome/components/http_request/update/http_request_update.cpp" \
  | sed -n '1,220p'

Repository: ApolloAutomation/MSR-2

Length of output: 10858


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

file='Integrations/ESPHome/Core.yaml'

# Check whether the config already waits on any explicit update completion signal.
rg -n 'wait_until:|update_http_request|update\.is_available|UPDATE_STATE_AVAILABLE|publish_state|firmware update|channel' "$file"

# Inspect the ESPHome update component source for state publication and whether perform(true) ignores availability.
ref="$(gh api repos/esphome/esphome/releases/latest --jq .tag_name)"
curl -fsSL "https://raw.githubusercontent.com/esphome/esphome/${ref}/esphome/components/http_request/update/http_request_update.cpp" \
  | sed -n '1,160p'

Repository: ApolloAutomation/MSR-2

Length of output: 7246


Wait for the manifest refresh to finish before forcing installation. component.update is asynchronous, so the fixed 5s delay can still race and perform(true) may flash the previous update_info or an empty URL if the new channel manifest hasn’t landed yet. Hook this to the update entity’s completion/available state for the selected channel instead of a timer. Integrations/ESPHome/Core.yaml:781-788

🤖 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 775 - 788, Replace the fixed
delay in the update_firmware on_press sequence with synchronization on the
update entity’s manifest refresh/completion or availability state for the
selected channel. Ensure id(update_http_request).perform(true) runs only after
the new channel’s update_info and URL are populated, while preserving the
existing apply_ota_source execution and wait steps.



interval:
- interval: 1s
Expand Down Expand Up @@ -785,6 +852,19 @@ switch:
id: setCo2AutoCalibration
enable: false

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


text_sensor:
- platform: ld2410
Expand Down Expand Up @@ -812,9 +892,36 @@ select:
name: "ld2410 Gate Size"
disabled_by_default: true

- 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).
# The manifest base is per-variant (ota_*_manifest substitutions) so legacy
# MSR-2_BLE builds keep their firmware-ble manifest.
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: setCo2AutoCalibration
mode: restart
parameters:
Expand Down
4 changes: 0 additions & 4 deletions Integrations/ESPHome/MSR-2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ wifi:
ap:
ssid: "Apollo MSR2 Hotspot"


http_request:
verify_ssl: true

safe_mode:

update:
Expand Down
14 changes: 5 additions & 9 deletions Integrations/ESPHome/MSR-2_BLE.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
substitutions:
# Legacy BLE devices keep updating from the firmware-ble manifest.
ota_stable_manifest: "https://apolloautomation.github.io/MSR-2/firmware-ble/manifest.json"
ota_beta_manifest: "https://github.com/ApolloAutomation/MSR-2/releases/download/beta-fw/manifest-ble.json"

esphome:
name: "${name}"
friendly_name: Apollo MSR-2
Expand Down Expand Up @@ -45,15 +50,6 @@ ota:
- platform: http_request
id: ota_managed

bluetooth_proxy:
active: true
esp32_ble_tracker:
scan_parameters:
active: false

http_request:
verify_ssl: true

safe_mode:

update:
Expand Down
3 changes: 0 additions & 3 deletions Integrations/ESPHome/MSR-2_Factory.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,6 @@ ota:
- platform: http_request
id: ota_managed

http_request:
verify_ssl: true

safe_mode:

update:
Expand Down
9 changes: 9 additions & 0 deletions Integrations/ESPHome/beta-channel/MSR-2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Beta-channel build of MSR-2.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 MSR-2.yaml directly.
substitutions:
firmware_channel_default: "Beta"

packages:
base: !include ../MSR-2.yaml
9 changes: 9 additions & 0 deletions Integrations/ESPHome/beta-channel/MSR-2_BLE.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Beta-channel build of MSR-2_BLE.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 MSR-2_BLE.yaml directly.
substitutions:
firmware_channel_default: "Beta"

packages:
base: !include ../MSR-2_BLE.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 MSR-2 Installer</h1>

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

<div class="footer">
Expand Down
Loading