diff --git a/.github/workflows/README.md b/.github/workflows/README.md index cfef930..efc8091 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -9,7 +9,8 @@ qualification configuration. Waveshare V2 audio, display and attended diagnostic development configurations are also generated and compiled. These require isolated storage and disable every public update transport. Capacity inspection checks their 16 MiB headers explicitly; -public release validation retains the 8 MiB reference contract. +a separate Waveshare production job checks the registered 16 MiB production +contract, with production storage and signed updates. The Waveshare RTC host fixture checks strict register validation, interrupted initialization, verified SNTP writes and startup restoration, including scheduler handoffs across clock corrections and saved skips. Hardware RTC/offline-restart diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 9a32ae6..a97cf2d 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -41,7 +41,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: OpenAthan-Project/website - ref: ba356c80f00ff07046b7433c03e0fdc9802f64b0 + ref: 407d6bb165b1e6bed91a0486a1eb37870004aef6 path: .installer-contract persist-credentials: false - name: Verify producer output with the real installer validator @@ -49,7 +49,7 @@ jobs: - name: Validate developer configurations run: | python tools/prepare_ci.py --output-dir "$RUNNER_TEMP/audio-fixture" - for config in feasibility/baseline feasibility/calculations feasibility/shared-audio scheduler/development scheduler/device scheduler/validation openathan provisioning/validation upgrades/qualification waveshare/audio waveshare/development waveshare/diagnostics; do + for config in feasibility/baseline feasibility/calculations feasibility/shared-audio scheduler/development scheduler/device scheduler/validation openathan provisioning/validation upgrades/qualification waveshare/production waveshare/audio waveshare/development waveshare/diagnostics; do python -m esphome config "firmware/esphome/$config.yaml" > "$RUNNER_TEMP/config.log" 2>&1 || { cat "$RUNNER_TEMP/config.log"; exit 1; } done @@ -59,7 +59,7 @@ jobs: strategy: fail-fast: false matrix: - configuration: [device, validation, reference, provisioning-validation, upgrade-qualification, upgrade-startup-failure, waveshare-audio, waveshare-display, waveshare-diagnostics] + configuration: [device, validation, reference, provisioning-validation, upgrade-qualification, upgrade-startup-failure, waveshare-production, waveshare-audio, waveshare-display, waveshare-diagnostics] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -98,6 +98,9 @@ jobs: if [ "$CONFIGURATION" = upgrade-startup-failure ]; then options=(-s qualification_version v0.0.3 -s qualification_startup_failure true) fi + elif [ "$CONFIGURATION" = waveshare-production ]; then + config=firmware/esphome/waveshare/production.yaml + image=openathan elif [ "$CONFIGURATION" = waveshare-audio ] || [ "$CONFIGURATION" = waveshare-display ] || [ "$CONFIGURATION" = waveshare-diagnostics ]; then config=firmware/esphome/waveshare/audio.yaml if [ "$CONFIGURATION" = waveshare-display ]; then diff --git a/README.md b/README.md index 6ad1f9c..b181657 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ OpenAthan is an open-source, low-cost DIY Athan and Quran smart speaker project. It supports an ordinary-user reference build and a reusable ESPHome integration for people who already have compatible hardware. The reference experience is intended to work without soldering, PCB design, Home Assistant, or embedded-development experience. -> **Project status:** pre-1.0 reference releases are available. [v0.3.0](https://github.com/OpenAthan-Project/openathan/releases/tag/v0.3.0) adds a dim clock, next-prayer and playback display to standalone scheduled Athan, phone-friendly setup and settings, USB Wi-Fi/password recovery, optional prayer lights and owner-requested firmware updates for AtomS3R C126 + Pyramid A167. See the [current release evidence and limits](docs/development/release-validation-2026-10-04.md); newer ST7735 display revisions, broader hardware/network coverage and long-term stability remain unqualified. +> **Project status:** [v0.4.0](https://github.com/OpenAthan-Project/openathan/releases/tag/v0.4.0) is available for AtomS3R C126 + Pyramid A167, with standalone scheduled Athan, a display, phone-friendly setup and settings, USB Wi-Fi/password recovery and owner-requested updates. [v0.5.0 preparation](docs/development/v0.5.0-preparation-2026-10-10.md) adds Waveshare Box V2 production firmware and browser installation. That candidate has not been published; its physical production installation and update qualification remain pending. Newer Atom ST7735 display revisions remain unqualified. Use the [public setup guide](https://openathan.com/docs/getting-started/) for a new speaker and the [firmware update guide](docs/development/firmware-upgrades.md) diff --git a/docs/development/firmware-upgrades.md b/docs/development/firmware-upgrades.md index 08ee0c1..e8acef3 100644 --- a/docs/development/firmware-upgrades.md +++ b/docs/development/firmware-upgrades.md @@ -1,6 +1,6 @@ # Firmware upgrades -Official reference firmware can check for published stable releases and install +Official production firmware can check for published stable releases and install application-only updates from its authenticated device page. Prayer calculation, scheduling and recordings continue to work independently of the release service. The flow is included in published pre-1.0 reference releases. See the @@ -162,3 +162,17 @@ The additive [USB application-update protocol](usb-firmware-updates.md) reuses these trust, partition and startup contracts. It waits for owner power handoff after selection. The companion website keeps public USB writes disabled until new transport-specific physical qualification and a capable release are available. + +## Hardware-specific releases + +v0.5.0 preparation adds the Waveshare Box V2 production profile. Atom retains +`upgrade.json` and `firmware.ota.bin`; Waveshare uses +`waveshare-box-v2.upgrade.json` and `waveshare-box-v2.firmware.ota.bin`. Each +signed payload binds its hardware identity, and each device discovers only its +board's descriptor. Settings/audio formats and partition offsets remain unchanged. + +USB firmware INFO accepts the legacy nine fields and appends hardware as a tenth +field in new builds. USB status also appends `hardware=` after the +existing fields/URLs; older website clients ignore it as a non-URL. The new +installer validates the identity and uses Atom compatibility for older responses. +Physical Waveshare production update qualification remains pending. diff --git a/docs/development/releases.md b/docs/development/releases.md index 5b850ce..49c5633 100644 --- a/docs/development/releases.md +++ b/docs/development/releases.md @@ -1,8 +1,12 @@ # Firmware release preparation -[v0.3.0](../releases/v0.3.0.md) is the published stable reference release. -The [release evidence](release-validation-2026-10-04.md) records its exact-source -build, reference-device acceptance, public delivery and qualification limits. +[v0.4.0](../releases/v0.4.0.md) is the published stable reference release. +The v0.5.0 candidate adds Waveshare Box V2 alongside Atom. Candidate preparation +does not establish physical installer/update qualification or publication. +The [v0.4.0 preparation record](v0.4.0-preparation-2026-10-07.md) records its +automated builds and physical-validation limits. The earlier +[release evidence](release-validation-2026-10-04.md) records reference-device +acceptance and public delivery for v0.3.0. The earlier [preparation report](v0.3.0-preparation-2026-10-03.md) retains the matched development measurements. @@ -14,7 +18,8 @@ schema 1. The firmware repository builds and packages releases. The website consumes a reviewed release; it never compiles firmware. The [installer contract](https://github.com/OpenAthan-Project/website/blob/main/installer/README.md) -remains schema v1, for AtomS3R C126 + Voice Pyramid A167, ESP32-S3 with 8 MiB flash +retains schema v1. Profiles identify AtomS3R C126 + Voice Pyramid A167 +(8 MiB flash) or Waveshare Box V2 (16 MiB flash), both ESP32-S3 and the dual-2 MiB application / 3.5 MiB shared-audio layout. The selected normal and Fajr recordings have a documented @@ -46,8 +51,10 @@ python tools/release.py build --commit \ --output-dir /tmp/openathan-build ``` -The command exports source to a fresh staging directory and compiles only -`firmware/esphome/openathan.yaml`. It checks dependency pins, ESP32-S3 image +The command exports source to a fresh staging directory and compiles the registered production configuration. The default remains +`firmware/esphome/openathan.yaml`; pass +`--hardware waveshare-esp32-s3-touch-lcd-1_85c-box-v2` for +`firmware/esphome/waveshare/production.yaml`. Both builds use the same source commit. It checks dependency pins, ESP32-S3 image checksums/digests, the actual factory partition table, empty initial NVS, matching factory/OTA application payloads, and the 1,572,864-byte application budget. No audio is required. Output includes both images, the source archive, build record, @@ -83,7 +90,15 @@ preserves MP3 bytes, checks their approved hashes, builds the shared partition, and executes the production C++ audio-format validator from the source commit. Format/hash validation does not decode MP3 or replace listening tests. -Current upgrade-capable releases contain exactly seven assets: +Single-board upgrade-capable releases contain seven assets. Combined releases +contain thirteen: retain the seven Atom names below and add six Waveshare files +prefixed `waveshare-box-v2.`. Both manifests reference the same `athan-audio.bin`. +Pass `--waveshare-build-dir /tmp/openathan-waveshare-build` when packaging the Atom +build to produce the combined bundle. The two builds must share an exact commit. +Each board has its own manifest, factory, OTA, signed descriptor, checksums and +build report; Atom filenames and signed schema 1 remain compatible. + +The Atom assets are: | Asset | Purpose | | --- | --- | @@ -97,7 +112,7 @@ Current upgrade-capable releases contain exactly seven assets: Only factory and audio appear in the fresh-install manifest's two parts. The device updater consumes the separate signed descriptor and OTA application; -the website importer consumes only the manifest, factory and audio. Legacy +the website importer validates both installation and signed upgrade assets. Legacy releases without upgrade identity retain the five-asset contract and four-file checksum list. The report excludes raw logs, credentials and personal paths. Existing outputs and symlink files are rejected; regenerate a changed bundle diff --git a/docs/development/v0.5.0-preparation-2026-10-10.md b/docs/development/v0.5.0-preparation-2026-10-10.md new file mode 100644 index 0000000..422c357 --- /dev/null +++ b/docs/development/v0.5.0-preparation-2026-10-10.md @@ -0,0 +1,81 @@ +# v0.5.0 preparation — 2026-10-10 + +This prepares Atom and Waveshare Box V2 production profiles, combined release +packaging and browser hardware selection. It does not publish a release or +establish new physical acceptance. No hardware access or flashing occurred. +See the [candidate release notes](../releases/v0.5.0.md). + +## Configuration and artifact contract + +Both profiles retain two 2 MiB application slots and separate 3.5 MiB shared +audio. Atom remains 8 MiB; Waveshare is 16 MiB with the upper flash unused. +Waveshare production uses production namespaces, enabled signed updates, round +display, BOOT controls and the merged RTC adapter. Diagnostic and isolated +storage components are excluded; development records are not migrated. + +Combined packaging keeps the seven Atom asset names and adds six files prefixed +`waveshare-box-v2.`. Both manifests reference identical approved audio. Signatures +bind hardware, layout, source, version and application hash; mismatched hardware +and source commits fail validation. See [release tooling](releases.md). + +The browser recognizes the additive USB hardware field, preserves legacy Atom +responses, and requires explicit model selection for unknown firmware. It checks +ESP32-S3, selected flash capacity and security before any erase/write. Discovery +and recovery do not erase data. USB application writes stay disabled in public +policy until physical qualification. Stable publication remains the approval +gate for automatic website adoption. + +## Automated validation + +- 18 host CTests passed with UndefinedBehaviorSanitizer. +- 112 Python tests passed, including production configuration, combined bundles, + cross-board signature rejection and isolated-material exclusion. +- Website: 180 unit tests, TypeScript/Astro checks, verified static output, + 363 development browser cases and 171 built-site cases passed. Three existing + browser cases were skipped. Chromium, WebKit and narrow-screen projects cover + explicit model/erase confirmation and existing Waveshare discovery. +- Desktop and narrow-screen installer inspection passed; the UI detector + reported no findings. No new dependencies or media were added. + +### Matched OTA measurements + +Baseline is main `96670c1e30a9d4c16ca522ac976f41682acb3ccd`; candidate is the +prepared v0.5.0 tree. Each existing configuration was built with the same pinned +Python 3.13.5, ESPHome 2026.9.0, ESP-IDF 5.5.5 and compiler, fixed development +source identity and compile-only audio fixtures. These comparison images are +not release artifacts and must not be installed or played. + +| Configuration | Before bytes | After bytes | Delta | Budget remaining | +| --- | ---: | ---: | ---: | ---: | +| Atom production | 1,276,752 | 1,277,360 | +608 | 295,504 | +| Waveshare isolated audio | 1,212,912 | 1,213,344 | +432 | 359,520 | +| Waveshare isolated display | 1,262,864 | 1,263,312 | +448 | 309,552 | +| Waveshare diagnostics | 1,349,792 | 1,350,224 | +432 | 222,640 | +| Waveshare production, new profile | — | 1,284,752 | — | 288,112 | + +All five passed capacity inspection, including dependency pins, factory/OTA +agreement, flash headers, partition layout and the 1,572,864-byte application +budget. Waveshare production retains 812,400 bytes free in its 2 MiB slot. +Static measurements do not establish runtime heap, fragmentation or PSRAM +headroom with concurrent audio/network/display activity. + +## Hardware evidence and remaining work + +Earlier attended development evidence covers stored playback, display, setup, +buttons and preservation within the source limits in the +[Waveshare validation reports](waveshare-box-v2.md). It does not qualify this new +production namespace or signed release installation. Intermittent HTTP timeouts +remain unresolved; see the [network follow-up](waveshare-network-development-2026-10-09.md). + +Pending: attended production fresh installation, real signed public-feed OTA, +rollback/interruption and recovery, production settings/history/audio preservation, +and listening with the [new volume mapping](waveshare-volume-build-2026-10-10.md). +The [RTC powered-restart acceptance](waveshare-rtc-validation-2026-10-10.md) is +also pending. Battery retention and drift remain unqualified capabilities. +Reuse earlier accepted evidence where applicable without claiming it covers +changed code or exact candidate runtime behavior. + +Draft PR review, merge, an exact merged-source rebuild, release/tag upload, +physical qualification and explicit stable publication are separate steps. +No tag, draft release upload, publication or website deployment is part of this +preparation. diff --git a/docs/development/waveshare-box-v2.md b/docs/development/waveshare-box-v2.md index d95fc08..5d2e068 100644 --- a/docs/development/waveshare-box-v2.md +++ b/docs/development/waveshare-box-v2.md @@ -1,4 +1,4 @@ -# Waveshare Box V2 development profile +# Waveshare Box V2 profiles The ESP32-S3-Touch-LCD-1.85C-BOX **V2** development profile reuses standalone prayer calculations, scheduling, stored recordings, USB provisioning and the @@ -23,9 +23,11 @@ The chip is configured for 16 MB flash and 8 MB octal PSRAM. The existing the lower 8 MiB: two 2 MiB application slots and separate 3.5 MiB shared audio. Additional flash is unused. Each OTA image must remain at or below 1,572,864 bytes. `check_feasibility.py` recognizes the explicit Waveshare hardware define -and requires 16 MiB image headers, isolated storage and disabled updates. -The public release validator still defaults to 8 MiB reference images and -rejects isolated development firmware. +and requires 16 MiB image headers. Development also requires isolated storage +and disabled updates; the registered production configuration enables updates. +The release validator selects the registered hardware profile and continues to +reject isolated development firmware. Production uses the same lower-8-MiB +partition layout; additional flash remains unused. Wi-Fi power saving is disabled in this board profile following the attended LIGHT/NONE comparison. HTTP timeouts remain unresolved; see the dated report for @@ -42,11 +44,11 @@ load production settings or consumption history. `firmware.hardware` and `firmware.updates_enabled` are additive status fields. Existing clients can ignore them; the local UI treats a missing update flag as -enabled for older firmware. Waveshare sets it to false: automatic discovery, +enabled for older firmware. Development sets it to false: automatic discovery, authenticated check/install/cancel requests and USB update writes are rejected. USB provisioning and healthy application startup confirmation remain enabled. -These builds cannot be installed through the public updater or published as -reference releases. Maintainer installation/recovery uses esptool in an attended +Isolated builds cannot be installed through the public updater or published as +production releases. Maintainer installation/recovery uses esptool in an attended session. ## Hardware adapters @@ -206,3 +208,21 @@ to 60 minutes and retain memory/error observations. Distinguish physical power cycling from software restart, and queued playback from audible acceptance. Prefer compatible application-only recovery after initial partition migration; a full-flash restore also restores historical settings and prayer consumption. + +## Production release candidate + +`firmware/esphome/waveshare/production.yaml` is the v0.5.0 production candidate. +It uses `openathan` with a MAC suffix, production settings/setup/network/upgrade +storage, round display, BOOT controls and signed updates. It omits diagnostic and +isolated-storage components. Existing isolated development records are not migrated. +The merged RTC adapter is retained in production with its existing powered-reset +behavior and qualification limits. + +Release bundles use `waveshare-box-v2.` filenames for this board and share the +approved audio partition with Atom. The website selects matching firmware from +reported OpenAthan USB hardware identity; unknown firmware requires explicit +model and erase confirmation. See [release preparation](releases.md). + +The [v0.5.0 preparation report](v0.5.0-preparation-2026-10-10.md) separates +automated evidence from pending production installation, update/recovery and +new volume-mapping listening qualification. No new physical acceptance is claimed. diff --git a/docs/releases/v0.4.0.md b/docs/releases/v0.4.0.md index 1c735ca..655a943 100644 --- a/docs/releases/v0.4.0.md +++ b/docs/releases/v0.4.0.md @@ -1,6 +1,6 @@ # OpenAthan v0.4.0 -Release preparation. This version has not been published. +Published stable release: [v0.4.0](https://github.com/OpenAthan-Project/openathan/releases/tag/v0.4.0). The speaker's local device page now puts the next prayer and everyday controls within easy reach on phones and desktops. @@ -30,7 +30,7 @@ partition layout and Home Assistant independence are unchanged. ## Updating your speaker -After this release is published, speakers running v0.2.1 or v0.3.0 can check for +Speakers running v0.2.1 or v0.3.0 can check for updates on their device page and choose **Install update**. Compatible application updates preserve settings, credentials, prayer history and stored audio. diff --git a/docs/releases/v0.5.0.md b/docs/releases/v0.5.0.md new file mode 100644 index 0000000..54c9320 --- /dev/null +++ b/docs/releases/v0.5.0.md @@ -0,0 +1,30 @@ +# OpenAthan v0.5.0 + +Release candidate; not published. + +Waveshare **ESP32-S3-Touch-LCD-1.85C-BOX V2** joins the AtomS3R C126 + Voice +Pyramid A167 reference build. V1 is not supported. + +- Standalone scheduled Athan, approved stored recordings and local phone setup. +- Round display with saved brightness and 12/24-hour formatting. +- BOOT controls to stop playback, skip the next Athan or cancel that skip; + RESET restarts the device. +- Onboard RTC startup recovery after internet synchronization, when RTC power + is retained. Battery retention, unplugged retention and drift are unqualified. +- Board-specific signed application updates preserve production settings, + credentials, playback history and shared audio. +- Browser setup recognizes hardware reported by compatible OpenAthan. Blank or + vendor firmware requires model selection and full-erasure confirmation. + USB preserving update writes remain disabled pending physical qualification. + +Touch, microphones and SD storage remain deferred. The Waveshare volume mapping +now uses the full software volume range; retained settings can sound louder. +Start at a low volume. Listening with this mapping remains pending. + +**Fresh installation erases existing firmware and saved data.** Isolated +development storage is not migrated into production. Existing production +speakers should use the [preserving update guidance](../development/firmware-upgrades.md). +Atom filenames and its existing update compatibility remain supported. + +See [preparation and validation](../development/v0.5.0-preparation-2026-10-10.md) +for automated results, prior hardware evidence and remaining qualification. diff --git a/firmware/esphome/components/openathan_device/__init__.py b/firmware/esphome/components/openathan_device/__init__.py index 502719f..cd59056 100644 --- a/firmware/esphome/components/openathan_device/__init__.py +++ b/firmware/esphome/components/openathan_device/__init__.py @@ -29,8 +29,8 @@ def validate(config): full = fv.full_config.get() if config["hardware"] == "waveshare-esp32-s3-touch-lcd-1_85c-box-v2": - if config["updates_enabled"] or "openathan_provisioning_validation" not in full: - raise cv.Invalid("Waveshare development requires isolated storage and updates_enabled false") + if "openathan_provisioning_validation" in full and config["updates_enabled"]: + raise cv.Invalid("Isolated Waveshare development requires updates_enabled false") if "improv_serial" not in full or "device_id" not in full["improv_serial"]: raise cv.Invalid("OpenAthan requires its external improv_serial USB dispatcher") if full["wifi"].get("networks") or "ap" in full["wifi"] or full["wifi"]["enable_on_boot"]: @@ -72,6 +72,9 @@ async def to_code(config): raise cv.Invalid("Official project version must match release/firmware.json") cg.add_define("OPENATHAN_FIRMWARE_VERSION", version) cg.add_define("OPENATHAN_HARDWARE", config["hardware"]) + prefix = "waveshare-box-v2." if config["hardware"].startswith("waveshare-") else "" + cg.add_define("OPENATHAN_UPGRADE_DESCRIPTOR", prefix + "upgrade.json") + cg.add_define("OPENATHAN_UPGRADE_APPLICATION", prefix + "firmware.ota.bin") cg.add_define("OPENATHAN_UPDATES_ENABLED", int(config["updates_enabled"])) cg.add_define("OPENATHAN_BUILD_COMMIT", os.environ.get("OPENATHAN_BUILD_COMMIT", "development")) cg.add_define("OPENATHAN_UPGRADE_PUBLIC_KEY", public_key) diff --git a/firmware/esphome/components/openathan_device/device.cpp b/firmware/esphome/components/openathan_device/device.cpp index 6649571..5866932 100644 --- a/firmware/esphome/components/openathan_device/device.cpp +++ b/firmware/esphome/components/openathan_device/device.cpp @@ -207,6 +207,7 @@ void Device::serial_request(const Frame& request) { : "ready"}; const auto urls = urls_(); status.insert(status.end(), urls.begin(), urls.end()); + status.emplace_back(std::string("hardware=") + OPENATHAN_HARDWARE); result_(true, command, status); } else if (command == 2 && fields.size() == 1 && valid_password(fields[0])) { if (password_.revision == std::numeric_limits::max()) { diff --git a/firmware/esphome/components/openathan_device/upgrade.cpp b/firmware/esphome/components/openathan_device/upgrade.cpp index ea04833..ed774b7 100644 --- a/firmware/esphome/components/openathan_device/upgrade.cpp +++ b/firmware/esphome/components/openathan_device/upgrade.cpp @@ -298,7 +298,7 @@ void Upgrade::run_() { if (!buffer) { fail_("Not enough memory to check or download firmware; try again later"); return; } if (!install_job_) { Response response; - if (!response.open(std::string(BASE) + "latest/download/upgrade.json")) { fail_("Could not check for updates; try again later"); return; } + if (!response.open(std::string(BASE) + "latest/download/" + OPENATHAN_UPGRADE_DESCRIPTOR)) { fail_("Could not check for updates; try again later"); return; } std::string envelope; const auto deadline = milliseconds() + 30000; while (!cancel_ && milliseconds() < deadline) { @@ -324,7 +324,7 @@ void Upgrade::run_() { if (cancel_) return; if (!safe_) { fail_("Waiting for a safe time between prayers"); return; } Response response; - if (!response.open(std::string(BASE) + "download/" + release.version + "/firmware.ota.bin") || + if (!response.open(std::string(BASE) + "download/" + release.version + "/" + OPENATHAN_UPGRADE_APPLICATION) || esp_http_client_get_content_length(response.client) != release.bytes) { fail_("Could not download the verified firmware"); return; } const auto *partition = esp_ota_get_next_update_partition(nullptr); esp_ota_handle_t handle = 0; diff --git a/firmware/esphome/components/openathan_device/upgrade.h b/firmware/esphome/components/openathan_device/upgrade.h index 80172c5..83efe0d 100644 --- a/firmware/esphome/components/openathan_device/upgrade.h +++ b/firmware/esphome/components/openathan_device/upgrade.h @@ -1,5 +1,11 @@ #pragma once #include "esphome/core/defines.h" +#ifndef OPENATHAN_UPGRADE_DESCRIPTOR +#define OPENATHAN_UPGRADE_DESCRIPTOR "upgrade.json" +#endif +#ifndef OPENATHAN_UPGRADE_APPLICATION +#define OPENATHAN_UPGRADE_APPLICATION "firmware.ota.bin" +#endif #ifndef OPENATHAN_HARDWARE #define OPENATHAN_HARDWARE "atoms3r-c126-pyramid-a167" #endif diff --git a/firmware/esphome/components/openathan_device/usb_upgrade.cpp b/firmware/esphome/components/openathan_device/usb_upgrade.cpp index cc83983..8c6567c 100644 --- a/firmware/esphome/components/openathan_device/usb_upgrade.cpp +++ b/firmware/esphome/components/openathan_device/usb_upgrade.cpp @@ -36,7 +36,7 @@ std::vector Upgrade::usb_info() { std::lock_guard lock(mutex_); return {"1", OPENATHAN_FIRMWARE_VERSION, OPENATHAN_BUILD_COMMIT, OPENATHAN_UPDATES_ENABLED && bootloader_ok_ ? "supported" : "unsupported", storage_ok_ ? state_ : "storage_fault", - confirmed_ ? "confirmed" : "pending", result_, queued_.version, std::to_string(received_.load())}; + confirmed_ ? "confirmed" : "pending", result_, queued_.version, std::to_string(received_.load()), OPENATHAN_HARDWARE}; } Upgrade::UsbSelection Upgrade::usb_selection_() { // Called with mutex_ held. Empty expected can also be a handoff marker that diff --git a/firmware/esphome/openathan.yaml b/firmware/esphome/openathan.yaml index 1e3249d..b42274d 100644 --- a/firmware/esphome/openathan.yaml +++ b/firmware/esphome/openathan.yaml @@ -7,7 +7,7 @@ esphome: min_version: 2026.9.0 project: name: OpenAthan.reference - version: v0.4.0 + version: v0.5.0 packages: openathan: !include packages/openathan.yaml diff --git a/firmware/esphome/waveshare/audio.yaml b/firmware/esphome/waveshare/audio.yaml index f3f5f64..30d009c 100644 --- a/firmware/esphome/waveshare/audio.yaml +++ b/firmware/esphome/waveshare/audio.yaml @@ -5,7 +5,7 @@ esphome: min_version: 2026.9.0 project: name: OpenAthan.waveshare_dev - version: v0.4.0 + version: v0.5.0 packages: openathan: !include ../packages/openathan.yaml diff --git a/firmware/esphome/waveshare/production.yaml b/firmware/esphome/waveshare/production.yaml new file mode 100644 index 0000000..a386bc0 --- /dev/null +++ b/firmware/esphome/waveshare/production.yaml @@ -0,0 +1,18 @@ +# Official Waveshare Box V2 firmware; production storage and signed updates. +esphome: + name: openathan + name_add_mac_suffix: true + min_version: 2026.9.0 + project: + name: OpenAthan.waveshare_box_v2 + version: v0.5.0 + +packages: + openathan: !include ../packages/openathan.yaml + product: !include ../packages/product.yaml + hardware: !include ../packages/hardware/waveshare-box-v2.yaml + screen: !include ../boards/waveshare-box-v2-display.yaml + +openathan_device: + hardware: waveshare-esp32-s3-touch-lcd-1_85c-box-v2 + updates_enabled: true diff --git a/release/firmware.json b/release/firmware.json index a9f1cc6..17ed5db 100644 --- a/release/firmware.json +++ b/release/firmware.json @@ -1,5 +1,5 @@ { - "version": "v0.4.0", + "version": "v0.5.0", "upgradeSchema": 1, "storageFormat": 1, "audioFormat": 1 diff --git a/release/installer-contract.json b/release/installer-contract.json index b57da72..ca36ad3 100644 --- a/release/installer-contract.json +++ b/release/installer-contract.json @@ -1,6 +1,6 @@ { "repository": "OpenAthan-Project/website", - "commit": "ba356c80f00ff07046b7433c03e0fdc9802f64b0", + "commit": "407d6bb165b1e6bed91a0486a1eb37870004aef6", "path": "installer/release.ts", - "sha256": "c0b5db8fb2dce2c85b0bedffdb81dff125cacc828e79bc22a32fc97448d1549e" + "sha256": "cc50cdfb1e942ab9e5b145271ae5d080d2c5f4c9996e449a69818417bbcee7aa" } diff --git a/release/rollback-bootloaders.json b/release/rollback-bootloaders.json index 24e3b2d..ad5d391 100644 --- a/release/rollback-bootloaders.json +++ b/release/rollback-bootloaders.json @@ -1,6 +1,7 @@ { "regionBytes": 32768, "sha256": [ - "91da841bab4020ca05049e3aef6307fd6fc2bb329932ffe366c3f49dcf940f0e" + "91da841bab4020ca05049e3aef6307fd6fc2bb329932ffe366c3f49dcf940f0e", + "f87e50006583a57e8e50f74ab7e26e0bb1a2b64b541d32de78e0c2f090af3f32" ] } diff --git a/tests/check_installer_contract.py b/tests/check_installer_contract.py index 6c64697..d2b1b9f 100644 --- a/tests/check_installer_contract.py +++ b/tests/check_installer_contract.py @@ -9,7 +9,7 @@ from release_fixtures import ROOT, firmware from audio_image import build_image -from release_artifacts import digest, json_bytes, make_manifest, require +from release_artifacts import digest, json_bytes, make_manifest, require, PROFILES, asset_name def main(): @@ -22,13 +22,14 @@ def main(): require(head == pin['commit'] and digest(module.read_bytes()) == pin['sha256'], 'Installer contract pin differs') with tempfile.TemporaryDirectory(prefix='openathan-contract-test-') as temporary: output = Path(temporary) - factory, _ = firmware() audio, _ = build_image(b'ID3-non-decodable-normal-fixture', b'ID3-non-decodable-fajr-fixture') - files = {'firmware.factory.bin': factory, 'athan-audio.bin': audio, - 'manifest.json': json_bytes(make_manifest('a' * 40, 'v0.1.0', factory, audio))} - for name, data in files.items(): - (output / name).write_bytes(data) - subprocess.run([os.environ.get('NODE', 'node'), ROOT / 'tests/installer_contract.mjs', module, output], check=True) + for hardware, profile in PROFILES.items(): + factory, _ = firmware(profile['flash_bytes']) + files = {asset_name(hardware, 'firmware.factory.bin'): factory, 'athan-audio.bin': audio, + asset_name(hardware, 'manifest.json'): json_bytes(make_manifest('a' * 40, 'v0.1.0', factory, audio, hardware))} + for name, data in files.items(): + (output / name).write_bytes(data) + subprocess.run([os.environ.get('NODE', 'node'), ROOT / 'tests/installer_contract.mjs', module, output, hardware], check=True) if __name__ == '__main__': diff --git a/tests/installer_contract.mjs b/tests/installer_contract.mjs index 74daaf0..919c604 100644 --- a/tests/installer_contract.mjs +++ b/tests/installer_contract.mjs @@ -2,11 +2,11 @@ import { readFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import assert from 'node:assert/strict'; -const [modulePath, bundle] = process.argv.slice(2); -const { checkedManifest, parsePin, verifyBundle, sha256 } = await import(pathToFileURL(modulePath)); -const bytes = new Uint8Array(await readFile(`${bundle}/manifest.json`)); +const [modulePath, bundle, hardware] = process.argv.slice(2); +const { checkedManifest, parsePin, verifyBundle, sha256, assetName } = await import(pathToFileURL(modulePath)); +const bytes = new Uint8Array(await readFile(`${bundle}/${assetName(hardware, "manifest.json")}`)); const pin = parsePin({ schema: 1, release: { - tag: 'v0.1.0', manifestSha256: await sha256(bytes), mediaReviewed: true, hardwareQualified: true, + hardware, tag: JSON.parse(new TextDecoder().decode(bytes)).tag, manifestSha256: await sha256(bytes), mediaReviewed: true, hardwareQualified: true, }}); const manifest = await checkedManifest(bytes, pin); const inputs = new Map(); @@ -14,4 +14,4 @@ for (const part of manifest.parts) inputs.set(part.file, new Uint8Array(await re assert.equal((await verifyBundle(manifest, inputs)).parts.length, 2); inputs.get('athan-audio.bin')[4096] ^= 1; await assert.rejects(verifyBundle(manifest, inputs)); -console.log('Pinned installer accepts producer output and rejects changed audio. Synthetic test only.'); +console.log('Pinned installer accepts producer output and rejects changed audio. No device access.'); diff --git a/tests/test_ci_build_policy.py b/tests/test_ci_build_policy.py index 7acd7d4..ca1e675 100644 --- a/tests/test_ci_build_policy.py +++ b/tests/test_ci_build_policy.py @@ -162,6 +162,15 @@ def test_git_timeout_falls_back_to_build(self): class WorkflowPolicyTests(unittest.TestCase): + def test_installer_checkout_matches_the_contract_pin(self): + import yaml + workflow = yaml.safe_load((ROOT / ".github/workflows/firmware.yml").read_text()) + pin = json.loads((ROOT / "release/installer-contract.json").read_text()) + step = next(step for step in workflow["jobs"]["host-tests"]["steps"] + if step.get("with", {}).get("path") == ".installer-contract") + self.assertEqual(step["with"]["repository"], pin["repository"]) + self.assertEqual(step["with"]["ref"], pin["commit"]) + def test_all_expensive_steps_require_successful_docs_only_detection_to_skip(self): import yaml workflow = yaml.safe_load((ROOT / ".github/workflows/firmware.yml").read_text()) @@ -169,7 +178,7 @@ def test_all_expensive_steps_require_successful_docs_only_detection_to_skip(self self.assertNotIn("if", build) self.assertEqual(build["strategy"]["matrix"]["configuration"], [ "device", "validation", "reference", "provisioning-validation", - "upgrade-qualification", "upgrade-startup-failure", "waveshare-audio", "waveshare-display", "waveshare-diagnostics"]) + "upgrade-qualification", "upgrade-startup-failure", "waveshare-production", "waveshare-audio", "waveshare-display", "waveshare-diagnostics"]) steps = build["steps"] self.assertEqual(steps[0]["with"]["fetch-depth"], 2) detector = next(step for step in steps if step.get("id") == "build_policy") diff --git a/tests/test_release_profiles.py b/tests/test_release_profiles.py new file mode 100644 index 0000000..c3643a2 --- /dev/null +++ b/tests/test_release_profiles.py @@ -0,0 +1,66 @@ +"""Board-bound release assets, using non-executable fixtures only.""" +import json +from pathlib import Path +import struct +import sys +import tempfile +import unittest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools')) +import release_artifacts as artifacts +import upgrade_artifacts as upgrade +from audio_image import build_image +from release_fixtures import firmware, esp_image + + +class HardwareReleaseTests(unittest.TestCase): + def bundle(self, hardware, commit='a'*40): + profile = artifacts.profile(hardware) + factory, _ = firmware(profile['flash_bytes']) + payload = bytearray(256) + struct.pack_into('set_network_time(athan_clock)', (build / 'openathan/src/main.cpp').read_text()) def test_generated_board_and_profile_guards(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tools/check_feasibility.py b/tools/check_feasibility.py index bfacf0f..27f169d 100644 --- a/tools/check_feasibility.py +++ b/tools/check_feasibility.py @@ -78,11 +78,11 @@ def inspect_firmware(build_dir, log, root=ROOT): qualification, isolated = inspect_build_profile(build_dir, app) defines = (build_dir / "src/esphome/core/defines.h").read_text() waveshare = '#define OPENATHAN_HARDWARE "waveshare-esp32-s3-touch-lcd-1_85c-box-v2"' in defines - if waveshare and (not isolated or qualification or '#define OPENATHAN_UPDATES_ENABLED 0\n' not in defines): + if waveshare and isolated and (qualification or '#define OPENATHAN_UPDATES_ENABLED 0\n' not in defines): raise ValueError("Waveshare capacity inspection requires isolated development with updates disabled") flash_bytes = 0x1000000 if waveshare else 0x800000 validate_firmware_images(factory, app, allow_qualification=qualification, allow_isolated=isolated, - flash_bytes=flash_bytes) + flash_bytes=flash_bytes, hardware=("waveshare-esp32-s3-touch-lcd-1_85c-box-v2" if waveshare else "atoms3r-c126-pyramid-a167")) log_text = log.read_text().replace("\r", "\n") ram = re.search(r"RAM:.*used (\d+) bytes from (\d+) bytes", log_text) flash = re.search(r"Flash:.*used (\d+) bytes from (\d+) bytes", log_text) diff --git a/tools/release.py b/tools/release.py index dd6e872..fb46cd1 100644 --- a/tools/release.py +++ b/tools/release.py @@ -16,7 +16,8 @@ import audio_image import check_feasibility -from release_artifacts import (ASSETS, CONFIGURATION, LICENSE_PATH, MEDIA_REGISTRY, PINS_PATH, +from release_artifacts import (ASSETS, HARDWARE, WAVESHARE, PROFILES, profile, asset_name, + configuration_hardware, validate_profile_files, LICENSE_PATH, MEDIA_REGISTRY, PINS_PATH, REPOSITORY, SHA, TAG, approved_tracks, checksums, digest, json_bytes, make_manifest, read_file, read_json, require, validate_bundle) @@ -74,7 +75,8 @@ def export_source(commit, destination): return archive -def build(commit, output): +def build(commit, output, hardware=HARDWARE): + configuration = profile(hardware)["configuration"] source_commit(commit) require(sys.version_info[:2] == (3, 13), "Use the pinned Python 3.13 environment") pins = read_json(source_file(commit, PINS_PATH)) @@ -90,7 +92,7 @@ def build(commit, output): log = stage / "compile.log" print(f"Compiling {commit}; build output will be retained in {output}", flush=True) with log.open("wb") as handle: - completed = subprocess.run([sys.executable, "-m", "esphome", "compile", CONFIGURATION], + completed = subprocess.run([sys.executable, "-m", "esphome", "compile", configuration], cwd=source, env=environment, stdout=handle, stderr=subprocess.STDOUT) if completed.returncode: # Include a bounded tail before temporary failed output is removed. @@ -100,7 +102,7 @@ def build(commit, output): for name in ("firmware.factory.bin", "firmware.ota.bin"): shutil.copyfile(compiled / "build" / name, stage / name) record = dict(schema=1, commit=commit, source_tree=git("rev-parse", f"{commit}^{{tree}}").decode().strip(), - configuration=CONFIGURATION, capacity=capacity, + configuration=configuration, capacity=capacity, toolchain=dict(python=".".join(map(str, sys.version_info[:3])), esphome=version("esphome"), esp_idf=pins["esp_idf"], compiler=pins["xtensa_esp_elf"], tzdata=version("tzdata"))) validate_report(public_report(record), commit) @@ -113,7 +115,7 @@ def build(commit, output): def validate_build(directory): record = read_json(read_file(directory / "build-record.json", 65536)) require(isinstance(record, dict), "Invalid build record") - require(record.get("schema") == 1 and record.get("configuration") == CONFIGURATION, "Unsupported build record") + require(record.get("schema") == 1 and record.get("configuration") in {entry["configuration"] for entry in PROFILES.values()}, "Unsupported build record") commit = source_commit(record.get("commit")) require(record.get("source_tree") == git("rev-parse", f"{commit}^{{tree}}").decode().strip(), "Build source tree differs") evidence = record.get("evidence", {}) @@ -154,6 +156,7 @@ def validate_report(report, commit): require(toolchain["esphome"] == pins["esphome"] and toolchain["esp_idf"] == pins["esp_idf"] and toolchain["compiler"] == pins["xtensa_esp_elf"] and toolchain["tzdata"] == "2026.4", "Toolchain report differs from pins") require(report["source_tree"] == git("rev-parse", f"{commit}^{{tree}}").decode().strip(), "Report source tree differs") + configuration_hardware(report.get("configuration")) capacity = report.get("capacity", {}) keys = {"application_bytes", "linked_image_bytes", "static_ram_bytes", "application_slot_bytes", "slot_free_bytes", "growth_target_passed", "application_sha256", "application_budget_remaining_bytes", "managed_components"} @@ -177,10 +180,11 @@ def validate_audio_cpp(commit, path): command([build_dir / "audio_validator", path.resolve()]) -def package(build_dir, tag, normal, fajr, output, signing_key=None): +def package(build_dir, tag, normal, fajr, output, signing_key=None, waveshare_build_dir=None): require(TAG.fullmatch(tag), "Version must have the form vX.Y.Z") record = validate_build(build_dir) commit = record["commit"] + hardware = configuration_hardware(record["configuration"]) upgrade_capable = bool(git("ls-tree", "--name-only", commit, "release/firmware.json").strip()) require(not upgrade_capable or signing_key is not None, "Upgrade-capable releases require --signing-key") payloads = (audio_image.read_recording(normal), audio_image.read_recording(fajr)) @@ -189,7 +193,7 @@ def package(build_dir, tag, normal, fajr, output, signing_key=None): factory = (build_dir / ASSETS[1]).read_bytes() with fresh_output(output) as stage: files = {ASSETS[1]: factory, ASSETS[2]: media, - "manifest.json": json_bytes(make_manifest(commit, tag, factory, media)), + "manifest.json": json_bytes(make_manifest(commit, tag, factory, media, hardware)), "build-report.json": json_bytes(public_report(record))} if signing_key is not None: require(not signing_key.resolve().is_relative_to(ROOT.resolve()), "Keep the private signing key outside the repository") @@ -202,13 +206,28 @@ def package(build_dir, tag, normal, fajr, output, signing_key=None): "Factory bootloader is not in the reviewed rollback allowlist") app = (build_dir / "firmware.ota.bin").read_bytes() files["firmware.ota.bin"] = app - files["upgrade.json"] = sign(tag, commit, app, signing_key.read_bytes(), public) - files["SHA256SUMS"] = checksums(files) + files["upgrade.json"] = sign(tag, commit, app, signing_key.read_bytes(), public, hardware) + files = {asset_name(hardware, name): data for name, data in files.items()} + checksum_name = asset_name(hardware, "SHA256SUMS") + files[checksum_name] = checksums(files) + if waveshare_build_dir is not None: + require(hardware == HARDWARE, "Combined packaging starts with the Atom build") + other = validate_build(waveshare_build_dir) + require(other["commit"] == commit and configuration_hardware(other["configuration"]) == WAVESHARE, + "Combined packaging requires the same-commit Waveshare production build") + with tempfile.TemporaryDirectory(prefix="openathan-waveshare-package-") as temporary: + destination = Path(temporary) / "bundle" + package(waveshare_build_dir, tag, normal, fajr, destination, signing_key) + for path in destination.iterdir(): + if path.name in files: + require(files[path.name] == path.read_bytes(), "Shared release asset differs") + else: + files[path.name] = path.read_bytes() for name, data in files.items(): (stage / name).write_bytes(data) validate_bundle(stage) validate_audio_cpp(commit, stage / ASSETS[2]) - return dict(bundle=str(output), manifest_sha256=digest(files["manifest.json"])) + return dict(bundle=str(output), manifest_sha256=digest(files[asset_name(hardware, "manifest.json")])) class GitHub: @@ -277,16 +296,21 @@ def upload_draft(bundle, github=None): manifest, report, files, payloads = validate_bundle(bundle) commit, tag = manifest["commit"], manifest["tag"] source_commit(commit) + groups = (HARDWARE, WAVESHARE) if asset_name(WAVESHARE, "manifest.json") in files and "manifest.json" in files else (manifest["hardware"],) upgrade_capable = bool(git("ls-tree", "--name-only", commit, "release/firmware.json").strip()) - require(not upgrade_capable or "upgrade.json" in files, "Upgrade-capable release is missing signed upgrade assets") - if "upgrade.json" in files: - from upgrade_artifacts import validate - bootloaders = read_json(source_file(commit, "release/rollback-bootloaders.json")) - require(bootloaders["regionBytes"] == 32768 and digest(files["firmware.factory.bin"][:32768]) in bootloaders["sha256"], - "Factory bootloader is not in the reviewed rollback allowlist") - validate(files["upgrade.json"], files["firmware.ota.bin"], - source_file(commit, "release/upgrade-public-key.pem"), tag, commit) - validate_report(report, commit) + for hardware in groups: + _, board_report, _ = validate_profile_files(files, hardware) + descriptor = asset_name(hardware, "upgrade.json") + require(not upgrade_capable or descriptor in files, "Upgrade-capable release is missing signed upgrade assets") + if descriptor in files: + from upgrade_artifacts import validate + bootloaders = read_json(source_file(commit, "release/rollback-bootloaders.json")) + require(bootloaders["regionBytes"] == 32768 and + digest(files[asset_name(hardware, "firmware.factory.bin")][:32768]) in bootloaders["sha256"], + "Factory bootloader is not in the reviewed rollback allowlist") + validate(files[descriptor], files[asset_name(hardware, "firmware.ota.bin")], + source_file(commit, "release/upgrade-public-key.pem"), tag, commit, hardware) + validate_report(board_report, commit) approved_tracks(read_json(source_file(commit, MEDIA_REGISTRY)), source_file(commit, LICENSE_PATH), payloads) github = github or GitHub() with tempfile.TemporaryDirectory(prefix="openathan-upload-") as temporary: @@ -300,11 +324,11 @@ def upload_draft(bundle, github=None): release = find_release(github, tag) if release is None: notes = snapshot / "notes.md" - notes.write_text(f"Reference firmware candidate from `{commit}`.\n\n" + notes.write_text(f"Board-specific firmware candidate from `{commit}`.\n\n" "Recording redistribution metadata was checked against the committed registry. " "Physical qualification and public publication remain pending. " - "The website installer requires a separate reviewed release selection.\n\n" - f"Manifest SHA-256: `{digest(files['manifest.json'])}`\n") + "The website may adopt a qualified stable release after explicit publication.\n\n" + f"Manifest SHA-256: `{digest(files[asset_name(manifest['hardware'], 'manifest.json')])}`\n") github.call("release", "create", tag, "--repo", REPOSITORY, "--draft", "--verify-tag", "--latest=false", "--title", f"OpenAthan {tag}", "--notes-file", notes) release = find_release(github, tag) @@ -319,17 +343,19 @@ def upload_draft(bundle, github=None): require(find_release(github, tag)["id"] == release["id"], "Draft identity changed") require(github.tag_commit(tag) == commit, "Remote tag changed during upload") require(set(verify_assets(github, release, files)) == set(files), "Upload is incomplete; rerun to reconcile") - return dict(url=release["html_url"], manifest_sha256=digest(files["manifest.json"]), status="draft", - remaining="Hardware qualification, explicit publication, and reviewed website release selection") + return dict(url=release["html_url"], manifest_sha256=digest(files[asset_name(manifest["hardware"], "manifest.json")]), status="draft", + remaining="Hardware qualification, explicit publication, and verified website adoption") def main(): parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="action", required=True) build_parser = commands.add_parser("build", help="Compile an exact source commit without recordings") + build_parser.add_argument("--hardware", choices=tuple(PROFILES), default=HARDWARE) build_parser.add_argument("--commit", required=True) build_parser.add_argument("--output-dir", required=True, type=Path) package_parser = commands.add_parser("package", help="Package firmware with approved recordings") + package_parser.add_argument("--waveshare-build-dir", type=Path, help="Same-commit production build for a combined release") package_parser.add_argument("--build-dir", required=True, type=Path) package_parser.add_argument("--tag", required=True) package_parser.add_argument("--signing-key", type=Path, help="Private P-256 key outside the repository") @@ -341,9 +367,9 @@ def main(): args = parser.parse_args() try: if args.action == "build": - result = build(args.commit, args.output_dir) + result = build(args.commit, args.output_dir, args.hardware) elif args.action == "package": - result = package(args.build_dir.resolve(), args.tag, args.normal, args.fajr, args.output_dir, args.signing_key) + result = package(args.build_dir.resolve(), args.tag, args.normal, args.fajr, args.output_dir, args.signing_key, args.waveshare_build_dir) else: result = upload_draft(args.bundle) print(json.dumps(result, indent=2)) diff --git a/tools/release_artifacts.py b/tools/release_artifacts.py index d13da5a..c9cf987 100644 --- a/tools/release_artifacts.py +++ b/tools/release_artifacts.py @@ -12,6 +12,28 @@ HARDWARE = "atoms3r-c126-pyramid-a167" LAYOUT = "dual-2m-audio-3_5m-v1" CONFIGURATION = "firmware/esphome/openathan.yaml" +WAVESHARE = "waveshare-esp32-s3-touch-lcd-1_85c-box-v2" +PROFILES = { + HARDWARE: dict(configuration=CONFIGURATION, flash_bytes=0x800000, prefix=""), + WAVESHARE: dict(configuration="firmware/esphome/waveshare/production.yaml", + flash_bytes=0x1000000, prefix="waveshare-box-v2."), +} + + +def profile(hardware): + require(hardware in PROFILES, "Unsupported release hardware") + return PROFILES[hardware] + + +def asset_name(hardware, name): + return name if name == "athan-audio.bin" else profile(hardware)["prefix"] + name + + +def configuration_hardware(configuration): + for hardware, entry in PROFILES.items(): + if entry["configuration"] == configuration: + return hardware + raise ValueError("Unsupported production configuration") MEDIA_REGISTRY = "release/recordings.json" LICENSE_PATH = "AUDIO-LICENSES.md" PINS_PATH = "firmware/esphome/feasibility/dependencies.json" @@ -90,9 +112,9 @@ def reject_test_material(app, *, allow_qualification=False, allow_isolated=False "Isolated test firmware cannot enter production release bundles") -def validate_firmware_images(factory, app=None, *, allow_qualification=False, allow_isolated=False, flash_bytes=0x800000): +def validate_firmware_images(factory, app=None, *, allow_qualification=False, allow_isolated=False, flash_bytes=0x800000, hardware=HARDWARE): require(0x10020 <= len(factory) < PARTITION_OFFSET, "Invalid factory image size") - require(flash_bytes == 0x800000 or allow_isolated, "16 MiB images require isolated development storage") + require(flash_bytes == profile(hardware)["flash_bytes"], "Hardware and image flash capacity differ") esp_image(factory[:0x8000], flash_bytes=flash_bytes) table = factory[0x8000:0x9000] for index, (name, kind, subtype, offset, size) in enumerate(PARTITIONS): @@ -152,14 +174,14 @@ def audio_payloads(media): return payloads -def make_manifest(commit, tag, factory, audio): +def make_manifest(commit, tag, factory, audio, hardware=HARDWARE): require(isinstance(commit, str) and SHA.fullmatch(commit), "An exact source commit is required") require(isinstance(tag, str) and TAG.fullmatch(tag), "Version must have the form vX.Y.Z") - return dict(schema=1, repository=REPOSITORY, tag=tag, commit=commit, hardware=HARDWARE, - chip="ESP32-S3", flashBytes=0x800000, layout=LAYOUT, provisioningProtocol=1, + return dict(schema=1, repository=REPOSITORY, tag=tag, commit=commit, hardware=hardware, + chip="ESP32-S3", flashBytes=profile(hardware)["flash_bytes"], layout=LAYOUT, provisioningProtocol=1, media=dict(redistributionApproved=True, licenseUrl=f"https://github.com/{REPOSITORY}/blob/{commit}/{LICENSE_PATH}"), - parts=[dict(role=role, file=name, offset=offset, bytes=len(data), sha256=digest(data)) + parts=[dict(role=role, file=asset_name(hardware, name), offset=offset, bytes=len(data), sha256=digest(data)) for role, name, offset, data in (("factory", ASSETS[1], 0, factory), ("audio", ASSETS[2], PARTITION_OFFSET, audio))]) @@ -172,26 +194,52 @@ def validate_bundle(directory): require(directory.is_dir() and not directory.is_symlink(), "Bundle must be a regular directory") from upgrade_artifacts import UPGRADE_ASSETS names = {p.name for p in directory.iterdir()} - require(names in (set(ASSETS), set(ASSETS) | set(UPGRADE_ASSETS)), "Bundle contains unexpected release assets") - limits = (16384, PARTITION_OFFSET - 1, PARTITION_SIZE, 1024, 65536) - files = {name: read_file(directory / name, limit) for name, limit in zip(ASSETS, limits)} - if set(UPGRADE_ASSETS) <= names: - files.update({"firmware.ota.bin": read_file(directory / "firmware.ota.bin", 1572864), - "upgrade.json": read_file(directory / "upgrade.json", 8192)}) - manifest = read_json(files["manifest.json"]) + combined = asset_name(WAVESHARE, "manifest.json") in names and "manifest.json" in names + hardware = HARDWARE if "manifest.json" in names else WAVESHARE + groups = (HARDWARE, WAVESHARE) if combined else (hardware,) + allowed = set() + for board in groups: + allowed.update(asset_name(board, name) for name in ASSETS) + if asset_name(board, "upgrade.json") in names: + allowed.update(asset_name(board, name) for name in UPGRADE_ASSETS) + require(names == allowed, "Bundle contains unexpected or incomplete release assets") + limits = dict(zip(ASSETS, (16384, PARTITION_OFFSET - 1, PARTITION_SIZE, 1024, 65536))) + limits.update({"firmware.ota.bin": 1572864, "upgrade.json": 8192}) + files = {asset_name(board, name): read_file(directory / asset_name(board, name), limit) + for board in groups for name, limit in limits.items() if asset_name(board, name) in names} + results = [validate_profile_files(files, board) for board in groups] + if combined: + require(results[0][0]["commit"] == results[1][0]["commit"] and + results[0][0]["tag"] == results[1][0]["tag"], "Hardware bundles have different source/version") + manifest, report, payloads = results[0] + return manifest, report, files, payloads + + +def validate_profile_files(all_files, hardware): + def data(name): + return all_files[asset_name(hardware, name)] + manifest = read_json(data("manifest.json")) require(isinstance(manifest, dict), "Invalid release manifest") - expected = make_manifest(manifest.get("commit"), manifest.get("tag"), files[ASSETS[1]], files[ASSETS[2]]) - require(json_bytes(manifest) == json_bytes(expected), - "Release manifest does not match the supported contract and files") - app = validate_firmware_images(files[ASSETS[1]]) - if "firmware.ota.bin" in files: - require(files["firmware.ota.bin"] == app, "Upgrade application differs from factory image") - payloads = audio_payloads(files[ASSETS[2]]) + expected = make_manifest(manifest.get("commit"), manifest.get("tag"), + data("firmware.factory.bin"), data("athan-audio.bin"), hardware) + require(json_bytes(manifest) == json_bytes(expected), "Release manifest does not match the supported contract and files") + app = validate_firmware_images(data("firmware.factory.bin"), + flash_bytes=profile(hardware)["flash_bytes"], hardware=hardware) + if asset_name(hardware, "firmware.ota.bin") in all_files: + require(data("firmware.ota.bin") == app, "Upgrade application differs from factory image") + payloads = audio_payloads(data("athan-audio.bin")) require(all(payload[:4096] not in app for payload in payloads), "Recording data found in application") - require(files["SHA256SUMS"] == checksums(files), "Bundle checksums differ") - report = read_json(files["build-report.json"]) + board_files = {name: value for name, value in all_files.items() + if name == "athan-audio.bin" or + (name.startswith(profile(hardware)["prefix"]) if hardware == WAVESHARE else + not name.startswith(profile(WAVESHARE)["prefix"]))} + checksum_name = asset_name(hardware, "SHA256SUMS") + checksum = "".join(f"{digest(board_files[name])} {name}\n" for name in sorted(board_files) + if name != checksum_name).encode() + require(data("SHA256SUMS") == checksum, "Bundle checksums differ") + report = read_json(data("build-report.json")) require(isinstance(report, dict) and report.get("commit") == manifest["commit"] and - report.get("configuration") == CONFIGURATION and report.get("schema") == 1, + report.get("configuration") == profile(hardware)["configuration"] and report.get("schema") == 1, "Invalid build report identity") expected_metrics = dict(application_bytes=len(app), application_sha256=digest(app), application_slot_bytes=0x200000, slot_free_bytes=0x200000-len(app), @@ -199,4 +247,4 @@ def validate_bundle(directory): require(isinstance(report.get("capacity"), dict), "Invalid capacity report") require(all(report["capacity"].get(key) == value for key, value in expected_metrics.items()), "Build report does not match the application") - return manifest, report, files, payloads + return manifest, report, payloads diff --git a/tools/upgrade_artifacts.py b/tools/upgrade_artifacts.py index 962e948..da04656 100644 --- a/tools/upgrade_artifacts.py +++ b/tools/upgrade_artifacts.py @@ -5,19 +5,19 @@ from cryptography.hazmat.primitives.asymmetric import ec from cryptography.exceptions import InvalidSignature from release_artifacts import (HARDWARE, LAYOUT, SHA, digest, esp_image, - json_bytes, read_json, require) + json_bytes, read_json, require, profile) UPGRADE_ASSETS = ("firmware.ota.bin", "upgrade.json") -def image_version(app): - esp_image(app, exact=True) +def image_version(app, hardware=HARDWARE): + esp_image(app, exact=True, flash_bytes=profile(hardware)["flash_bytes"]) require(len(app) >= 288 and struct.unpack_from("