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-1 firmware from the beta branch and publishes it as assets on a
# 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).

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-1.yaml), not the first-flash Factory image.
- { yaml: Integrations/ESPHome/MSR-1.yaml, name: firmware-standard }
- { yaml: Integrations/ESPHome/MSR-1_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' pre-release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view beta -R "${{ github.repository }}" >/dev/null 2>&1 \
|| gh release create beta -R "${{ github.repository }}" \
--prerelease --title "Beta (rolling)" \
--notes "Latest MSR-1 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"
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 "manifest-$v.json" -R "${{ github.repository }}" --clobber
find "$src" -name '*.bin' -print -exec \
gh release upload beta {} -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-1.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-1.yaml
Integrations/ESPHome/MSR-1_Factory.yaml
Integrations/ESPHome/MSR-1_BLE.yaml
firmware-names: "1_Factory:firmware,1_BLE:firmware-ble"
firmware-names: "1:firmware,1_Factory:firmware-factory,1_BLE:firmware-ble"
core-yaml-path: Integrations/ESPHome/Core.yaml
esphome-version: stable
# Bypass check if manually triggered with bypass option
Expand Down
82 changes: 81 additions & 1 deletion Integrations/ESPHome/Core.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
substitutions:
version: "26.6.17.1"
version: "26.7.7.1"
device_description: ${name} made by Apollo Automation - version ${version}.
# Default OTA password. Override in your device YAML by re-declaring
# `substitutions: { ota_password: !secret <name>_ota_password }` so each
# device on your network uses a unique secret instead of the shared default.
ota_password: "apolloautomation"
# Firmware variant identity: overridden to "true" by MSR-1_BLE.yaml so
# each image tracks its own OTA manifests.
ble_firmware: "false"
# Manifest URL bases. Stable = GitHub Pages (main branch builds).
# Beta = rolling "beta" pre-release assets (beta branch builds).
stable_manifest_base: "https://apolloautomation.github.io/MSR-1"
beta_manifest_base: "https://github.com/ApolloAutomation/MSR-1/releases/download/beta"

esp32:
variant: esp32c3
Expand Down Expand Up @@ -655,6 +662,41 @@ 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 based on selected channel and variant"
# Free heap for the TLS download on the BLE variant. Guarded so the
# non-BLE variant compiles the same YAML.
- lambda: |-
#ifdef USE_ESP32_BLE
if (esp32_ble::global_ble && esp32_ble::global_ble->is_active()) {
ESP_LOGI("firmware", "Disabling BLE for firmware update");
esp32_ble::global_ble->disable();
}
#endif
- delay: 3s
- script.execute: apply_ota_source
- script.wait: apply_ota_source
# The manifest fetch runs in its own task and YAML has no "fetch done"
# condition to wait on (update.is_available stays false for same-version
# variant switches), so give it a fixed window like R_PRO-1/CAST-1 do.
- delay: 5s
- update.perform:
id: update_http_request
force_update: true
# Only reached if the update did not start (e.g. manifest unreachable).
- lambda: |-
#ifdef USE_ESP32_BLE
if (esp32_ble::global_ble) {
ESP_LOGI("firmware", "Re-enabling BLE (no update performed)");
esp32_ble::global_ble->enable();
}
#endif

Comment on lines +665 to +699

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

ESPHome update.perform http_request blocking or async behavior

💡 Result:

In ESPHome, the behavior of http_request and its associated update mechanism involves important distinctions between blocking and asynchronous execution to ensure system stability. HTTP Request Component The underlying http_request component has historically been known for its blocking nature, as it often relies on underlying libraries (like C++ httplib or standard Arduino WiFiClient) that wait for the full response before proceeding [1][2][3]. This behavior can block the main execution loop if a request takes too long, potentially causing issues like lost data or system instability [1]. Efforts are ongoing within the ESPHome project to move these operations toward a more asynchronous execution model to prevent blocking the main loop [1]. update.perform Action When using the update.perform action (specifically in the context of an http_request update source), the behavior is designed to avoid blocking the system during the actual firmware installation [4]. 1. Initiation: When perform is called, the component validates the update state [4]. 2. Deferred Execution: Rather than performing the heavy lifting of the OTA (Over-the-Air) flash process immediately and synchronously, the implementation uses a deferred execution pattern [4]. The flashing process is scheduled to occur in the next loop cycle using a defer call, ensuring the current execution context is not stalled by the initiation of the update [4]. Summary While standard http_request send actions can be blocking in their communication phase, the update.perform action is specifically implemented to be non-blocking by deferring the heavy flash operation to subsequent loop cycles [1][4]. Users should be aware that standard HTTP request actions in automations still carry a risk of blocking the loop if the remote server is unresponsive [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the reviewed YAML and any ESPHome source/docs in the repo.
git ls-files | rg '(^|/)Integrations/ESPHome/Core\.yaml$|update(_perform|\.cpp|\.h)|http_request|esp32_ble|ble' || true

# Show the reviewed snippet with line numbers.
sed -n '650,710p' Integrations/ESPHome/Core.yaml

# Search for update.perform implementation or references if present in repo.
rg -n --hidden --no-ignore-vcs 'update\.perform|class .*Update|defer\(|global_ble|disable\(\)|enable\(\)' . || true

Repository: ApolloAutomation/MSR-1

Length of output: 2576


🌐 Web query:

ESPHome http_request update perform source deferred action returns before completion lambda after update.perform

💡 Result:

In ESPHome, the http_request.send action (used by http_request.get, http_request.post, etc.) historically behaves as a blocking operation within the automation chain [1][2][3]. Because it is designed as a synchronous action, the execution of any subsequent lambdas or actions defined in your script will not occur until the HTTP request has fully completed and the response has been processed [1]. In the context of ESPHome automations, actions are generally executed in the order they appear. For an action to be non-blocking and allow the automation to "continue" before completion, it must be explicitly designed as an asynchronous action (where play_next_() is deferred and called later via a timer, loop callback, or event handler) [4][5]. The http_request action, however, does not defer its completion in this manner; it waits for the underlying network library (such as cpp-httplib) to return [1]. If you find that your code is executing unexpectedly or you need to perform actions in parallel, consider the following: 1. Blocking Nature: The http_request action blocks the main ESPHome loop until the request completes [1][3]. If your request takes a long time, it can cause watchdog timeouts or impact other system functions [1]. 2. Script Modes: If you are using scripts, the mode (single, restart, parallel) affects how multiple instances of the same script interact [6]. If a script instance is triggered while another is still blocked by a long-running HTTP request, the script's behavior will depend on its defined mode [6]. 3. Architecture: ESPHome actions are typically synchronous by default unless they specifically implement the asynchronous pattern (registering callbacks to call play_next_()) [4][5]. Since http_request does not currently offload the request to an asynchronous worker that returns control immediately, the "completion lambda" or subsequent actions will always wait for the request to conclude [1]. If you require non-blocking execution, you would typically need to move the request into a custom component that handles the network communication in a separate task or uses an asynchronous networking library that does not block the main loop [1]. Using standard http_request actions will remain synchronous and block the progression of the current script's action list [1].

Citations:


🌐 Web query:

site:github.com/esphome/esphome "update.perform" deferred play_next_ action http_request_update.cpp

💡 Result:

In the context of ESPHome's source code, specifically within esphome/components/http_request/update/http_request_update.cpp, the use of.defer with actions like play_next_ is a pattern used to ensure thread safety when code runs in a background FreeRTOS task [1][2]. The.defer method schedules a callback to be executed on the ESPHome main loop thread [1][2]. This is critical in http_request_update.cpp because the update check typically runs in a dedicated background task (to prevent watchdog timeouts during network operations) [3][4]. Accessing shared components, state, or triggering automations directly from this background task can lead to data races or memory corruption if those objects are not thread-safe or if they perform non-atomic read-modify-write operations on components [1][2]. By wrapping operations—such as calling play_next_ after an action completes, updating status errors, or triggering events—inside a.defer lambda, the code ensures that these state changes occur exclusively on the main loop [1][2]. This pattern prevents concurrent access to shared resources like the component's state or the global protobuf buffer [1][2]. Similar patterns are seen in core automation components, where play_next_ is often called from deferred callbacks (e.g., via timers or event handlers) to maintain the state machine integrity of the automation [5][6].

Citations:


Lines 694-698: restore BLE only if you disabled it here. The trailing lambda re-enables BLE unconditionally on a failed update; if BLE was already off, this turns it back on unexpectedly. Capture the original state and only re-enable when you actually disabled it.

🤖 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 665 - 699, The firmware update
flow unconditionally re-enables BLE in the trailing lambda even when it may not
have been disabled by this action. In the update action sequence under the
“Firmware Update” template, capture whether `esp32_ble::global_ble` was active
before disabling it, then only call `enable()` in the fallback path when this
step actually turned BLE off. Keep the state check and restore logic paired with
the existing disable/enable lambdas so the original BLE state is preserved.


switch:
- platform: ld2410
Expand Down Expand Up @@ -706,6 +748,44 @@ select:
name: "Radar Distance Resolution"
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: "Stable"
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) for the firmware variant this image was built as.
# Stable = GitHub Pages, Beta = rolling "beta" release assets.
then:
- lambda: |-
const bool ble = std::string("${ble_firmware}") == "true";
const bool beta = id(firmware_channel).current_option() == "Beta";
std::string url;
if (beta) {
url = ble
? "${beta_manifest_base}/manifest-ble.json"
: "${beta_manifest_base}/manifest-standard.json";
} else {
url = ble
? "${stable_manifest_base}/firmware-ble/manifest.json"
: "${stable_manifest_base}/firmware/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



#Used To Control RGB Light On Startup. Tells User Status Of Device
Expand Down
3 changes: 3 additions & 0 deletions Integrations/ESPHome/MSR-1_BLE.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
substitutions:
ble_firmware: "true"

esphome:
name: apollo-msr-1
friendly_name: Apollo Multisensor Mk1 (MSR-1)
Expand Down
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-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