-
Notifications
You must be signed in to change notification settings - Fork 12
Unified firmware: runtime Bluetooth Proxy switch + Stable/Beta channel OTA #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: beta
Are you sure you want to change the base?
Changes from all commits
5d4a6e8
230a74b
c023fa1
dd2239b
1e53c41
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| name: Build and Publish Beta | ||
|
|
||
| # Builds PLT-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 Minimal | ||
| # images, not the first-flash improv images. | ||
| - { yaml: Integrations/ESPHome/beta-channel/PLT-1_Minimal.yaml, name: firmware-beta } | ||
| - { yaml: Integrations/ESPHome/beta-channel/PLT-1B_Minimal.yaml, name: firmware-b-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 PLT-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" | ||
| # Manifest names match the on-device variant_slug: "" and "-b". | ||
| declare -A DIRS=( [""]=firmware-beta ["-b"]=firmware-b-beta ) | ||
| for v in "" "-b"; 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 | ||
| 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. | ||
| # The two variants have distinct device names, so bin filenames | ||
| # don't collide in the flat release-asset namespace. | ||
| jq --arg base "$BASE" ' | ||
| .builds[0].ota.path = ($base + "/" + (.builds[0].ota.path | sub(".*/"; ""))) | ||
| | .builds[0].parts |= map(.path = ($base + "/" + (.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." | ||
|
|
||
| - 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 }}" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,3 +3,4 @@ | |
| # You can modify this file to suit your needs. | ||
| /.esphome/ | ||
| /secrets.yaml | ||
| beta-channel/.esphome/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,41 @@ | ||
| substitutions: | ||
| version: "26.8.27.1" | ||
| # Firmware variant identity: overridden to "-b" by the PLT-1B images so | ||
| # each hardware variant tracks its own manifests. | ||
| variant_slug: "" | ||
| # 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/PLT-1" | ||
| beta_manifest_base: "https://github.com/ApolloAutomation/PLT-1/releases/download/beta-fw" | ||
| # Per-variant OTA manifest URLs picked by apply_ota_source. | ||
| ota_stable_manifest: "${stable_manifest_base}/firmware${variant_slug}/manifest.json" | ||
| ota_beta_manifest: "${beta_manifest_base}/manifest${variant_slug}.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: | ||
| # Point the update entity at the selected channel's manifest. | ||
| - priority: -100 | ||
| then: | ||
| - script.execute: apply_ota_source | ||
| # Re-apply the Bluetooth Proxy switch after all components set up, so BLE | ||
| # scanning matches the persisted switch (proxy stays off by default). | ||
| - priority: -300 | ||
| then: | ||
| - if: | ||
| condition: | ||
| switch.is_on: bluetooth_proxy_switch | ||
| then: | ||
| - esp32_ble_tracker.start_scan: | ||
| continuous: true | ||
| else: | ||
| - esp32_ble_tracker.stop_scan: | ||
|
|
||
| esp32: | ||
| variant: esp32c3 | ||
|
|
@@ -10,7 +46,15 @@ esp32: | |
| assertion_level: SILENT | ||
| enable_lwip_assert: false | ||
|
|
||
| esp32_ble_tracker: | ||
| id: ble_tracker | ||
| scan_parameters: | ||
| continuous: true | ||
|
|
||
| bluetooth_proxy: | ||
|
|
||
| api: | ||
| encryption: | ||
| actions: | ||
| - action: play_buzzer | ||
| variables: | ||
|
|
@@ -76,6 +120,17 @@ globals: | |
|
|
||
| captive_portal: | ||
|
|
||
| http_request: | ||
| verify_ssl: true | ||
| # GitHub release-asset downloads answer with a redirect carrying a | ||
| # ~3.6 KB Content-Security-Policy header; each header line must fit | ||
| # this buffer or the request fails with "HTTP_CLIENT: Out of buffer". | ||
| buffer_size_rx: 5120 | ||
| # The redirect target is a signed URL with a ~850-char query string; the | ||
| # follow-up request line must fit the TX buffer or esp_http_client_open | ||
| # fails with "Out of buffer" before sending anything. | ||
| buffer_size_tx: 2048 | ||
|
|
||
|
|
||
| i2c: | ||
| sda: GPIO1 | ||
|
|
@@ -339,6 +394,34 @@ button: | |
| icon: mdi:power-cycle | ||
| name: "ESP Reboot" | ||
|
|
||
| - platform: template | ||
| name: "Firmware Update" | ||
| id: update_firmware | ||
| icon: mdi:cloud-download | ||
| entity_category: "config" | ||
| on_press: | ||
| - logger.log: "Applying firmware update for the selected channel" | ||
| # OTA download needs the device awake for its whole duration. | ||
| - lambda: |- | ||
| id(deep_sleep_1).prevent_deep_sleep(); | ||
| - delay: 3s | ||
| - script.execute: apply_ota_source | ||
| - script.wait: apply_ota_source | ||
| # The manifest fetch runs in its own task; give it a fixed window to land | ||
| # (update.is_available stays false for same-version switches). | ||
| - delay: 5s | ||
| - lambda: id(update_http_request).perform(true); | ||
|
Comment on lines
+408
to
+413
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the configured update sequence.
rg -n -C 3 'apply_ota_source|component\.update: update_http_request|perform\(true\)|delay: 5s' \
Integrations/ESPHome/Core.yaml
# Inspect the ESPHome version-selection policy used by CI.
rg -n -C 2 'esphome-version:|uses: esphome/workflows' .github/workflows
# Verify the current upstream behavior that must also hold for CI's resolved ESPHome version.
tag="$(curl -fsSL https://api.github.com/repos/esphome/esphome/releases/latest | jq -r '.tag_name')"
curl -fsSL "https://raw.githubusercontent.com/esphome/esphome/${tag}/esphome/components/http_request/update/http_request_update.cpp" |
sed -n '69,85p;246,258p'Repository: ApolloAutomation/PLT-1 Length of output: 6202 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Core.yaml update component and sequence ---'
sed -n '380,425p;490,518p' Integrations/ESPHome/Core.yaml
printf '%s\n' '--- Upstream HttpRequestUpdate implementation ---'
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
tag="$(curl -fsSL https://api.github.com/repos/esphome/esphome/releases/latest | jq -r '.tag_name')"
curl -fsSL "https://raw.githubusercontent.com/esphome/esphome/${tag}/esphome/components/http_request/update/http_request_update.cpp" > "$tmp"
printf 'latest tag: %s\n' "$tag"
rg -n -C 8 'void HttpRequestUpdate::(update|perform|set_source_url|update_task)|update_info\.(firmware_url|md5)|update_task_handle_' "$tmp"
printf '%s\n' '--- Manifest fields and OTA defaults in repository ---'
rg -n -C 3 'firmware_url|md5|manifest|update_http_request|ota:' Integrations/ESPHome .githubRepository: ApolloAutomation/PLT-1 Length of output: 19813 🏁 Script executed: #!/bin/bash
set -euo pipefail
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
tag="$(curl -fsSL https://api.github.com/repos/esphome/esphome/releases/latest | jq -r '.tag_name')"
base="https://raw.githubusercontent.com/esphome/esphome/${tag}/esphome/components/http_request/update"
curl -fsSL "$base/http_request_update.cpp" > "$tmpdir/update.cpp"
curl -fsSL "$base/http_request_update.h" > "$tmpdir/update.h"
python3 - "$tmpdir/update.cpp" "$tmpdir/update.h" <<'PY'
import re, sys
cpp, header = (open(p, encoding="utf-8").read() for p in sys.argv[1:])
checks = {
"update launches a separate ESP32 task": r'xTaskCreate\(HttpRequestUpdate::update_task',
"task clears its handle only after deferred completion": r'update_task_handle_ = nullptr;',
"manifest errors return before update_info is replaced": r'if \(result->error_str != nullptr\).*?return;',
"perform force bypasses availability state": r'if \(this->state_ != update::UPDATE_STATE_AVAILABLE && !force\)',
"perform uses cached md5": r'set_md5\(this->update_info\.md5\)',
"perform uses cached firmware URL": r'set_url\(this->update_info\.firmware_url\)',
}
for label, pattern in checks.items():
source = cpp
ok = bool(re.search(pattern, source, re.S))
print(f"{label}: {'yes' if ok else 'no'}")
print("\nRelevant deferred task section:")
lines = cpp.splitlines()
for i, line in enumerate(lines, 1):
if 200 <= i <= 225:
print(f"{i}: {line}")
PYRepository: ApolloAutomation/PLT-1 Length of output: 1684 Wait for the selected manifest check before calling
Call 🤖 Prompt for AI Agents |
||
| # 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(); | ||
|
|
||
| - platform: factory_reset | ||
| disabled_by_default: True | ||
| name: "Factory Reset ESP" | ||
|
|
@@ -365,9 +448,24 @@ switch: | |
| condition: | ||
| binary_sensor.is_off: ota_mode | ||
| then: | ||
| - lambda: |- | ||
| - lambda: |- | ||
| id(deep_sleep_1).allow_deep_sleep(); | ||
|
|
||
| # Note: on this deep-sleep device the proxy only forwards while the device | ||
| # is awake - pair it with "Prevent Sleep" to use it continuously. | ||
| - platform: template | ||
| name: "Bluetooth Proxy" | ||
| id: bluetooth_proxy_switch | ||
| icon: mdi:bluetooth | ||
| entity_category: "config" | ||
| restore_mode: RESTORE_DEFAULT_OFF | ||
| optimistic: true | ||
| on_turn_on: | ||
| - esp32_ble_tracker.start_scan: | ||
| continuous: true | ||
| on_turn_off: | ||
| - esp32_ble_tracker.stop_scan: | ||
|
|
||
|
|
||
| text_sensor: | ||
| - platform: wifi_info | ||
|
|
@@ -385,7 +483,35 @@ text_sensor: | |
| update_interval: never | ||
| entity_category: "diagnostic" | ||
|
|
||
| select: | ||
| - platform: template | ||
| name: "Firmware Channel" | ||
| id: firmware_channel | ||
| icon: mdi:source-branch | ||
| entity_category: "config" | ||
| optimistic: true | ||
| restore_value: true | ||
| options: | ||
| - "Stable" | ||
| - "Beta" | ||
| initial_option: "${firmware_channel_default}" | ||
| on_value: | ||
| then: | ||
| - script.execute: apply_ota_source | ||
|
|
||
| script: | ||
| - id: apply_ota_source | ||
| # Sets the OTA manifest URL from the Firmware Channel select (Stable/Beta). | ||
| # The manifest URLs are per-variant (ota_*_manifest substitutions via | ||
| # ${variant_slug}) so each hardware variant tracks its own manifests. | ||
| then: | ||
| - lambda: |- | ||
| const bool beta = id(firmware_channel).current_option() == "Beta"; | ||
| std::string url = beta ? "${ota_beta_manifest}" : "${ota_stable_manifest}"; | ||
| ESP_LOGI("firmware", "OTA manifest set to: %s", url.c_str()); | ||
| id(update_http_request).set_source_url(url); | ||
| - component.update: update_http_request | ||
|
|
||
| - id: statusCheck | ||
| then: | ||
| - if: | ||
|
|
||
There was a problem hiding this comment.
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
Publish manifests after all firmware assets.
Line 99 replaces the public manifest before Line 100 uploads the matching binaries. A device can read the new manifest while a referenced asset is unavailable or still contains the prior build. The update can then fail during the release window.
Generate both manifests locally. Upload all
.binassets first. Upload the manifests last.Proposed publication order
📝 Committable suggestion
🤖 Prompt for AI Agents