Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ Closes #

## Type of change

- [ ] Bugfix (T-number: ___)
- [ ] Bugfix
- [ ] New feature (F-number from PROJECT_PLAN.md: ___)
- [ ] Refactor
- [ ] Refactor / YAML cleanup
- [ ] CI / tooling
- [ ] Documentation

## Checklist

- [ ] `pio run` compiles cleanly (no warnings)
- [ ] `pio test -e native` passes
- [ ] Tested on hardware (LOLIN D32 + DHT22 + Ruck EM 125L EC 02)
- [ ] `esphome config fancontrol.yaml` passes
- [ ] `esphome compile fancontrol.yaml` succeeds (lambdas type-check)
- [ ] Tested on hardware (LOLIN D32 + DHT22 + Ruck EM 125L EC 02) if change affects runtime behaviour
- [ ] `CHANGELOG.md` updated under `[Unreleased]`
- [ ] No blocking calls >10ms in `loop()`
- [ ] All NVS writes check their return value and log the result
- [ ] No hard-coded credentials — all secrets referenced via `!secret ...`
- [ ] Safety behaviour preserved (sensor stall → fan 100 %, min-% floor, alarm thresholds)
68 changes: 31 additions & 37 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: PlatformIO CI
name: ESPHome CI

on:
push:
Expand All @@ -13,51 +13,45 @@ jobs:
- name: Checkout
uses: actions/checkout@v6

- name: Cache PlatformIO + pip
uses: actions/cache@v5
with:
path: |
~/.cache/pip
~/.platformio
key: ${{ runner.os }}-pio-${{ hashFiles('platformio.ini') }}
restore-keys: |
${{ runner.os }}-pio-

- name: Setup Python
uses: actions/setup-python@v6
# Seed a dummy secrets.yaml so `esphome config` resolves !secret
# references during the lint. Real secrets live in the maintainer's
# local secrets.yaml (gitignored) and are not needed for CI.
- name: Stage dummy secrets for lint
run: |
python3 -c 'import base64, os; print("api_encryption_key: \"" + base64.b64encode(os.urandom(32)).decode() + "\"")' > secrets.yaml
cat >> secrets.yaml <<EOF
wifi_ssid: "CiDummyNet"
wifi_password: "ci-dummy-password"
ota_password: "ci-dummy-ota-password"
EOF

- name: Validate ESPHome config
uses: esphome/build-action@v7
with:
python-version: '3.11'

- name: Install PlatformIO
run: pip install --upgrade platformio

- name: Build firmware (lolin_d32)
run: pio run -e lolin_d32
yaml-file: fancontrol.yaml
version: latest

# Host-side Unity tests for pure-logic modules (see test/README.md).
# This is a hard gate: failures must fail the build.
- name: Run unit tests (native)
run: pio test -e native

# Static analysis via cppcheck. Soft gate initially — we want visibility
# into findings without blocking merges on advisory warnings. Tighten to
# --fail-on-defect when the baseline is clean.
- name: Static analysis (pio check)
continue-on-error: true
run: pio check -e lolin_d32 --fail-on-defect high --skip-packages --verbose

- name: Check binary size (<= 1.5 MB)
- name: Firmware size gate (<= 1.5 MB)
run: |
SIZE=$(stat -c%s .pio/build/lolin_d32/firmware.bin)
FW=$(find . -path './.esphome/build/*/firmware.bin' -type f | head -1)
if [ -z "$FW" ]; then
echo "No firmware.bin found — build step likely failed."
exit 1
fi
SIZE=$(stat -c%s "$FW")
echo "Firmware size: $SIZE bytes"
if [ "$SIZE" -gt 1500000 ]; then
echo "ERROR: firmware too large (>1.5MB)"
exit 1
fi

- name: Upload firmware artifact
- name: Upload firmware artifacts
uses: actions/upload-artifact@v7
with:
name: firmware-${{ github.sha }}
path: .pio/build/lolin_d32/firmware.bin
name: fancontrol-${{ github.sha }}
path: |
.esphome/build/fancontrol/.pioenvs/fancontrol/firmware.bin
.esphome/build/fancontrol/.pioenvs/fancontrol/firmware.ota.bin
.esphome/build/fancontrol/.pioenvs/fancontrol/firmware.factory.bin
retention-days: 30
if-no-files-found: warn
79 changes: 25 additions & 54 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,64 +17,38 @@ jobs:
with:
fetch-depth: 0

- name: Cache PlatformIO + pip
uses: actions/cache@v5
with:
path: |
~/.cache/pip
~/.platformio
key: ${{ runner.os }}-pio-${{ hashFiles('platformio.ini') }}
restore-keys: |
${{ runner.os }}-pio-

- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.11'

- name: Install PlatformIO
run: pip install --upgrade platformio

- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

- name: Inject version into firmware
run: |
sed -i 's/#define VERSION ".*"/#define VERSION "'${{ steps.version.outputs.VERSION }}'"/' src/version.h

- name: Build release firmware
run: pio run -e lolin_d32

