From b964bc540fc3aaa8223a634ae229b9f6b651205d Mon Sep 17 00:00:00 2001 From: tschottd Date: Tue, 19 May 2026 01:31:58 +0200 Subject: [PATCH 01/24] chore(esphome): parameterize OTA password (preserve current default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the hardcoded `password: "apolloautomation"` literal to a `${ota_password}` substitution in `Core.yaml`, defaulting to the same value so existing consumers see no behavior change. Consumers who want a per-device password can override via `substitutions:` in their device YAML and stop sharing a fleet-wide credential. Why ---- Every AIR-1 worldwide currently boots with the same OTA password (introduced in #34, "Fix ota password", to make OTA upgrades work after the prior no-password attempts in #20 / #27). Consumers who include this file via `packages:` cannot override from their device YAML because ESPHome packages extend list-valued keys (a second `ota: - platform: esphome` block produces a duplicate-platform validation error) and `ota: !remove` cannot be paired with a top-level redeclaration in the same YAML doc. Newer Apollo product configs (MSR-2, MTR-1, PWR-1, TEMP-1, PLT-1, BTN-1, RLY-1, H-1, H-2, PUMP-1) already avoid the literal — this brings AIR-1 in line. Backwards compat ---------------- 100% — devices that don't set `ota_password` get `apolloautomation` exactly as today. CI / firmware builds unchanged. Override example (in a consumer device YAML): substitutions: ota_password: !secret living_room_air_1_ota_password packages: ApolloAutomation.AIR-1: github://ApolloAutomation/AIR-1/Integrations/ESPHome/AIR-1.yaml --- Integrations/ESPHome/AIR-1.yaml | 2 +- Integrations/ESPHome/AIR-1_BLE.yaml | 2 +- Integrations/ESPHome/Core.yaml | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Integrations/ESPHome/AIR-1.yaml b/Integrations/ESPHome/AIR-1.yaml index 60ffefa..454b4ce 100644 --- a/Integrations/ESPHome/AIR-1.yaml +++ b/Integrations/ESPHome/AIR-1.yaml @@ -37,7 +37,7 @@ logger: ota: - platform: esphome id: ota_esphome - password: "apolloautomation" + password: ${ota_password} wifi: ap: diff --git a/Integrations/ESPHome/AIR-1_BLE.yaml b/Integrations/ESPHome/AIR-1_BLE.yaml index 5ec6d3c..424fd2f 100644 --- a/Integrations/ESPHome/AIR-1_BLE.yaml +++ b/Integrations/ESPHome/AIR-1_BLE.yaml @@ -33,7 +33,7 @@ dashboard_import: ota: - platform: esphome - password: "apolloautomation" + password: ${ota_password} id: ota_esphome bluetooth_proxy: diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 64a2eba..721a5da 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -2,6 +2,10 @@ substitutions: name: apollo-air-1 version: "26.3.2.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 _ota_password }` so each + # device on your network uses a unique secret instead of the shared default. + ota_password: "apolloautomation" esp32: variant: esp32c3 From 1505d315d8f879d307e893cd7e0982a6390b471b Mon Sep 17 00:00:00 2001 From: bharvey88 <8107750+bharvey88@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:37:36 -0500 Subject: [PATCH 02/24] Enable CO2 automatic self-calibration with GUI toggle Enable the SCD40's automatic self-calibration by default (ESPHome default) so users no longer need to manually recalibrate every 1-2 years. Adds a "CO2 Auto Calibration" switch so users in spaces that never see fresh-air CO2 levels can turn ASC off and keep using the manual 420ppm calibration button instead. The switch re-applies the saved choice after every boot, since the sensor loses the setting on power loss and the scd4x component re-applies the YAML default during setup. --- Integrations/ESPHome/Core.yaml | 56 +++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 64a2eba..b5383b6 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -217,7 +217,7 @@ sensor: co2: name: "CO2" id: "co2" - automatic_self_calibration: false + automatic_self_calibration: true update_interval: 60s measurement_mode: "periodic" i2c_id: bus_a @@ -455,7 +455,7 @@ switch: entity_category: "config" on_turn_on: then: - - lambda: |- + - lambda: |- id(deep_sleep_1).prevent_deep_sleep(); on_turn_off: then: @@ -463,11 +463,59 @@ switch: condition: binary_sensor.is_off: ota_mode then: - - lambda: |- + - lambda: |- id(deep_sleep_1).allow_deep_sleep(); + - platform: template + name: "CO2 Auto Calibration" + id: co2_auto_calibration + icon: mdi:molecule-co2 + restore_mode: RESTORE_DEFAULT_ON + optimistic: true + entity_category: "config" + on_turn_on: + then: + - script.execute: + id: setCo2AutoCalibration + enable: true + on_turn_off: + then: + - script.execute: + id: setCo2AutoCalibration + enable: false + script: - - id: testScript + - id: setCo2AutoCalibration + mode: restart + parameters: + enable: bool + then: + #The SCD40 forgets this setting on power loss and the scd4x component + #re-applies the YAML default during setup (~1.5s after boot), so when this + #runs at boot (via the restored switch state) wait for setup to finish first. + - if: + condition: + lambda: "return millis() < 20000;" + then: + - delay: 20s + #The sensor only accepts the ASC command while idle, so stop periodic + #measurement first, then restart it. Same sequence the component uses + #for forced calibration. + - lambda: |- + id(scd40).write_command((uint16_t) 0x3F86); //stop periodic measurement + - delay: 500ms + - lambda: |- + id(scd40).set_automatic_self_calibration(enable); + if (!id(scd40).write_command((uint16_t) 0x2416, (uint16_t) (enable ? 1 : 0))) { + ESP_LOGE("Apollo", "Failed to set CO2 auto calibration"); + } else { + ESP_LOGI("Apollo", "CO2 auto calibration %s", enable ? "enabled" : "disabled"); + } + - delay: 10ms + - lambda: |- + id(scd40).write_command((uint16_t) 0x21B1); //start periodic measurement + + - id: testScript then: if: condition: From ccbfe3077fb7591d9088ae9b16c002392f480871 Mon Sep 17 00:00:00 2001 From: bharvey88 <8107750+bharvey88@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:46:08 -0500 Subject: [PATCH 03/24] Bump version to 26.6.10.1 --- Integrations/ESPHome/Core.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index b5383b6..f126081 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,6 +1,6 @@ substitutions: name: apollo-air-1 - version: "26.3.2.1" + version: "26.6.10.1" device_description: ${name} made by Apollo Automation - version ${version}. esp32: From 667937500c450ee012ebd7ddd78aefe4e35b39bc Mon Sep 17 00:00:00 2001 From: bharvey88 <8107750+bharvey88@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:46:06 -0500 Subject: [PATCH 04/24] Run label-check and auto-assign via pull_request_target Fork-submitted PRs get a read-only token on pull_request runs, so the label and assignee bots fail with 403. pull_request_target runs in the base repo context with a write token; safe here because neither job checks out or executes PR code. Build jobs stay on pull_request. Trim ci.yml permissions to what the builds need. --- .github/workflows/autoassign.yml | 5 ++++- .github/workflows/ci.yml | 7 ------- .github/workflows/label-check.yml | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/label-check.yml diff --git a/.github/workflows/autoassign.yml b/.github/workflows/autoassign.yml index 604d6d5..d4895a8 100644 --- a/.github/workflows/autoassign.yml +++ b/.github/workflows/autoassign.yml @@ -1,8 +1,11 @@ name: Auto Assign +# pull_request_target (not pull_request) so assignment works on +# fork-submitted PRs; fork pull_request runs get a read-only token. +# Safe because this workflow never checks out or executes PR code. on: issues: types: [opened] - pull_request: + pull_request_target: types: [opened] jobs: run: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 300d99f..2037d6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,17 +4,10 @@ on: pull_request: permissions: - # Allow GITHUB_TOKEN to add labels to pull requests - pull-requests: write - issues: write contents: read id-token: write jobs: - label-check: - name: Label Check - uses: ApolloAutomation/Workflows/.github/workflows/label-check.yml@main - ci: name: Building ${{ matrix.file }} / ESPHome ${{ matrix.esphome-version }} / ESPHome ${{ matrix.esphome-version }} runs-on: ubuntu-latest diff --git a/.github/workflows/label-check.yml b/.github/workflows/label-check.yml new file mode 100644 index 0000000..96a9751 --- /dev/null +++ b/.github/workflows/label-check.yml @@ -0,0 +1,20 @@ +name: Label Check + +# pull_request_target (not pull_request) so the job gets a write token on +# fork-submitted PRs too; plain pull_request runs from forks are read-only +# and cannot add labels. Safe because the called workflow only reads the PR +# body and never checks out or executes PR code. The "edited" type re-runs +# the check when the template checkboxes are changed. +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + label-check: + name: Label Check + uses: ApolloAutomation/Workflows/.github/workflows/label-check.yml@main From 749d1a9736a2e465b974652056af4ead6b66bc1e Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:16:31 -0500 Subject: [PATCH 05/24] Add HTTP request OTA update system from R_PRO-1 Port the managed firmware update system from R_PRO-1 to all AIR-1 variants: - Add http_request OTA platform alongside the existing esphome OTA - Add update component pulling the firmware manifest from GitHub Pages - Add safe_mode for recovery from failed updates - Build and publish the BLE variant (firmware-ble) so BLE devices update from their own manifest instead of being converted to the factory firmware --- .github/workflows/build.yml | 3 ++- Integrations/ESPHome/AIR-1.yaml | 13 +++++++++++++ Integrations/ESPHome/AIR-1_BLE.yaml | 13 +++++++++++++ Integrations/ESPHome/AIR-1_Factory.yaml | 13 +++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a97fdfe..cc32322 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,8 @@ jobs: device-name: air-1 yaml-files: | Integrations/ESPHome/AIR-1_Factory.yaml - firmware-names: "1_Factory:firmware" + Integrations/ESPHome/AIR-1_BLE.yaml + firmware-names: "1_Factory:firmware,1_BLE:firmware-ble" core-yaml-path: Integrations/ESPHome/Core.yaml esphome-version: stable # Bypass check if manually triggered with bypass option diff --git a/Integrations/ESPHome/AIR-1.yaml b/Integrations/ESPHome/AIR-1.yaml index 454b4ce..33366e2 100644 --- a/Integrations/ESPHome/AIR-1.yaml +++ b/Integrations/ESPHome/AIR-1.yaml @@ -38,6 +38,19 @@ ota: - platform: esphome id: ota_esphome password: ${ota_password} + - platform: http_request + id: ota_managed + +http_request: + verify_ssl: true + +safe_mode: + +update: + - platform: http_request + id: update_http_request + name: Firmware Update + source: https://apolloautomation.github.io/AIR-1/firmware/manifest.json wifi: ap: diff --git a/Integrations/ESPHome/AIR-1_BLE.yaml b/Integrations/ESPHome/AIR-1_BLE.yaml index 424fd2f..0190710 100644 --- a/Integrations/ESPHome/AIR-1_BLE.yaml +++ b/Integrations/ESPHome/AIR-1_BLE.yaml @@ -35,6 +35,19 @@ ota: - platform: esphome password: ${ota_password} id: ota_esphome + - platform: http_request + id: ota_managed + +http_request: + verify_ssl: true + +safe_mode: + +update: + - platform: http_request + id: update_http_request + name: Firmware Update + source: https://apolloautomation.github.io/AIR-1/firmware-ble/manifest.json bluetooth_proxy: active: true diff --git a/Integrations/ESPHome/AIR-1_Factory.yaml b/Integrations/ESPHome/AIR-1_Factory.yaml index 0133d5c..09eaf1a 100644 --- a/Integrations/ESPHome/AIR-1_Factory.yaml +++ b/Integrations/ESPHome/AIR-1_Factory.yaml @@ -44,6 +44,19 @@ logger: ota: - platform: esphome id: ota_esphome + - platform: http_request + id: ota_managed + +http_request: + verify_ssl: true + +safe_mode: + +update: + - platform: http_request + id: update_http_request + name: Firmware Update + source: https://apolloautomation.github.io/AIR-1/firmware/manifest.json esp32_improv: authorizer: none From eb0ef6cdde0f6547cf7061116f37aa62cc90a47f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:52:55 -0500 Subject: [PATCH 06/24] Check for updates when WiFi connects The http_request update component polls every 6h and the first poll fires before the network is up, so a freshly booted device would not see an available update for 6 hours. Trigger a manifest check as soon as WiFi connects. --- Integrations/ESPHome/AIR-1.yaml | 2 ++ Integrations/ESPHome/AIR-1_BLE.yaml | 2 ++ Integrations/ESPHome/AIR-1_Factory.yaml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/Integrations/ESPHome/AIR-1.yaml b/Integrations/ESPHome/AIR-1.yaml index 33366e2..50bfd86 100644 --- a/Integrations/ESPHome/AIR-1.yaml +++ b/Integrations/ESPHome/AIR-1.yaml @@ -53,6 +53,8 @@ update: source: https://apolloautomation.github.io/AIR-1/firmware/manifest.json wifi: + on_connect: + - component.update: update_http_request ap: ssid: "Apollo AIR1 Hotspot" diff --git a/Integrations/ESPHome/AIR-1_BLE.yaml b/Integrations/ESPHome/AIR-1_BLE.yaml index 0190710..40c5dbc 100644 --- a/Integrations/ESPHome/AIR-1_BLE.yaml +++ b/Integrations/ESPHome/AIR-1_BLE.yaml @@ -56,6 +56,8 @@ esp32_ble_tracker: active: false wifi: + on_connect: + - component.update: update_http_request ap: ssid: "Apollo AIR1 Hotspot" diff --git a/Integrations/ESPHome/AIR-1_Factory.yaml b/Integrations/ESPHome/AIR-1_Factory.yaml index 09eaf1a..42c7ae4 100644 --- a/Integrations/ESPHome/AIR-1_Factory.yaml +++ b/Integrations/ESPHome/AIR-1_Factory.yaml @@ -62,6 +62,8 @@ esp32_improv: authorizer: none wifi: + on_connect: + - component.update: update_http_request ap: ssid: "Apollo AIR1 Hotspot" From 3330a7975b2352ba164d4e48d73db56da0ea6892 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:52:55 -0500 Subject: [PATCH 07/24] Raise min_version to 2025.11.0 to match R_PRO-1's update-system floor --- Integrations/ESPHome/AIR-1.yaml | 2 +- Integrations/ESPHome/AIR-1_BLE.yaml | 2 +- Integrations/ESPHome/AIR-1_Factory.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Integrations/ESPHome/AIR-1.yaml b/Integrations/ESPHome/AIR-1.yaml index 50bfd86..479748c 100644 --- a/Integrations/ESPHome/AIR-1.yaml +++ b/Integrations/ESPHome/AIR-1.yaml @@ -7,7 +7,7 @@ esphome: name: "ApolloAutomation.AIR-1" version: "${version}" - min_version: 2025.2.0 + min_version: 2025.11.0 on_boot: priority: 500 then: diff --git a/Integrations/ESPHome/AIR-1_BLE.yaml b/Integrations/ESPHome/AIR-1_BLE.yaml index 40c5dbc..2f3ae53 100644 --- a/Integrations/ESPHome/AIR-1_BLE.yaml +++ b/Integrations/ESPHome/AIR-1_BLE.yaml @@ -7,7 +7,7 @@ esphome: name: "ApolloAutomation.AIR-1" version: "${version}" - min_version: 2025.2.0 + min_version: 2025.11.0 on_boot: priority: 500 then: diff --git a/Integrations/ESPHome/AIR-1_Factory.yaml b/Integrations/ESPHome/AIR-1_Factory.yaml index 42c7ae4..e3d1025 100644 --- a/Integrations/ESPHome/AIR-1_Factory.yaml +++ b/Integrations/ESPHome/AIR-1_Factory.yaml @@ -7,7 +7,7 @@ esphome: name: "ApolloAutomation.AIR-1" version: "${version}" - min_version: 2025.2.0 + min_version: 2025.11.0 on_boot: - priority: 500 then: From d3cf3923239d92613ad129b75467bd4a837469b5 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:33:54 -0500 Subject: [PATCH 08/24] Release notes: compact formatting for the HA update dialog Home Assistant shows only the first 255 characters of an ESPHome release summary, so boilerplate is expensive and ## headings render oversized in the update dialog: - Render category titles and What's Changed in bold instead of H2 - Drop the star-the-repo footer - Drop the Full Changelog line: it semver-truncates 4-part versions (always links ...X.Y.Z.1) - the shared build workflow now appends a correct compare link instead --- .github/release-drafter.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 8d00377..dc3f169 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -2,6 +2,7 @@ name-template: 'Release v$NEXT_PATCH_VERSION' tag-template: "$RESOLVED_VERSION" change-template: "- #$NUMBER $TITLE @$AUTHOR" sort-direction: ascending +category-template: '**$TITLE**' categories: - title: "🚨 Breaking changes" @@ -26,11 +27,9 @@ include-labels: no-changes-template: '- No changes' +# The shared build workflow appends a Full Changelog compare link to the +# release body (release-drafter cannot render 4-part version tags). template: | - ## What's Changed + **What's Changed** $CHANGES - - **Full Changelog**: https://github.com/ApolloAutomation/AIR-1/compare/$PREVIOUS_TAG...$RESOLVED_VERSION.1 - - Be sure to 🌟 this repository for updates! \ No newline at end of file From cc1b87c97e63968bc58cff6955bed7be6146047e Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:25:46 -0500 Subject: [PATCH 09/24] Add air quality LED indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive the AIR-1's onboard RGB LEDs from air-quality readings. Everything lives in Core.yaml, so all variants pick it up via the core package. - "Air Quality LED Source" select (Off / NowCast AQI / CO2 / VOC Index), default Off. A selected source colors the LEDs on a six-step green->maroon severity scale; thresholds match each metric's range, and the VOC bands line up with the VOC Quality text sensor. - "Air Quality LED Brightness" slider (5-100%, default 100%). Event-driven: each source sensor's on_value fires the updater only when it is the selected source, so the LED tracks readings at their real cadence with no polling. Selecting Off clears the LED once, then leaves it for manual use. A millis() guard skips the early-boot window so the select's restore-publish can't write the light before it is initialized, and the updater defers while statusCheck/testScript own the LED. An unavailable source (NaN, e.g. CO2 with no module) clears the LED instead of showing a stale color. Bumps version to 26.6.25.1. Verified on hardware and with a full esphome 2026.6.2 compile. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 122 ++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index df97fdd..6c84cb9 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,6 +1,6 @@ substitutions: name: apollo-air-1 - version: "26.6.10.1" + version: "26.6.25.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 _ota_password }` so each @@ -84,6 +84,23 @@ deep_sleep: run_duration: 2min number: + - platform: template + name: "Air Quality LED Brightness" + id: air_quality_led_brightness + icon: mdi:brightness-6 + optimistic: true + restore_value: true + entity_category: "config" + unit_of_measurement: "%" + min_value: 5 + max_value: 100 + step: 1 + initial_value: 100 + mode: slider + on_value: + then: + - script.execute: update_air_quality_led + - platform: template name: SEN55 Temperature Offset id: sen55_temperature_offset @@ -221,6 +238,12 @@ sensor: co2: name: "CO2" id: "co2" + on_value: + - if: + condition: + lambda: 'return id(air_quality_led_source).current_option() == "CO2";' + then: + - script.execute: update_air_quality_led automatic_self_calibration: true update_interval: 60s measurement_mode: "periodic" @@ -274,7 +297,12 @@ sensor: voc: name: "SEN55 VOC" id: sen55_voc - + on_value: + - if: + condition: + lambda: 'return id(air_quality_led_source).current_option() == "VOC Index";' + then: + - script.execute: update_air_quality_led algorithm_tuning: #https://sensirion.com/media/documents/25AB572C/62B463AA/Sensirion_Engineering_Guidelines_SEN5x.pdf index_offset: 100 @@ -338,6 +366,12 @@ sensor: pm_10_0: pm_10_0 calculation_type: AQI device_class: aqi + on_value: + - if: + condition: + lambda: 'return id(air_quality_led_source).current_option() == "NowCast AQI";' + then: + - script.execute: update_air_quality_led - platform: mics_4514 id: mics4514 @@ -488,6 +522,20 @@ switch: id: setCo2AutoCalibration enable: false +select: + - platform: template + name: "Air Quality LED Source" + id: air_quality_led_source + icon: mdi:led-on + optimistic: true + restore_value: true + entity_category: "config" + options: ["Off", "NowCast AQI", "CO2", "VOC Index"] + initial_option: "Off" + on_value: + then: + - script.execute: update_air_quality_led + script: - id: setCo2AutoCalibration mode: restart @@ -635,3 +683,73 @@ script: - component.update: pm2_5_to_4 - component.update: pm4_to_10 - component.update: voc_quality + + - id: update_air_quality_led + then: + - lambda: |- + // The select's restore_value publishes during component setup and + // fires on_value before rgb_light (esp32_rmt_led_strip) is + // initialized; touching the light then faults in schedule_show(). + // Don't write the LED in the first few seconds after boot. The + // sensor/select/brightness triggers all run normally after that. + if (millis() < 5000) { + return; + } + + // While statusCheck or testScript owns the LED (button-press status + // flash, boot self-test), leave rgb_light alone so this updater + // doesn't override their colors mid-display. + if (id(statusCheck).is_running() || id(testScript).is_running()) { + return; + } + + const std::string src = id(air_quality_led_source).current_option(); + + if (src == "Off") { + id(rgb_light).turn_off().perform(); + return; + } + + // Map the selected metric to a severity band: 0=Good … 5=Hazardous. + // Each metric has its own scale; bands share one color table below. + int band = -1; + if (src == "NowCast AQI") { // US EPA AQI + const float x = id(nowcast_aqi).state; + if (isnan(x)) { id(rgb_light).turn_off().perform(); return; } // no sample yet (e.g. missing module); clear stale color + if (x <= 50) band = 0; + else if (x <= 100) band = 1; + else if (x <= 150) band = 2; + else if (x <= 200) band = 3; + else if (x <= 300) band = 4; + else band = 5; + } else if (src == "CO2") { // ppm, SCD40 + const float x = id(co2).state; + if (isnan(x)) { id(rgb_light).turn_off().perform(); return; } // no sample yet (e.g. missing module); clear stale color + if (x <= 800) band = 0; + else if (x <= 1000) band = 1; + else if (x <= 1500) band = 2; + else if (x <= 2000) band = 3; + else if (x <= 2500) band = 4; + else band = 5; + } else if (src == "VOC Index") { // Sensirion VOC index (matches "VOC Quality" text sensor) + const float x = id(sen55_voc).state; + if (isnan(x)) { id(rgb_light).turn_off().perform(); return; } // no sample yet (e.g. missing module); clear stale color + if (x < 80) band = 0; + else if (x < 150) band = 1; + else if (x < 250) band = 2; + else if (x < 400) band = 3; + else band = 4; // VOC scale tops out at "extremely abnormal" + } + if (band < 0) return; + + auto call = id(rgb_light).turn_on(); + call.set_brightness(id(air_quality_led_brightness).state / 100.0f); + switch (band) { + case 0: call.set_rgb(0.0f, 1.0f, 0.0f); break; // Green - Good + case 1: call.set_rgb(1.0f, 1.0f, 0.0f); break; // Yellow - Moderate + case 2: call.set_rgb(1.0f, 0.5f, 0.0f); break; // Orange - Unhealthy (Sensitive) + case 3: call.set_rgb(1.0f, 0.0f, 0.0f); break; // Red - Unhealthy + case 4: call.set_rgb(0.6f, 0.0f, 1.0f); break; // Purple - Very Unhealthy + case 5: call.set_rgb(0.5f, 0.0f, 0.0f); break; // Maroon - Hazardous + } + call.perform(); From 625733788c629ac7b49084d5944643a19c25eea7 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:38:41 -0500 Subject: [PATCH 10/24] Restore air quality LED after status/test scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit statusCheck and testScript both end by turning rgb_light off, and update_air_quality_led defers while either is running, so after a button status flash or the boot self-test the air-quality color stays gone until the next sensor on_value (up to 60s with CO2 as the source). Add a restore_air_quality_led helper (mode: restart) that delays 200ms then executes update_air_quality_led, called as the last action of both scripts. The delay lets the calling script finish first so update_air_quality_led's is_running() guard passes. Bump version to 26.7.1.1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 6c84cb9..1250e1d 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,6 +1,6 @@ substitutions: name: apollo-air-1 - version: "26.6.25.1" + version: "26.7.1.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 _ota_password }` so each @@ -634,6 +634,8 @@ script: - light.turn_off: id: rgb_light + - script.execute: restore_air_quality_led + - id: statusCheck then: - if: @@ -669,6 +671,7 @@ script: blue: 0% - delay: 5s - light.turn_off: rgb_light + - script.execute: restore_air_quality_led - id: reportAllValues then: - component.update: sen55 @@ -753,3 +756,15 @@ script: case 5: call.set_rgb(0.5f, 0.0f, 0.0f); break; // Maroon - Hazardous } call.perform(); + + # Runs after statusCheck/testScript finish so the air-quality color comes + # back right away instead of waiting for the next sensor on_value (up to 60s + # when CO2 is the source). statusCheck/testScript call this as their last + # action; the short delay lets the calling script finish first so + # update_air_quality_led's is_running() guard passes. mode: restart so a + # rapid second status flash doesn't stack pending restores. + - id: restore_air_quality_led + mode: restart + then: + - delay: 200ms + - script.execute: update_air_quality_led From e8fd0b09d9892e82377e3525fd5f4686ca612fb6 Mon Sep 17 00:00:00 2001 From: Greg Troxel Date: Tue, 7 Jul 2026 14:59:18 -0400 Subject: [PATCH 11/24] esphome.yaml: Tidy boot action whitespace Make the single action a list of one action, rather than an action not in a list. This is semantically identical, and greatly reduces diffs among the 3 config files. --- Integrations/ESPHome/AIR-1.yaml | 32 ++++++++++++++--------------- Integrations/ESPHome/AIR-1_BLE.yaml | 26 +++++++++++------------ 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Integrations/ESPHome/AIR-1.yaml b/Integrations/ESPHome/AIR-1.yaml index 479748c..fd8a28b 100644 --- a/Integrations/ESPHome/AIR-1.yaml +++ b/Integrations/ESPHome/AIR-1.yaml @@ -9,22 +9,22 @@ esphome: min_version: 2025.11.0 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 diff --git a/Integrations/ESPHome/AIR-1_BLE.yaml b/Integrations/ESPHome/AIR-1_BLE.yaml index 2f3ae53..3e27cb0 100644 --- a/Integrations/ESPHome/AIR-1_BLE.yaml +++ b/Integrations/ESPHome/AIR-1_BLE.yaml @@ -9,19 +9,19 @@ esphome: min_version: 2025.11.0 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: + - 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(); on_shutdown: - light.turn_off: rgb_light From 919ab0eea40277ae9ea8ff7397fa426ab9177810 Mon Sep 17 00:00:00 2001 From: Greg Troxel Date: Tue, 7 Jul 2026 14:37:18 -0400 Subject: [PATCH 12/24] esphome.yaml: Regularize (whitespace, ordering) Adjust ordering of esphome yaml, and regularize whitespace. This commit makes no functional changes, and serves only to reduce the diffs from the base config to the BLE and Factory variants. --- Integrations/ESPHome/AIR-1_BLE.yaml | 7 ++++--- Integrations/ESPHome/AIR-1_Factory.yaml | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Integrations/ESPHome/AIR-1_BLE.yaml b/Integrations/ESPHome/AIR-1_BLE.yaml index 2f3ae53..ba8af60 100644 --- a/Integrations/ESPHome/AIR-1_BLE.yaml +++ b/Integrations/ESPHome/AIR-1_BLE.yaml @@ -25,16 +25,16 @@ esphome: on_shutdown: - light.turn_off: rgb_light -logger: - dashboard_import: package_import_url: github://ApolloAutomation/AIR-1/Integrations/ESPHome/AIR-1_BLE.yaml import_full_config: false +logger: + ota: - platform: esphome - password: ${ota_password} id: ota_esphome + password: ${ota_password} - platform: http_request id: ota_managed @@ -51,6 +51,7 @@ update: bluetooth_proxy: active: true + esp32_ble_tracker: scan_parameters: active: false diff --git a/Integrations/ESPHome/AIR-1_Factory.yaml b/Integrations/ESPHome/AIR-1_Factory.yaml index e3d1025..0a052a3 100644 --- a/Integrations/ESPHome/AIR-1_Factory.yaml +++ b/Integrations/ESPHome/AIR-1_Factory.yaml @@ -32,13 +32,10 @@ esphome: on_shutdown: - light.turn_off: rgb_light - dashboard_import: package_import_url: github://ApolloAutomation/AIR-1/Integrations/ESPHome/AIR-1_Factory.yaml import_full_config: false -improv_serial: - logger: ota: @@ -58,6 +55,8 @@ update: name: Firmware Update source: https://apolloautomation.github.io/AIR-1/firmware/manifest.json +improv_serial: + esp32_improv: authorizer: none From a1b9bf00af12081520a4b541bd8c65fc609b024e Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:44:43 -0500 Subject: [PATCH 13/24] Port MSR-1 unified firmware: runtime Bluetooth Proxy switch + channel-only OTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One firmware image per channel replaces the build-time _BLE split: bluetooth_proxy + esp32_ble_tracker compile into every variant and a "Bluetooth Proxy" switch (default off, persisted) starts/stops scanning at runtime. The Firmware Channel select swaps the OTA manifest URL directly - no ble_firmware selector, no manifest-matching guard, no pre-OTA BLE disable (ESPHome's OTA quiesces BLE itself). - Firmware Update button holds prevent_deep_sleep for the download and re-arms sleep on the not-started path (unless ota_mode/Prevent Sleep is holding the device awake) - http_request consolidated into Core.yaml with the proven buffer sizes (rx 5120 for GitHub's ~3.6KB CSP header line, tx 2048 for the ~850-char signed-redirect query) - apply_ota_source is channel-only; AIR-1_BLE.yaml keeps legacy devices on the firmware-ble manifests via substitution overrides - beta-channel/ wrappers default the select to Beta; build-beta.yml publishes manifest-standard.json + manifest-ble.json to the rolling beta-fw pre-release (absolute URLs) - build.yml now serves the end-user AIR-1.yaml image at firmware/ and moves the improv Factory image to firmware-factory/ (installer page repointed) - version 26.7.12.1 Ported from MSR-1 26.7.9.1 (ApolloAutomation/MSR-1 #100, #103, #104). Supersedes #107 and #110. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .github/workflows/build-beta.yml | 105 +++++++++++++++ .github/workflows/build.yml | 5 +- Integrations/ESPHome/AIR-1.yaml | 3 - Integrations/ESPHome/AIR-1_BLE.yaml | 15 +-- Integrations/ESPHome/AIR-1_Factory.yaml | 3 - Integrations/ESPHome/Core.yaml | 122 +++++++++++++++++- Integrations/ESPHome/beta-channel/AIR-1.yaml | 9 ++ .../ESPHome/beta-channel/AIR-1_BLE.yaml | 9 ++ static/index.html | 2 +- 9 files changed, 254 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/build-beta.yml create mode 100644 Integrations/ESPHome/beta-channel/AIR-1.yaml create mode 100644 Integrations/ESPHome/beta-channel/AIR-1_BLE.yaml diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml new file mode 100644 index 0000000..6a1d584 --- /dev/null +++ b/.github/workflows/build-beta.yml @@ -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-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 + # (AIR-1.yaml), not the first-flash Factory image. + - { yaml: Integrations/ESPHome/beta-channel/AIR-1.yaml, name: firmware-standard } + - { yaml: Integrations/ESPHome/beta-channel/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-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 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-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." diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cc32322..65772a2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,10 +24,13 @@ jobs: pull-requests: write with: device-name: air-1 + # AIR-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/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 diff --git a/Integrations/ESPHome/AIR-1.yaml b/Integrations/ESPHome/AIR-1.yaml index fd8a28b..faaab6b 100644 --- a/Integrations/ESPHome/AIR-1.yaml +++ b/Integrations/ESPHome/AIR-1.yaml @@ -41,9 +41,6 @@ ota: - platform: http_request id: ota_managed -http_request: - verify_ssl: true - safe_mode: update: diff --git a/Integrations/ESPHome/AIR-1_BLE.yaml b/Integrations/ESPHome/AIR-1_BLE.yaml index 83c1774..4b3461d 100644 --- a/Integrations/ESPHome/AIR-1_BLE.yaml +++ b/Integrations/ESPHome/AIR-1_BLE.yaml @@ -1,3 +1,8 @@ +substitutions: + # Legacy BLE devices keep updating from the firmware-ble manifest. + ota_stable_manifest: "https://apolloautomation.github.io/AIR-1/firmware-ble/manifest.json" + ota_beta_manifest: "https://github.com/ApolloAutomation/AIR-1/releases/download/beta-fw/manifest-ble.json" + esphome: name: "${name}" friendly_name: Apollo AIR-1 @@ -38,9 +43,6 @@ ota: - platform: http_request id: ota_managed -http_request: - verify_ssl: true - safe_mode: update: @@ -49,13 +51,6 @@ update: name: Firmware Update source: https://apolloautomation.github.io/AIR-1/firmware-ble/manifest.json -bluetooth_proxy: - active: true - -esp32_ble_tracker: - scan_parameters: - active: false - wifi: on_connect: - component.update: update_http_request diff --git a/Integrations/ESPHome/AIR-1_Factory.yaml b/Integrations/ESPHome/AIR-1_Factory.yaml index 0a052a3..f609248 100644 --- a/Integrations/ESPHome/AIR-1_Factory.yaml +++ b/Integrations/ESPHome/AIR-1_Factory.yaml @@ -44,9 +44,6 @@ ota: - platform: http_request id: ota_managed -http_request: - verify_ssl: true - safe_mode: update: diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 1250e1d..5fbd295 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,11 +1,45 @@ substitutions: name: apollo-air-1 - version: "26.7.1.1" + version: "26.7.12.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 _ota_password }` so each # device on your network uses a unique secret instead of the shared default. ota_password: "apolloautomation" + # 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/AIR-1" + beta_manifest_base: "https://github.com/ApolloAutomation/AIR-1/releases/download/beta-fw" + # Per-variant OTA manifest URLs. Core defaults to the unified (standard) + # image; AIR-1_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 @@ -13,6 +47,24 @@ esp32: framework: type: esp-idf +esp32_ble_tracker: + id: ble_tracker + scan_parameters: + continuous: true + +bluetooth_proxy: + +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 + api: on_client_connected: - delay: 90s @@ -442,6 +494,34 @@ 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 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(); + text_sensor: # Convert VOC Index To Text: # https://sensirion.com/media/documents/02232963/6294E043/Info_Note_VOC_Index.pdf @@ -522,6 +602,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: + select: - platform: template name: "Air Quality LED Source" @@ -536,7 +629,34 @@ 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: "${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 + # AIR-1_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: diff --git a/Integrations/ESPHome/beta-channel/AIR-1.yaml b/Integrations/ESPHome/beta-channel/AIR-1.yaml new file mode 100644 index 0000000..ddc70f9 --- /dev/null +++ b/Integrations/ESPHome/beta-channel/AIR-1.yaml @@ -0,0 +1,9 @@ +# Beta-channel build of AIR-1.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 AIR-1.yaml directly. +substitutions: + firmware_channel_default: "Beta" + +packages: + base: !include ../AIR-1.yaml diff --git a/Integrations/ESPHome/beta-channel/AIR-1_BLE.yaml b/Integrations/ESPHome/beta-channel/AIR-1_BLE.yaml new file mode 100644 index 0000000..3472fdc --- /dev/null +++ b/Integrations/ESPHome/beta-channel/AIR-1_BLE.yaml @@ -0,0 +1,9 @@ +# Beta-channel build of AIR-1_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 AIR-1_BLE.yaml directly. +substitutions: + firmware_channel_default: "Beta" + +packages: + base: !include ../AIR-1_BLE.yaml diff --git a/static/index.html b/static/index.html index 0235fd5..99a5cfd 100644 --- a/static/index.html +++ b/static/index.html @@ -84,7 +84,7 @@

Apollo AIR-1 Installer

AIR-1 Firmware

- +
From b134014c222475bc6f4c564a5a087c15a8618d1f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:06:44 -0500 Subject: [PATCH 14/24] Auto-assign on fork PRs via pull_request_target --- .github/workflows/autoassign.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/autoassign.yml b/.github/workflows/autoassign.yml index 77bef44..03b36f5 100644 --- a/.github/workflows/autoassign.yml +++ b/.github/workflows/autoassign.yml @@ -1,9 +1,12 @@ name: Auto Assign +# pull_request_target (not pull_request) so auto-assign works on +# fork-submitted PRs; fork pull_request runs only get a read-only token. +# Safe because this workflow never checks out or executes PR code. on: issues: types: [opened] - pull_request: + pull_request_target: types: [opened] permissions: @@ -12,8 +15,6 @@ permissions: jobs: auto-assign: - # Skip auto-assign for pull requests from forks due to GITHUB_TOKEN permission restrictions - if: github.event_name == 'issues' || github.event.pull_request.head.repo.full_name == github.repository uses: ApolloAutomation/Workflows/.github/workflows/autoassign.yml@main with: assignees: bharvey88 From 727b1929c8459b1ecf990202a36bfff7691c0a48 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:45:31 -0500 Subject: [PATCH 15/24] Enable API encryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an empty encryption: key to the api: block so ESPHome/HA provisions a per-device API key on adoption, matching MSR-1. Bump firmware version to 26.7.14.1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 5fbd295..601d43e 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,6 +1,6 @@ substitutions: name: apollo-air-1 - version: "26.7.12.1" + version: "26.7.14.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 _ota_password }` so each @@ -66,6 +66,7 @@ http_request: buffer_size_tx: 2048 api: + encryption: on_client_connected: - delay: 90s - if: From fd7780a73031c5a5a1ec9712a335096e3811b325 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:46:49 -0500 Subject: [PATCH 16/24] Force sen5x model to SEN55 to keep NOx enabled on misreporting units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some AIR-1 units report SEN54 on the sen5x product name register even though the NOx element is present and working, which makes ESPHome disable the NOx sensor ("NOx requires a SEN55"). Pull the sen5x component with a model override from bharvey88/esphome and set model: sen55 so NOx stays enabled. Temporary until the model option lands in upstream esphome. Bump version to 26.7.21.1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 1250e1d..cb93a0e 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,6 +1,6 @@ substitutions: name: apollo-air-1 - version: "26.7.1.1" + version: "26.7.21.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 _ota_password }` so each @@ -13,6 +13,15 @@ esp32: framework: type: esp-idf +# Temporary: sen5x with a model override, pending the same option landing in +# upstream esphome. Some AIR-1 units report SEN54 on the product name register +# even though the NOx element is present and functional; model: sen55 below +# keeps NOx enabled on those units. +external_components: + - source: github://bharvey88/esphome@sen5x-model-override + components: [sen5x, sensirion_common] + refresh: 1d + api: on_client_connected: - delay: 90s @@ -316,6 +325,7 @@ sensor: id: sen55_nox acceleration_mode: low store_baseline: true + model: sen55 address: 0x69 update_interval: 10s From 1ab9ff297d8dc681e937bbdceb5658fdba8c37ee Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:57:27 -0500 Subject: [PATCH 17/24] Point sen5x override at ApolloAutomation/esphome-components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host the component override under the org instead of a personal fork. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index cb93a0e..f2fab66 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -18,7 +18,7 @@ esp32: # even though the NOx element is present and functional; model: sen55 below # keeps NOx enabled on those units. external_components: - - source: github://bharvey88/esphome@sen5x-model-override + - source: github://ApolloAutomation/esphome-components@main components: [sen5x, sensirion_common] refresh: 1d From d00eb7227483e3e8f0d77ba17902a17fe899981b Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:16:03 -0500 Subject: [PATCH 18/24] Use core sen5x model option, require esphome 2026.7.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model option merged upstream (esphome/esphome#17764, milestoned 2026.7.2), so drop the temporary external_components pull and use the core component. Bump min_version to 2026.7.2. CI stays red on the stable and beta legs until 2026.7.2 releases; merge after that. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/AIR-1.yaml | 2 +- Integrations/ESPHome/AIR-1_BLE.yaml | 2 +- Integrations/ESPHome/AIR-1_Factory.yaml | 2 +- Integrations/ESPHome/Core.yaml | 12 +++--------- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/Integrations/ESPHome/AIR-1.yaml b/Integrations/ESPHome/AIR-1.yaml index fd8a28b..bba2310 100644 --- a/Integrations/ESPHome/AIR-1.yaml +++ b/Integrations/ESPHome/AIR-1.yaml @@ -7,7 +7,7 @@ esphome: name: "ApolloAutomation.AIR-1" version: "${version}" - min_version: 2025.11.0 + min_version: 2026.7.2 on_boot: - priority: 500 then: diff --git a/Integrations/ESPHome/AIR-1_BLE.yaml b/Integrations/ESPHome/AIR-1_BLE.yaml index 83c1774..a2e3918 100644 --- a/Integrations/ESPHome/AIR-1_BLE.yaml +++ b/Integrations/ESPHome/AIR-1_BLE.yaml @@ -7,7 +7,7 @@ esphome: name: "ApolloAutomation.AIR-1" version: "${version}" - min_version: 2025.11.0 + min_version: 2026.7.2 on_boot: - priority: 500 then: diff --git a/Integrations/ESPHome/AIR-1_Factory.yaml b/Integrations/ESPHome/AIR-1_Factory.yaml index 0a052a3..212944b 100644 --- a/Integrations/ESPHome/AIR-1_Factory.yaml +++ b/Integrations/ESPHome/AIR-1_Factory.yaml @@ -7,7 +7,7 @@ esphome: name: "ApolloAutomation.AIR-1" version: "${version}" - min_version: 2025.11.0 + min_version: 2026.7.2 on_boot: - priority: 500 then: diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index f2fab66..ebd88c0 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -13,15 +13,6 @@ esp32: framework: type: esp-idf -# Temporary: sen5x with a model override, pending the same option landing in -# upstream esphome. Some AIR-1 units report SEN54 on the product name register -# even though the NOx element is present and functional; model: sen55 below -# keeps NOx enabled on those units. -external_components: - - source: github://ApolloAutomation/esphome-components@main - components: [sen5x, sensirion_common] - refresh: 1d - api: on_client_connected: - delay: 90s @@ -325,6 +316,9 @@ sensor: id: sen55_nox acceleration_mode: low store_baseline: true + # Some units misreport SEN54 on the product name register even though the + # NOx element is present; forcing the model keeps NOx enabled. Needs the + # model option from esphome 2026.7.2. model: sen55 address: 0x69 update_interval: 10s From 050da4cc033d6a0a0f237abfabeb5e1a0c938d0b Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:23:16 -0500 Subject: [PATCH 19/24] Bump version to 26.7.23.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit esphome 2026.7.2 is released, so the core model option this PR requires is now available on stable. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index ebd88c0..7843b3c 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,6 +1,6 @@ substitutions: name: apollo-air-1 - version: "26.7.21.1" + version: "26.7.23.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 _ota_password }` so each From b83a4a5d2c34f300acd588d8f8ac357bcfc3c10c Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:14:43 -0500 Subject: [PATCH 20/24] Bump version to 26.7.23.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main merge adds the firmware version publish fix (#96) to the BLE and Factory variants, so the built firmware changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 1c2f8e8..73041b0 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,6 +1,6 @@ substitutions: name: apollo-air-1 - version: "26.7.23.1" + version: "26.7.23.2" device_description: ${name} made by Apollo Automation - version ${version}. # Default OTA password. Override in your device YAML by re-declaring # `substitutions: { ota_password: !secret _ota_password }` so each From 53784c6935ff7f5da38ae807a1a780b1ce6b23c7 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:17:59 -0500 Subject: [PATCH 21/24] Keep version at 26.7.23.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 73041b0..1c2f8e8 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,6 +1,6 @@ substitutions: name: apollo-air-1 - version: "26.7.23.2" + version: "26.7.23.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 _ota_password }` so each From c093926636963594365f6732b0f99813251be9cd Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:58:40 -0500 Subject: [PATCH 22/24] Re-point beta-fw tag on every beta publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gh release create tagged default-branch HEAD once and uploads never move the tag, so the release source commit drifted away from the published assets. PATCH the tag ref to the built commit after upload. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .github/workflows/build-beta.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 6a1d584..a6d11d0 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -103,3 +103,14 @@ jobs: gh release upload beta-fw {} -R "${{ github.repository }}" --clobber \; done 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 }}" From d9a084aafae5cde89d5c250a756023e3ddc54489 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:04:14 -0500 Subject: [PATCH 23/24] Pin shared workflows to a commit SHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reusable workflows from ApolloAutomation/Workflows ran from the mutable main ref, including in pull_request_target contexts with write tokens. Pin all five references to the current reviewed commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .github/workflows/autoassign.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/label-check.yml | 2 +- .github/workflows/weekly.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/autoassign.yml b/.github/workflows/autoassign.yml index be0b7f4..6f34643 100644 --- a/.github/workflows/autoassign.yml +++ b/.github/workflows/autoassign.yml @@ -16,7 +16,7 @@ jobs: auto-assign: # Skip auto-assign for pull requests from forks due to GITHUB_TOKEN permission restrictions if: github.event_name == 'issues' || github.event.pull_request.head.repo.full_name == github.repository - uses: ApolloAutomation/Workflows/.github/workflows/autoassign.yml@main + uses: ApolloAutomation/Workflows/.github/workflows/autoassign.yml@430d90dc695c6f7d1075c4e4a0df4b13a6496252 # main 2026-07-23 with: assignees: bharvey88 num-of-assignees: 1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 65772a2..2ddfc5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,7 @@ on: jobs: build-and-publish: - uses: ApolloAutomation/Workflows/.github/workflows/build.yml@main + uses: ApolloAutomation/Workflows/.github/workflows/build.yml@430d90dc695c6f7d1075c4e4a0df4b13a6496252 # main 2026-07-23 permissions: contents: write pages: write diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ef5150..42fe377 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ permissions: jobs: ci: name: CI - uses: ApolloAutomation/Workflows/.github/workflows/esphome-ci.yml@main + uses: ApolloAutomation/Workflows/.github/workflows/esphome-ci.yml@430d90dc695c6f7d1075c4e4a0df4b13a6496252 # main 2026-07-23 with: yaml-files: | Integrations/ESPHome/AIR-1.yaml diff --git a/.github/workflows/label-check.yml b/.github/workflows/label-check.yml index 96a9751..9315e38 100644 --- a/.github/workflows/label-check.yml +++ b/.github/workflows/label-check.yml @@ -17,4 +17,4 @@ permissions: jobs: label-check: name: Label Check - uses: ApolloAutomation/Workflows/.github/workflows/label-check.yml@main + uses: ApolloAutomation/Workflows/.github/workflows/label-check.yml@430d90dc695c6f7d1075c4e4a0df4b13a6496252 # main 2026-07-23 diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index 9f8d09a..cd576ca 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -10,7 +10,7 @@ permissions: jobs: build: name: Weekly Build - uses: ApolloAutomation/Workflows/.github/workflows/esphome-ci.yml@main + uses: ApolloAutomation/Workflows/.github/workflows/esphome-ci.yml@430d90dc695c6f7d1075c4e4a0df4b13a6496252 # main 2026-07-23 with: yaml-files: | Integrations/ESPHome/AIR-1.yaml From 9890f41d6535604970bc5f5ac9249b92779434fd Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:04:51 -0500 Subject: [PATCH 24/24] Fix BLE deep sleep duration 60x too long MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sleep Duration number is minutes, but the BLE on_boot restore converted it with a 60 * 60 * 1000 multiplier, treating minutes as hours. A 5 minute setting slept 5 hours. Match Core and Factory. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/AIR-1_BLE.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Integrations/ESPHome/AIR-1_BLE.yaml b/Integrations/ESPHome/AIR-1_BLE.yaml index 89d4b0e..251a837 100644 --- a/Integrations/ESPHome/AIR-1_BLE.yaml +++ b/Integrations/ESPHome/AIR-1_BLE.yaml @@ -20,7 +20,7 @@ esphome: id: apollo_firmware_version state: "${version}" - lambda: |- - id(deep_sleep_1).set_sleep_duration(id(deep_sleep_sleep_duration).state * 60 * 60 * 1000); + id(deep_sleep_1).set_sleep_duration(id(deep_sleep_sleep_duration).state * 60 * 1000); - if: condition: or: