diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 5fd67bf..3583acb 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -19,7 +19,7 @@ jobs: merge-bot: name: Merge bot pull request job - uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@098016c83723e384c9da0088c13a00ca14e02fd5 # 2.0.1004 + uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@4b10111c29a2b6200cf270e44e39846614f4a1c0 # 2.0.1120 secrets: CODEGEN_APP_CLIENT_ID: ${{ secrets.CODEGEN_APP_CLIENT_ID }} CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index cce5cca..dee607e 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -28,7 +28,7 @@ jobs: plan: name: Plan release job needs: [assert-dispatch-ref] - uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@098016c83723e384c9da0088c13a00ca14e02fd5 # 2.0.1004 + uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@4b10111c29a2b6200cf270e44e39846614f4a1c0 # 2.0.1120 with: event_name: ${{ github.event_name }} actor: ${{ github.actor }} @@ -38,7 +38,7 @@ jobs: name: Validate sources job needs: [plan] if: ${{ needs.plan.outputs.publish == 'true' }} - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@098016c83723e384c9da0088c13a00ca14e02fd5 # 2.0.1004 + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@4b10111c29a2b6200cf270e44e39846614f4a1c0 # 2.0.1120 permissions: contents: read secrets: @@ -48,7 +48,7 @@ jobs: name: Publish project release job needs: [plan, validate] if: ${{ needs.plan.outputs.publish == 'true' && needs.validate.result == 'success' }} - uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@098016c83723e384c9da0088c13a00ca14e02fd5 # 2.0.1004 + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@4b10111c29a2b6200cf270e44e39846614f4a1c0 # 2.0.1120 permissions: contents: write actions: write diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 1eca770..97b870d 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -3,6 +3,7 @@ name: Test pull request action # Source lint plus a change-gated compile test, run on the ptr727/esphome-nonroot image the repo deploys. # - template-compile-test builds the example devices, which between them compose every published template. # - test/easystart.yaml also covers the easystart external component. +# - Each leg then lints the build it compiled, failing on a compiler warning in this repository's own code. # It costs minutes of esp-idf build, so it runs only when what it covers moves. # Operational model. # - develop takes direct signed commits, where push runs advisory CI. @@ -32,7 +33,7 @@ jobs: validate: name: Validate sources job - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@098016c83723e384c9da0088c13a00ca14e02fd5 # 2.0.1004 + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@4b10111c29a2b6200cf270e44e39846614f4a1c0 # 2.0.1120 permissions: contents: read secrets: @@ -80,7 +81,8 @@ jobs: fi WORKFLOW='\.github/workflows/test-pull-request\.yml$' # The test/ tree hosts the example devices, and test/easystart.yaml compiles easystart/components. - if grep -qE "^(templates/|test/|easystart/components/|$WORKFLOW)" <<<"$DIFF"; then + # The compile lint runs in the same legs, so a change to it reruns them too. + if grep -qE "^(templates/|test/|easystart/components/|tools/compile-lint/|$WORKFLOW)" <<<"$DIFF"; then echo "template-test=true" >> "$GITHUB_OUTPUT" else echo "template-test=false" >> "$GITHUB_OUTPUT" @@ -105,6 +107,15 @@ jobs: echo "Templates that no example device in test/ includes: ${missing[*]}" exit 1 fi + # A flashed example device runs on a bench with no Home Assistant and maybe no network, so it must not reboot for lack of either. + rebooting=() + for path in test/*.yaml; do + grep -qE "^ api_reboot_timeout: [\"']?0s[\"']?( *#.*)?\$" "$path" && grep -qE "^ wifi_reboot_timeout: [\"']?0s[\"']?( *#.*)?\$" "$path" || rebooting+=("$path") + done + if (( ${#rebooting[@]} > 0 )); then + echo "Example devices not setting api_reboot_timeout and wifi_reboot_timeout to 0s: ${rebooting[*]}" + exit 1 + fi CONFIGS="$(basename -a -s .yaml test/*.yaml | jq -Rsc 'split("\n") | map(select(length > 0))')" echo "Template matrix: $CONFIGS" echo "template-configs=$CONFIGS" >> "$GITHUB_OUTPUT" @@ -140,10 +151,12 @@ jobs: key="$(python3 -c 'import base64, os; print(base64.b64encode(os.urandom(32)).decode())')" sed -i "s|REPLACE_WITH_BASE64_32_BYTE_KEY|$key|" secrets.yaml + # The cache sits at a fixed path, and the log there names the build path, so the lint step finds the build. - name: Compile template test config step run: | set -Eeuo pipefail - cache="$(mktemp -d)" + cache="$RUNNER_TEMP/esphome-cache" + mkdir -p "$cache" chmod -R 0777 "$cache" . docker run --rm --user 1000:1000 \ --volume "$PWD":/config \ @@ -153,7 +166,24 @@ jobs: set -Eeuo pipefail /entrypoint/cache.sh esphome config /config/test/${{ matrix.config }}.yaml > /dev/null - esphome compile /config/test/${{ matrix.config }}.yaml + esphome compile /config/test/${{ matrix.config }}.yaml 2>&1 | tee /cache/compile.log + ' + + # Recompile the generated main.cpp syntax-only with stricter warnings, keeping what lands in this repository's code. + # A finding annotates its YAML lambda line or component file, and fails the leg, per OPERATIONS.md "Compile Lint". + # It runs as the runner's own user, so it can append to the step summary file mounted into the container. + - name: Lint compiled code step + run: | + set -Eeuo pipefail + docker run --rm --user "$(id -u):$(id -g)" \ + --volume "$PWD":/config \ + --volume "$RUNNER_TEMP/esphome-cache":/cache \ + --volume "$GITHUB_STEP_SUMMARY":/summary.md \ + --env PYTHONPATH=/config/tools/compile-lint/src \ + ptr727/esphome-nonroot:latest \ + bash -c ' + set -Eeuo pipefail + python3 -m compile_lint --config-root /config --annotate --summary /summary.md --from-log /cache/compile.log ' # GitHub Actions does not support required status checks on conditional jobs. @@ -177,9 +207,9 @@ jobs: exit 1 fi # The compile job is skipped when the change gate says nothing it covers moved. - # Only a real failure blocks, a skip must still pass. - # The matrix aggregates to a single result, failure if any leg failed. - if [[ "${{ needs.template-compile-test.result }}" == "failure" ]]; then - echo "Job 'template-compile-test' failed; refusing to pass." + # A skip must still pass, and anything else short of success blocks, a cancelled leg included. + result="${{ needs.template-compile-test.result }}" + if [[ "$result" != "success" && "$result" != "skipped" ]]; then + echo "Job 'template-compile-test' did not succeed ($result); refusing to pass." exit 1 fi diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ae4d2b4..f14e4d3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -45,6 +45,42 @@ "clear": false } }, + { + // The compile above, then the compile lint over the build it produced, per OPERATIONS.md "Compile Lint". + // A warning in a lambda or component lands in the Problems pane on its YAML or source line. + "label": "ESPHome: Compile and Lint", + "type": "process", + "command": "docker", + "args": [ + "exec", + "--env", + "PYTHONPATH=/config/tools/compile-lint/src", + "esphome", + "python3", + "-m", + "compile_lint", + "--config-root", + "/config", + "--compile", + "/config/${relativeFile}" + ], + "problemMatcher": { + "owner": "compile-lint", + "fileLocation": ["relative", "${workspaceFolder}"], + "pattern": { + "regexp": "^[^:\\s]+: (.+):(\\d+):(\\d+): (warning|error): (.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + } + }, + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, { "label": "ESPHome: Clean", "type": "process", diff --git a/CODESTYLE.md b/CODESTYLE.md index 7978963..4abfd27 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -53,7 +53,7 @@ This is packaged as the `python-codestyle` Skill at `.agents/skills/python-codes ### This Repository's Python -All Python in this repository lives in one **uv workspace** rooted at the repository root, and every project in it takes the `build` profile. The root [`pyproject.toml`][root-pyproject] is a virtual workspace root with no `[project]` of its own: it lists the member projects, holds the shared `dev` dependency group, and carries the one `[tool.ruff]`, `[tool.pyright]`, and `[tool.pytest.ini_options]` configuration every member uses. The single root `uv.lock` pins every member's dependencies and the dev tools together. The one member today is [`easystart/python`][easystart-python], the EasyStart BLE monitor, a `src`-layout project with its own `[project]`, `[build-system]`, and an `easystart-monitor` console script. +All Python in this repository lives in one **uv workspace** rooted at the repository root, and every project in it takes the `build` profile. The root [`pyproject.toml`][root-pyproject] is a virtual workspace root with no `[project]` of its own: it lists the member projects, holds the shared `dev` dependency group, and carries the one `[tool.ruff]`, `[tool.pyright]`, and `[tool.pytest.ini_options]` configuration every member uses. The single root `uv.lock` pins every member's dependencies and the dev tools together. The members are [`easystart/python`][easystart-python], the EasyStart BLE monitor, and [`tools/compile-lint`][compile-lint], the compile lint. Each is a `src`-layout project with its own `[project]`, `[build-system]`, and console script. The compile lint also runs inside the ESPHome image, so it needs only the standard library and ESPHome's own PyYAML. - **uv is the only supported toolchain.** Run everything from the repository root with `uv run`, which syncs the workspace first: `uv run ruff check`, `uv run ruff format --check`, `uv run pyright`, `uv run pytest`, and a member's own script such as `uv run easystart-monitor`. There is no pip or `requirements.txt` path. - **The hub validator is the CI gate.** A root `pyproject.toml`, `uv.lock`, and `tests/` are what its tree detection keys on, so it runs `uv sync --all-groups --frozen`, ruff, pyright, and pytest with coverage uploaded to Codecov, with no repository-specific CI step. The Codecov statuses are informational, per `codecov.yml`. @@ -80,7 +80,7 @@ The C++ here is a small amount of hand-written code that an ESPHome build compil The style derives from ESPHome upstream (LLVM-based, 2-space indent, 120-column limit) so the committed sources format clean and do not churn against upstream conventions. A repository's `.h` is read as C++ by context, which is what ESPHome and Arduino headers are. -Semantic and static analysis is deliberately out of scope. The downstream ESPHome build compiles this code and does the compile-time checking, and it holds the compile database that a `clang-tidy` pass would need. Adding a second, weaker analysis here would duplicate that without the context to be correct. +Semantic checking runs on the generated code rather than on the YAML. A lambda only compiles inside the `main.cpp` an ESPHome build produces. The compile lint rechecks that build with stricter warnings, per `OPERATIONS.md` "Compile Lint". Checking the YAML text directly would mean reimplementing ESPHome's code generation. ### Conventions @@ -90,6 +90,7 @@ Semantic and static analysis is deliberately out of scope. The downstream ESPHom [clang-format]: ./.clang-format +[compile-lint]: ./tools/compile-lint/ [cspell]: ./cspell.json [devices]: ./DEVICES.md [easystart-python]: ./easystart/python/ diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 5619d33..405551e 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -276,6 +276,7 @@ Every repo's GitHub repository details (the About panel) follow a fixed conventi - [`test/`](./test/), example device configs that between them compose every published template, each overlay on its board's example. CI compiles these against the current ESPHome release, so a published template is known to build, and they double as usage samples. - [`easystart/`](./easystart/), the Micro-Air EasyStart soft-starter project: the ESPHome external component, its protocol and reverse-engineering notes, and the standalone BLE monitor utility under `easystart/python`. - [`archive/`](./archive/), retired device configs kept for reference, neither built nor validated. +- [`tools/`](./tools/), repository tooling. [`tools/compile-lint`](./tools/compile-lint/) is the compile lint the compile test runs over each build, per [`OPERATIONS.md`](./OPERATIONS.md) "Compile Lint". - This repo carries no local `repo-config/` or `spec/secrets.json`: the hub hosts the branch ruleset and settings payloads and the secret-name ground truth, and [`AUDIT.md`](./AUDIT.md) "General Settings and Rulesets" and "Secrets" check against the hub's checkout rather than a local copy. - [`host-tools.json`](./host-tools.json), what a development host needs beyond the fleet baseline, layered over it tighten-only. Empty here, because every tool this repo's procedures need is already declared at its own floor. - [`.devcontainer/`](./.devcontainer/), the offline debugging container described above. diff --git a/HISTORY.md b/HISTORY.md index 11ee2a2..ed1417f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -4,6 +4,16 @@ ESPHome configuration templates and projects. ## Release History +- Version 2.4: + - Added M5Stack CoreS3 support, with an LVGL touch screen example device. + - Added M5Stack GC0308 camera overlay support. + - Added M5Stack Dial support, with an LVGL and rotary encoder example device. + - Changed the example devices to disable the API and WiFi reboot timeouts, so a flashed one runs on a bench without Home Assistant. + - Added a compile lint for compiler warnings in a lambda or the EasyStart component, so a warning surfaces while developing and at pull request time. + - Added a VS Code task that compiles the open config and lists each lint finding in the Problems pane on its YAML or source line. + - Added a pull request check that fails a compile leg on a lint finding, annotating its YAML or source line. + - Added a pull request check that fails an example device that does not disable both reboot timeouts. + - Fixed the float math warnings the compile lint finds in the templates and the EasyStart component. - Version 2.3: - Changed OTA to encryption only, using the API key, so only the upgrade bridge build still needs the `ota_password` secret. - Fixed the compile warnings the templates and the EasyStart component can address. diff --git a/OPERATIONS.md b/OPERATIONS.md index 155c891..60e7f05 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -28,7 +28,7 @@ The mechanics below were confirmed against ESPHome 2026.7.3 by validating a scra Some templates deviate from the plain shorthand, and each says so in its own block: - [`easystart.yaml`][easystart-template] is parameterized, so it takes the `url` / `ref` / `files` form with `vars` rather than the one-line shorthand. -- The overlays are composed on their board template as a second package entry. The `esp32-s3-wroom-*` board definitions compose on [`esp32-s3-devkitc.yaml`][devkitc-template], and [`heltec-l76k-gnss.yaml`][heltec-gnss-template] and [`waveshare-esp32-s3-eth-camera.yaml`][waveshare-camera-template] on their own boards. +- The overlays are composed on their board template as a second package entry. The `esp32-s3-wroom-*` board definitions compose on [`esp32-s3-devkitc.yaml`][devkitc-template], and [`heltec-l76k-gnss.yaml`][heltec-gnss-template], [`waveshare-esp32-s3-eth-camera.yaml`][waveshare-camera-template] and [`m5stack-cores3-camera.yaml`][m5stack-cores3-camera-template] on their own boards. - [`templates/secrets.yaml`][secrets-template] is repository-internal plumbing and is not externally usable, since it re-exports a path that exists only in this tree. Every substitution a template defines or needs is declared in that template's own header. A template composing others also exposes their knobs, so a consumer reads the composed set rather than one file, and [`common.yaml`][common-template] is the usual composer. [`README.md`][readme] catalogs knobs for an adopter choosing a template, and where it names one, the header is what it has to agree with. @@ -134,12 +134,25 @@ fi - **Point `config_dir` at a checkout rather than at the deployed tree.** The dashboard's container already serves that tree, so compile it there with `docker exec` rather than in a second container. - **`latest` gives CI parity, not dashboard parity.** CI pulls `latest` fresh on every run, so run `docker pull ptr727/esphome-nonroot:latest` first for parity. That can be newer than the dashboard's last pull. Before an install, rerun with the image tag set to the dashboard's ESPHome version, per [Running the ESPHome CLI][running-the-esphome-cli]. The image's Docker Hub tag list carries each ESPHome release as a tag, such as `2026.9.1`. - **Compile a [`test/`][test] example device to gate a template change.** A top-level device config works the same way, but the example device is what CI builds. +- **An example device sets `api_reboot_timeout` and `wifi_reboot_timeout` to `0s`.** A flashed one then keeps running on a bench with no Home Assistant and no network, rather than rebooting every 15 minutes. Every template that sets a reboot timeout reads the matching substitution. A template with no `wifi:` block, such as an ethernet-only board's, ignores the WiFi half. CI's change-detection job fails an example device that leaves either one out. - **`secrets.yaml` belongs at the repository root**, not beside the config being compiled, because [`templates/secrets.yaml`][secrets-template] re-exports the root file and every template resolves `!secret` through it. Generate one from [`secrets._yaml`][secrets-example], and the guard above keeps a real one from being overwritten. - **`/entrypoint/cache.sh` runs first**, and it prepares the `/cache` mount the image expects. - **`/cache` holds the toolchain and the build tree, so keep it between runs.** A fresh directory each time makes every local compile a cold ESP-IDF build of several minutes. It also holds ESPHome's ccache store, `home/.cache/esphome/idf/ccache`, so a kept directory turns most repeat compiles into cache hits. CI uses one regardless, since it discards the runner. - **The container runs as the invoking user**, so what it writes stays owned by that user. The image points its build tree and its data directory at `/cache`. The only thing it leaves in the checkout is `test/.gitignore`, which is ignored. CI pins uid 1000 and chmods the checkout instead, because its runner owns the files differently. - **A local compile is not the whole gate.** CI runs source lint too. Its change-detection job also fails when no example device in [`test/`][test] includes a device template. An overlay composes into its board's example device rather than getting one of its own. A new utility include joins that job's exemption list instead. +### Compile Lint + +A lambda is C++ that only compiles inside the `main.cpp` ESPHome generates. Its warnings land in a compile log among hundreds from ESPHome and the framework, where they are easy to miss. The compile lint in [`tools/compile-lint`][compile-lint] reruns the compile of `main.cpp` from the build ESPHome left behind. The rerun is syntax-only, uses the build's own flags plus `-Wdouble-promotion`, and keeps only the diagnostics located in this repository's code. It never builds firmware and never changes what gets flashed. + +- **Run it with every compile.** The VS Code task "ESPHome: Compile and Lint" compiles the open file in the dashboard's container, then lints that build. Each finding lands in the Problems pane on its YAML or source line. On a throwaway container, replace the compile call in [Compiling the Way CI Does][compiling-the-way-ci-does] with `PYTHONPATH=/config/tools/compile-lint/src python3 -m compile_lint --config-root /config --compile /config/test/