- name: Rename binary
- name: Stage dummy secrets for build
run: |
cp .pio/build/lolin_d32/firmware.bin \
FanControl-${{ steps.version.outputs.VERSION }}.bin
python3 -c 'import base64, os; print("api_encryption_key: \"" + base64.b64encode(os.urandom(32)).decode() + "\"")' > secrets.yaml
cat >> secrets.yaml <<EOF
wifi_ssid: "ReleaseDummyNet"
wifi_password: "release-dummy-password"
ota_password: "release-dummy-ota-password"
EOF

- name: SHA256 checksum
run: |
sha256sum FanControl-${{ steps.version.outputs.VERSION }}.bin \
> FanControl-${{ steps.version.outputs.VERSION }}.bin.sha256
- name: Build ESPHome firmware
uses: esphome/build-action@v7
with:
yaml-file: fancontrol.yaml
version: latest

# Signing matches SmartEVSE-3.5/pio-build.yaml exactly.
# Repo secret SECRET_RSA_KEY holds the PEM-encoded RSA private key.
# If the secret is unset the step exits 0 silently so forks build cleanly;
# the release step then skips the .signed.bin via fail_on_unmatched_files: false.
- name: Sign firmware
env:
super_secret: ${{ secrets.SECRET_RSA_KEY }}
- name: Collect + rename release artifacts
id: artifacts
run: |
if [ -z "$super_secret" ]; then
echo "Signing key not set, skipping firmware signing"
exit 0
fi
secret_file=$(mktemp)
echo "$super_secret" > "$secret_file"
openssl dgst -sign "$secret_file" -keyform PEM -sha256 \
-out firmware.sign -binary FanControl-${{ steps.version.outputs.VERSION }}.bin
cat firmware.sign FanControl-${{ steps.version.outputs.VERSION }}.bin \
> FanControl-${{ steps.version.outputs.VERSION }}.signed.bin
rm -f "$secret_file" firmware.sign
VER=${{ steps.version.outputs.VERSION }}
mkdir -p release
cp .esphome/build/fancontrol/.pioenvs/fancontrol/firmware.bin release/FanControl-${VER}.bin
cp .esphome/build/fancontrol/.pioenvs/fancontrol/firmware.ota.bin release/FanControl-${VER}.ota.bin
cp .esphome/build/fancontrol/.pioenvs/fancontrol/firmware.factory.bin release/FanControl-${VER}.factory.bin
cd release
sha256sum FanControl-${VER}.bin > FanControl-${VER}.bin.sha256
sha256sum FanControl-${VER}.ota.bin > FanControl-${VER}.ota.bin.sha256
sha256sum FanControl-${VER}.factory.bin > FanControl-${VER}.factory.bin.sha256
ls -la

- name: Extract changelog section for this release
run: |
Expand All @@ -94,9 +68,6 @@ jobs:
draft: false
prerelease: ${{ contains(steps.version.outputs.VERSION, '-') }}
fail_on_unmatched_files: false
files: |
FanControl-${{ steps.version.outputs.VERSION }}.bin
FanControl-${{ steps.version.outputs.VERSION }}.bin.sha256
FanControl-${{ steps.version.outputs.VERSION }}.signed.bin
files: release/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
16 changes: 12 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
.pio/
.pioenvs/
.piolibdeps/
# ESPHome
.esphome/
secrets.yaml

# Editor / OS
.vscode/
.cache/
.DS_Store
*.swp
*.bak

# Legacy PlatformIO artifacts — kept so a stray .pio/ from pre-v0.2.0
# checkouts never gets committed back.
.pio/
.pioenvs/
.piolibdeps/
.cache/
build/
dist/
98 changes: 93 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,98 @@ Release notes for each tagged version are extracted from this file by the

## [Unreleased]

## [0.2.0] - 2026-04-19

**Architecture switch: custom PlatformIO + Arduino C++ → ESPHome.**

Firmware behaviour is unchanged in spirit (same hardware, same fan curve,
same captive portal, same safety failsafe), but the entire ~2,000-line C++
codebase is replaced by a single ~200-line YAML file. See
`PROJECT_PLAN.md` "History" for the decision rationale.

### Added
- `SECURITY.md` with release-verification recipe and key-rotation policy.
- `docs/signing_public_key.pem` — public half of the release signing key,
committed so users can verify `.signed.bin` without coordination.
- README "Verifying releases" section linking to the above.

