Skip to content
Closed
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 AIR-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
# (AIR-1.yaml), not the first-flash Factory image.
- { yaml: Integrations/ESPHome/AIR-1.yaml, name: firmware-standard }
- { yaml: Integrations/ESPHome/AIR-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 AIR-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."
6 changes: 5 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ jobs:
pull-requests: write
with:
device-name: air-1
# AIR-1.yaml is the end-user image served at firmware/ (OTA updates and
# the Bluetooth Proxy "Disabled" option). The Factory image (improv +
# factory test) is only used for first flashes via the web installer.
yaml-files: |
Integrations/ESPHome/AIR-1.yaml
Integrations/ESPHome/AIR-1_Factory.yaml
Integrations/ESPHome/AIR-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
33 changes: 17 additions & 16 deletions Integrations/ESPHome/AIR-1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,24 @@ esphome:
version: "${version}"

min_version: 2025.11.0
# List form so Core.yaml's on_boot entries merge in (see Core.yaml).
on_boot:
priority: 500
then:
- text_sensor.template.publish:
id: apollo_firmware_version
state: "${version}"
- lambda: |-
id(deep_sleep_1).set_sleep_duration(id(deep_sleep_sleep_duration).state * 60 * 1000);
- if:
condition:
or:
- binary_sensor.is_on: ota_mode
- switch.is_on: prevent_sleep
then:
- lambda: |-
ESP_LOGW("Apollo", "Preventing Deep Sleep Due To OTA On Boot");
id(deep_sleep_1).prevent_deep_sleep();
- priority: 500
then:
- text_sensor.template.publish:
id: apollo_firmware_version
state: "${version}"
- lambda: |-
id(deep_sleep_1).set_sleep_duration(id(deep_sleep_sleep_duration).state * 60 * 1000);
- if:
condition:
or:
- binary_sensor.is_on: ota_mode
- switch.is_on: prevent_sleep
then:
- lambda: |-
ESP_LOGW("Apollo", "Preventing Deep Sleep Due To OTA On Boot");
id(deep_sleep_1).prevent_deep_sleep();
on_shutdown:
- light.turn_off: rgb_light

Expand Down
31 changes: 18 additions & 13 deletions Integrations/ESPHome/AIR-1_BLE.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
substitutions:
ble_firmware: "true"

esphome:
name: "${name}"
friendly_name: Apollo AIR-1
Expand All @@ -8,20 +11,22 @@ esphome:
version: "${version}"

min_version: 2025.11.0
# List form so Core.yaml's on_boot entries merge in (see Core.yaml).
on_boot:
priority: 500
then:
- lambda: |-
id(deep_sleep_1).set_sleep_duration(id(deep_sleep_sleep_duration).state * 60 * 60 * 1000);
- if:
condition:
or:
- binary_sensor.is_on: ota_mode
- switch.is_on: prevent_sleep
then:
- lambda: |-
ESP_LOGW("Apollo", "Preventing Deep Sleep Due To OTA On Boot");
id(deep_sleep_1).prevent_deep_sleep();
- priority: 500
then:
- text_sensor.template.publish:
id: apollo_firmware_version
state: "${version}"
- lambda: |-
id(deep_sleep_1).set_sleep_duration(id(deep_sleep_sleep_duration).state * 60 * 1000);
# A Bluetooth proxy is useless asleep, so this image keeps the device
# awake: forcing the Prevent Sleep switch on also gates the
# api on_client_connected deep_sleep.enter path in Core.yaml.
- lambda: |-
ESP_LOGI("Apollo", "Bluetooth proxy firmware: forcing Prevent Sleep on");
id(prevent_sleep).turn_on();
id(deep_sleep_1).prevent_deep_sleep();
on_shutdown:
- light.turn_off: rgb_light

Expand Down
123 changes: 122 additions & 1 deletion Integrations/ESPHome/Core.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
substitutions:
name: apollo-air-1
version: "26.7.1.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 AIR-1_BLE.yaml so the
# Bluetooth Proxy select can self-correct to what is actually running.
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/AIR-1"
beta_manifest_base: "https://github.com/ApolloAutomation/AIR-1/releases/download/beta"

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:
# The Bluetooth Proxy select mirrors the firmware actually running, so
# a failed or abandoned switch snaps back to the truth on reboot.
- lambda: |-
if (std::string("${ble_firmware}") == "true") {
id(firmware_ble).publish_state("Enabled");
} else {
id(firmware_ble).publish_state("Disabled");
}
- script.execute: apply_ota_source

esp32:
variant: esp32c3
Expand Down Expand Up @@ -442,6 +465,52 @@ button:
on_press:
- sen5x.start_fan_autoclean: sen55

- 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 type"
# OTA download needs the device awake for its whole duration.
- lambda: |-
id(deep_sleep_1).prevent_deep_sleep();
# 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:
Comment on lines +487 to +494

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the referenced file and nearby definitions.
git ls-files | rg '^Integrations/ESPHome/Core\.yaml$|^Integrations/ESPHome/'

# Show the relevant section around the cited lines.
sed -n '430,540p' Integrations/ESPHome/Core.yaml

# Find where apply_ota_source and update_http_request are defined/used.
rg -n "apply_ota_source|update_http_request|update\.perform|script\.wait" Integrations/ESPHome/Core.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 4154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the parts of the file that define the scripts and update flow.
sed -n '1,220p' Integrations/ESPHome/Core.yaml
sed -n '220,420p' Integrations/ESPHome/Core.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 11059


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Use a small Python probe to understand the ordering semantics from the YAML structure only.
python3 - <<'PY'
from pathlib import Path
text = Path('Integrations/ESPHome/Core.yaml').read_text()
for key in ['apply_ota_source', 'update_http_request', 'update.perform', 'script.wait']:
    print(f'-- {key} --')
    for i, line in enumerate(text.splitlines(), 1):
        if key in line:
            print(i, line)
PY

Repository: ApolloAutomation/AIR-1

Length of output: 721


🌐 Web query:

ESPHome script.wait component.update update.perform semantics asynchronous script action update component check_for_update

💡 Result:

In ESPHome, script.wait, update.perform, and update.check are specific automation actions with defined semantics [1][2][3]. script.wait The script.wait action is used to suspend the execution of an automation until a specified script has finished running [2]. If no script is currently running, the action completes immediately [2]. If multiple instances of the script are running in parallel, it blocks until all of them have terminated [2]. Internally, as of early 2026, script.wait is classified as an asynchronous action, meaning it does not block the device's main execution loop while waiting [4]. update.perform and update.check The update component provides two primary actions for managing firmware or software updates: 1. update.perform: This action triggers the update entity to begin the installation process [1][5]. It accepts a boolean force_update parameter, which, if set to true, forces an update even if the device is already on the same version [1][5]. 2. update.check: This action triggers the update entity to check for available updates without initiating an installation [1]. Important Semantics Users should note that script.wait is designed for orchestration between scripts and is not suitable for use within lambdas, as doing so would block the device's execution and cause it to crash [2]. Furthermore, while script.wait handles queueing for concurrent script executions, incorrect configurations in child scripts (such as improperly handled delays) have historically caused script.wait to return prematurely [6]. Finally, developers should be aware that recent changes (as of March 2026) require actions to explicitly define their synchronous or asynchronous nature when registered [4].

Citations:


🌐 Web query:

ESPHome "component.update" action synchronous update entity semantics

💡 Result:

The ESPHome component.update action is a synchronous action that manually triggers the update method of a specified component [1][2]. Because it is categorized as a synchronous action within the ESPHome automation framework, it executes immediately when called during an automation sequence [3][4]. Key semantic and technical details include: 1. Synchronous Execution: The component.update action is considered a synchronous action, meaning it executes its logic and returns control back to the automation runner immediately after the component's update method has been invoked [3][4]. It is not an asynchronous operation that waits for the component's update process to complete if that process involves delayed, non-blocking I/O [3]. 2. PollingComponent Requirement: The action is designed specifically for components that inherit from the PollingComponent class [1][2]. Attempting to use this action on incompatible component types will result in a compile-time error [1][2]. 3. Implementation Details: Under the hood, the action simply calls the target component's update method (e.g., id(my_component).update;) [1][2]. The framework includes safety checks to ensure that the component has completed its setup phase before the update is triggered to prevent potential crashes (e.g., if memory has not been allocated) [5]. 4. Usage Context: It is often used to trigger updates on-demand for components whose automatic polling interval is set to never or is suspended [1][2]. Developers should be cautious when calling this action from multiple places simultaneously, as rapid-fire updates to complex components may lead to performance issues or system instability [6]. For developers building external components, note that as of 2026.3.0, all actions registered via register_action must explicitly define their synchronous nature [3][4]. The component.update action is categorized as synchronous (synchronous=True) [3][4].

Citations:


🌐 Web query:

site:esphome.io "component.update" "update entity" ESPHome

💡 Result:

In ESPHome, component.update and update entities represent two different, distinct concepts. component.update Action The component.update action is a general-purpose automation action used to manually trigger the update method of a component [1]. This is commonly used in scenarios where you want to force a component to perform its routine work (e.g., refreshing a display, updating an image, or re-drawing a screen) outside of its standard update_interval [2][3][4][5]. Example usage in an automation: on_...: then: - component.update: my_component_id Update Entity (update Component) The update entity is a specific, high-level component type within ESPHome designed to manage software or firmware updates for a device [6][7]. It is not a generic "update" action, but rather a dedicated entity that tracks update availability, state, and progress [8][9][10]. Key features of the update entity: - Purpose: It allows you to check for firmware or software updates (e.g., via a manifest file) [6][7]. - Automation: You can use the update.check action specifically on an update entity to trigger a check for updates [6]. - States: It tracks states such as unknown, no update, update available, and installing [9][10]. - Implementation: It is managed via the UpdateEntity class in the ESPHome source code, which provides methods like publish_state and perform (to install an update) [8][9][10]. In summary, component.update is a generic trigger to make any component run its internal update logic, whereas an update entity is a specialized sensor-like component for handling device update management [1][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the apply_ota_source script and the update entity definition.
sed -n '620,660p' Integrations/ESPHome/Core.yaml
sed -n '1,120p' Integrations/ESPHome/Core.yaml | rg -n "update_http_request|http_request|update:"

Repository: ApolloAutomation/AIR-1

Length of output: 1680


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the update_http_request entity definition and any related actions.
rg -n "id:\s*update_http_request|platform:\s*update|http_request|update\.check|update\.perform|set_source_url" Integrations/ESPHome

Repository: ApolloAutomation/AIR-1

Length of output: 1464


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether the same update flow exists in the variant files or README, which may clarify intent.
rg -n "apply_ota_source|update_http_request|delay: 3s|delay: 2s|update.perform" Integrations/ESPHome/AIR-1*.yaml Integrations/ESPHome/README.md

Repository: ApolloAutomation/AIR-1

Length of output: 610


🌐 Web query:

ESPHome update http_request entity update() asynchronous manifest check source_url set_source_url perform semantics

💡 Result:

In ESPHome, the http_request update platform provides a mechanism to automatically check for and perform firmware updates by fetching a JSON manifest file [1]. Key architectural and functional details include: Asynchronous Execution The update() function in the http_request update component runs in a dedicated task (on ESP32) to prevent blocking the main loop [2][3]. This prevents watchdog timeouts if the HTTP request is slow or blocked, as the task can yield while waiting for the esp_http_client to return [3]. Because network operations and state updates occur in different contexts, the component uses defer() to schedule state changes (such as publishing the state or triggering the update_available event) back to the main loop to ensure thread safety [2][4]. Manifest Handling The component expects a JSON manifest structured for ESP-Web-Tools [1]. During the update() cycle, it fetches this manifest from the source_url [2]. It validates the metadata and determines if an update is available based on the current firmware version [1]. Users can optionally define custom check_update logic in their YAML configuration to further refine whether an update should be considered available [5]. Semantics of Source URL and Perform - set_source_url: This method (or the source configuration variable) defines the URI of the JSON manifest [1][6]. - perform(bool force): This method is called to initiate the installation of an update [7]. When invoked, it sets the component state to UPDATE_STATE_INSTALLING and begins the OTA process, often involving configuring the ota parent component with the required MD5 hash [2]. Important Behaviors - Network Awareness: Recent updates have improved reliability by checking network::is_connected() before attempting to fetch the manifest [8]. This prevents errors at startup when the device might not yet have an active network connection [8][9]. - Automation: You can trigger checks or perform updates via ESPHome's internal update.check or ota.http_request.flash actions, depending on your configuration requirements [8][10]. Top results: [1][2][3][4][6][5][8][10]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '35,75p' Integrations/ESPHome/AIR-1.yaml
sed -n '35,75p' Integrations/ESPHome/AIR-1_BLE.yaml
sed -n '35,75p' Integrations/ESPHome/AIR-1_Factory.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 1981


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the ESPHome update action docs for perform/check behavior.
python3 - <<'PY'
import urllib.request, re
urls = [
    'https://esphome.io/components/update/',
    'https://new.esphome.io/components/update/',
    'https://esphome.io/components/update/http_request/',
]
for u in urls:
    try:
        with urllib.request.urlopen(u, timeout=20) as r:
            html = r.read().decode('utf-8', 'ignore')
        print(f'== {u} ==')
        for pat in ['perform', 'check', 'source_url', 'manifest']:
            if pat in html:
                print(f'contains {pat}')
    except Exception as e:
        print(f'ERR {u}: {e}')
PY

Repository: ApolloAutomation/AIR-1

Length of output: 708


Wait for the manifest check instead of relying on fixed sleeps. script.wait only waits for apply_ota_source to finish; it does not wait for the component.update: update_http_request fetch to complete. On a slow network, update.perform can still run before the update state refreshes, causing a stale or skipped firmware check.

🤖 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 487 - 491, The OTA flow in the
`apply_ota_source`/`update.perform` sequence still depends on fixed delays,
which can let the firmware check run before the manifest fetch has refreshed the
update state. Replace the sleep-based timing with an explicit wait for the
`component.update: update_http_request` manifest check to complete before
calling `update.perform`, using the existing `apply_ota_source` script path as
the anchor for where to add the synchronization.

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
- if:
condition:
and:
- switch.is_off: prevent_sleep
- binary_sensor.is_off: ota_mode
then:
- lambda: |-
id(deep_sleep_1).allow_deep_sleep();

Comment thread
coderabbitai[bot] marked this conversation as resolved.
text_sensor:
# Convert VOC Index To Text:
# https://sensirion.com/media/documents/02232963/6294E043/Info_Note_VOC_Index.pdf
Expand Down Expand Up @@ -536,7 +605,59 @@ select:
then:
- script.execute: update_air_quality_led

- 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

- platform: template
name: "Bluetooth Proxy"
id: firmware_ble
icon: mdi:bluetooth
entity_category: "config"
optimistic: true
restore_value: true
options:
- "Disabled"
- "Enabled"
initial_option: "Disabled"
on_value:
then:
- script.execute: apply_ota_source

script:
- id: apply_ota_source
# Sets the OTA manifest URL from the two selectors: Bluetooth Proxy
# (Disabled/Enabled) x Firmware Channel (Stable/Beta).
# Stable = GitHub Pages, Beta = rolling "beta" release assets.
then:
- lambda: |-
const bool ble = id(firmware_ble).current_option() == "Enabled";
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

- id: setCo2AutoCalibration
mode: restart
parameters:
Expand Down
2 changes: 1 addition & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ <h1>Apollo AIR-1 Installer</h1>
<div class="button-row" style="display: flex; justify-content: center; gap: 20px; align-items: center;">
<div>
<p>AIR-1 Firmware</p>
<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>
</div>
</div>

Expand Down