- `fancontrol.yaml` — single-source firmware config driving ESPHome:
- DHT22 on GPIO 4, 5 s polling, last-good caching via ESPHome filters.
- `output.ledc` PWM on GPIO 25, default 1 kHz, runtime-tunable
1000–5000 Hz via `number.template` + `ledc.set_frequency` action.
10-bit resolution auto-computed by ESPHome.
- `Fan Speed` template sensor with a 5-point piecewise-linear
temperature-to-PWM lambda matching the v0.1.x curve exactly.
NaN → 100 %; sensor-stall flag → 100 %.
- `Sensor Alarm` (60 s stall detection) and `Temperature Alarm`
(configurable threshold, default 35 °C) as `binary_sensor.template`
entities with `safety` / `problem` device classes.
- `Restart Counter` persisted via `globals` + `restore_value: true`,
incremented in `on_boot`.
- WiFi STA + open captive-portal AP `FanControl-Setup` via `captive_portal:`.
- mDNS `fancontrol.local`.
- ESPHome native API (`api:` with encryption key) as the default HA
transport. MQTT block left commented out for users who want the
v0.1.x topic shape.
- ESPHome `web_server` on port 80 for the built-in dashboard.
- OTA via `ota:` platform `esphome`, password-gated.
- `status_led` on GPIO 5 (LOLIN D32 onboard LED).
- `button.restart` and `button.factory_reset` entities.
- `secrets.yaml.example` — template for WiFi credentials, API encryption
key, and OTA password. Real `secrets.yaml` gitignored.
- `docs/home_assistant.md` — integration walkthrough, entity table, sample
automations, optional MQTT-with-TLS recipe.
- `.github/workflows/ci.yml` now uses `esphome/build-action@v10` — full
compile gate (not just config lint), size gate unchanged (≤ 1.5 MB).
- `.github/workflows/release.yml` publishes `firmware.bin`,
`firmware.ota.bin`, `firmware.factory.bin`, and their SHA-256 checksums.

### Changed

- **Home Assistant integration transport:** MQTT (with HA auto-discovery)
→ ESPHome native API (encrypted, faster, zero-config on the HA side).
Users who want MQTT can uncomment the `mqtt:` block in `fancontrol.yaml`.
- **Web UI:** custom 4-section dark-themed single-page PROGMEM UI →
ESPHome's built-in `web_server` dashboard. The control surface for most
users becomes Home Assistant itself.
- **Documentation:**
- `README.md` rewritten for the ESPHome workflow.
- `CLAUDE.md` rewritten — architecture rules now about keeping the YAML
idiomatic rather than FreeRTOS task discipline.
- `CONTRIBUTING.md` rewritten — build loop is `esphome config/compile/run`;
release cut is merge `develop` → `main` + tag.
- `PROJECT_PLAN.md` rewritten as v2.0 with an ESPHome-shaped feature list
and a "History" section archiving the v0.1.x custom-C++ approach.
- `SECURITY.md` updated — new OTA + API auth model documented; v0.1.x RSA
signature verification recipe retained for historical artifacts.
- `docs/wiring_diagram.md` kept (pin map unchanged); references to
`src/config.h` updated to `fancontrol.yaml`.
- `docs/mqtt_topics.md` and `docs/api.md` deleted — MQTT is now optional
and documented inline in the YAML; no custom HTTP / WebSocket API
exists under ESPHome.

### Removed

- **Entire custom C++ tree** under `src/` (config, storage, sensor, fan,
fan_curve, watchdog, wifi_manager, mqtt, webserver, websocket,
index_html, version, main). ~2,000 LOC deleted.
- **Unity test scaffold** under `test/` — ESPHome has no comparable unit
test concept; the fan-curve lambda was ported verbatim from the Unity-
tested `fan_curve::computeFromTemperature`, so the logic is unchanged.
- `platformio.ini` — ESPHome handles the PlatformIO build transparently.
- **RSA-signed OTA pipeline.** v0.2.0+ releases ship `firmware.bin` +
SHA-256 only. The `SECRET_RSA_KEY` repo secret remains configured but
unused; `docs/signing_public_key.pem` is kept so v0.1.x signed
binaries remain verifiable. See `SECURITY.md`.

### Migration notes

- **Reflash required.** Pre-v0.2.0 devices store their NVS keys under the
custom `fancontrol` namespace; ESPHome uses its own global-variables
namespace layout. Flashing v0.2.0 over v0.1.x will not carry user
settings forward — reconfigure via the captive portal on first boot.
- **Home Assistant:** remove the MQTT-based device (if you want). Install
the ESPHome integration if you haven't; paste the API key from
`secrets.yaml` when prompted.
- **OTA password** in `secrets.yaml` is the one you chose in v0.2.0;
there is no longer a "changeme" default + first-boot forced-rotation
banner (ESPHome bakes the password at compile time).

## [0.1.1] - 2026-04-19

Expand Down Expand Up @@ -124,6 +211,7 @@ SUCCESS, `pio test -e native` 7/7 PASSED.
- `pio run -e lolin_d32` → SUCCESS, RAM 15.0 %, flash 54.5 %.
- `pio test -e native` → 7 / 7 PASSED.

[Unreleased]: https://github.com/basmeerman/FanControl/compare/v0.1.1...HEAD
[Unreleased]: https://github.com/basmeerman/FanControl/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/basmeerman/FanControl/releases/tag/v0.2.0
[0.1.1]: https://github.com/basmeerman/FanControl/releases/tag/v0.1.1
[0.1.0]: https://github.com/basmeerman/FanControl/releases/tag/v0.1.0
Loading
Loading