#+title: Smart AC IR Remote — M5StickC Plus2 #+options: toc:2 [[https://github.com/mnaza/condition-control/actions/workflows/ci.yml][https://github.com/mnaza/condition-control/actions/workflows/ci.yml/badge.svg]] Rust firmware that turns an M5StickC Plus2 into a Wi-Fi bridge for a Baxi (AUX-made, remote YKR-L/201E) air conditioner: a phone-friendly web UI, MQTT + Home Assistant discovery, a weekly schedule, power saving and on-device diagnostics. The AC is driven over IR with the =ELECTRA_AC=, =COOLIX= or =GREE= protocol (selectable in settings) re-implemented from scratch on the ESP32 RMT peripheral, bit-exact against IRremoteESP8266. * Hardware - *M5StickC Plus2* (ESP32-PICO-V3-02): IR LED on GPIO 19, ST7789V2 135x240 display, BtnA (GPIO 37) = power toggle, BtnB (GPIO 39) = temp cycle, battery ~200 mAh (voltage on GPIO 38, 1:2 divider), power HOLD on GPIO 4. - *AC*: Baxi / Rusklimat, remote *YKR-L/201E* — the AUX "YKR" family, =ELECTRA_AC= 13-byte frames. ** Protocol notes (found the hard way) - The AC ignores the stock IRremoteESP8266 power-off frame. Live-confirmed fix: byte 11 must be =0x05= (not the library default =0x08=) in OFF frames. See =firmware-stick-rs/ac-core/src/lib.rs= (~electra_frame~, =OFF_VARIANT_DEFAULT=); the web UI still allows overriding the encoding variant under Settings. - AC remotes are stateless receivers: every frame carries the FULL state (power, mode, temp, fan, swing) — never record-and-replay buttons. - Frame: header 9166/4470 µs, bit mark 646 µs, one/zero space 1647/547 µs, LSB-first, 38 kHz carrier, byte 12 = sum of bytes 0–11. * Repository layout #+begin_example firmware-stick-rs/ Rust firmware (esp-idf-svc, std) ac-core/ pure domain crate — host-tested (cargo +stable test) src/ main, ir (RMT), net (WiFi/NVS/MQTT), web, ui #+end_example The original Arduino/C++ firmware and its helper sketches (protocol tester, IR sniffer) were removed; they live in git history (=git log -- firmware-stick tools=). * Building & flashing One-time toolchain (Xtensa Rust via [[https://github.com/esp-rs/espup][espup]], plus =espflash= and =ldproxy= on =PATH=): #+begin_src sh espup install --targets esp32 #+end_src Build, flash, test: #+begin_src sh cd firmware-stick-rs source ~/export-esp.sh cargo build --release espflash flash --monitor target/xtensa-esp32-espidf/release/firmware-stick-rs # host tests for the pure core (frames, schedule, battery, parsing): cd ac-core && cargo +stable test #+end_src The first build downloads and compiles ESP-IDF into =.embuild/= (~2 GB, 15–30 min); subsequent builds take seconds. ** OTA updates The flash uses an A/B layout (=partitions.csv=: two 3 MB app slots), so after the initial serial flash all further updates go over Wi-Fi: #+begin_src sh # initial flash (or whenever the partition table changes): espflash flash --partition-table partitions.csv --port /dev/ttyACM0 \ target/xtensa-esp32-espidf/release/firmware-stick-rs # every update after that — build an app image and upload it: espflash save-image --chip esp32 \ target/xtensa-esp32-espidf/release/firmware-stick-rs /tmp/fw.bin curl --data-binary @/tmp/fw.bin http://<device-ip>/api/ota #+end_src Or simply pick the =.bin= in *Settings → Firmware update (OTA)* on the web page — with an upload progress bar. The device writes the image into the inactive slot (the bootloader validates it), reboots into it and keeps all NVS settings; Diagnostics shows which slot is running. *** Update from GitHub releases Easier still: every push to =main= makes CI build the firmware and publish a version-tagged GitHub release with the OTA image (skipped when the =Cargo.toml= version is already tagged). The *⬆️ Update from GitHub* button in Settings makes the device fetch the latest release over TLS, compare versions and flash itself if a newer one exists — progress is shown right on the page (=POST /api/update= / =/api/update/status= for scripts). So a routine upgrade is: bump the version, merge to main, press the button. Releases are *signed*: CI publishes a =manifest.json= next to the image (Ed25519 over version/target/size/sha256, key in the =OTA_SIGNING_KEY= repo secret, signer in =tools/sign-manifest=). The device verifies the signature, target, exact size and SHA-256 against its embedded public key before booting anything — a release without a valid manifest is refused. The manual =/api/ota= upload stays as the password-protected unsigned escape hatch for dev builds and recovery; if the signing key is ever lost, ship a new public key through it once. * First-time setup No credentials are compiled in. On a blank device: 1. It opens the Wi-Fi AP *AC-Remote*. The password is generated per-device (since v0.3.14) and the display switches to a provisioning screen: SSID, password and a QR code — scan it with the phone camera to join the AP directly (v0.3.16). Reading the screen is the ownership proof. (A configured device can be sent back into AP mode once via the 📡 button in the Wi-Fi modal; a normal power-cycle returns it to your network.) 2. Join it and open http://192.168.71.1 — you can control the AC right away, or open *Settings*, scan for your network, enter the password and save; the device reboots onto your LAN. 3. Its IP is shown on the display; everything (MQTT broker, schedule, OFF-frame variant) is configured from the web page. NVS survives reflashing. * Web UI Bilingual (RU/EN, auto-detected from the browser, toggle persists), icon-based controls, big temperature dial, mode-colored buttons. Sections: - *Schedule* (⏰) — up to 8 rules: time + weekday mask → power on/off. Applied without reboot, stored in NVS. Clock comes from SNTP; on save the browser also derives a POSIX timezone rule (v0.3.22), so DST transitions are handled automatically — no re-saving after clock changes. After a power gap, missed rules are replayed from at most the trailing 3 hours. - *Settings* (⚙️) — IR protocol (=ELECTRA= for AUX/Baxi/Rusklimat YKR remotes — the live-tested default; =COOLIX= for Midea & many OEMs; =GREE=), OFF-frame encoding variant (Electra only), Wi-Fi with a network scanner (fan-shaped signal icons, tap to pick), MQTT broker, and *Diagnostics* (🩺): uptime, last reset reason (=brownout= means the battery died), heap free/min, RSSI, device time, IR frame counter. - *Web password* (🔒, in Settings) — HTTP Basic Auth for the page and the whole API. Off until you set a password (up to 64 bytes), so set one *before* putting the device on a shared LAN — the open UI lets anyone set it first. The browser asks once and remembers; username is ignored (=curl -u x:PASSWORD=). Saving an empty field removes it; holding *BtnB while powering on* clears a forgotten password. MQTT and the GitHub updater are separate channels and unaffected. No TLS — credentials travel in the clear on your LAN. * HTTP API All endpoints require Basic Auth once a web password is set (=curl -u x:PASSWORD ...=); without one they are open. Everything that *changes* state is POST-only (since v0.3.13 — GETs like the old =/api/set?power=on= answer 405), and browser requests must be same-origin: a cross-site POST carries a foreign =Origin= header and is rejected with 403, which is what stops CSRF against cached browser credentials. Requests without an =Origin= header (curl, scripts, Home Assistant) pass as before, e.g.: =curl -u x:PW -X POST -d 'power=on&temp=24' http://<ip>/api/set= (If you ever front the device with a TLS reverse proxy, have the proxy strip or rewrite the =Origin= header — an =https://= origin is rejected.) | Endpoint | Method | Purpose | |-------------------+----------+------------------------------------------------| | =/= | GET | the web page | | =/api/status= | GET | state + battery JSON | | =/api/set= | POST | apply =power/mode/temp/fan/swing= form fields | | =/api/offvariant= | POST | =v=0..3= — OFF-frame encoding (NVS) | | =/api/protocol= | POST | =p=electra/coolix/gree= — IR protocol (NVS) | | =/api/scan= | GET | surrounding networks (ssid/rssi/sec) | | =/api/wifi= | POST | save STA credentials, reboot | | =/api/mqtt= | GET/POST | broker settings (empty host disables MQTT) | | =/api/schedule= | GET/POST | scheduler rules + timezone | | =/api/health= | GET | uptime/reset/heap/RSSI/time/IR counter/slot | | =/api/ota= | POST | raw app image → inactive OTA slot, reboot | | =/api/webauth= | POST | set/clear the web password (=password= field) | | =/api/update= | POST | start a GitHub-release update (see OTA above) | | =/api/apmode= | POST | reboot into the fallback AP once (next power-cycle returns to Wi-Fi) | * MQTT / Home Assistant The reported state is *assumed*, not sensed: IR is one-way, so the device shows the last state it transmitted. If the AC misses a frame or the original remote is used, they drift apart — the *📤 Resend* button in Settings (or =POST /api/resend=) pushes the full frame again. HA sees a =state_source: assumed= attribute on the climate entity. With a broker configured the device announces itself via MQTT discovery (=homeassistant/climate/…=) and appears in HA as a *climate* entity: modes off/auto/cool/dry/fan_only/heat, 16–32 °C, fan auto/low/medium/high, vertical swing, availability via LWT. Raw topics (usable without HA): =<DEVICE_ID>/mode|temp|fan|swing/set=, matching =…/state= (retained), =<DEVICE_ID>/availability=. * Power & battery - Wi-Fi max modem sleep (STA mode) + automatic light sleep with CPU frequency scaling (160→40 MHz) — the RMT driver holds a PM lock during IR transmission, so timings stay exact. - Backlight off after 30 s idle; the waking press is not forwarded to the AC. - Battery percent (LiPo discharge curve) and a runtime estimate (200 mAh / ~25 mA average) on the page, the display and =/api/status=. Charging is detected from voltage /steps/ between samples, not absolute thresholds — this unit's ADC reads ~50 mV high. - The AP fallback mode cannot save power (an AP must beacon); join a real network for battery use. * Design notes - ~AcState~ is the single source of truth; any change (button, web, MQTT, schedule) re-sends the full IR frame after a 300 ms debounce that coalesces bursts (e.g. HA slider drags) into one transmission. - All pure logic (frames, HA payloads, schedule math, battery model, form parsing) lives in the dependency-free =ac-core= crate with host unit tests; the firmware crate is thin hardware glue. - The device keeps working as a local remote with no Wi-Fi at all.
Not written in Markdown, so it's shown here as plain text — view it formatted on GitHub.
95 commits
Rust
81.4%
HTML
18.6%
#+title: Smart AC IR Remote — M5StickC Plus2 #+options: toc:2 [[https://github.com/mnaza/condition-control/actions/workflows/ci.yml][https://github.com/mnaza/condition-control/actions/workflows/ci.yml/badge.svg]] Rust firmware that turns an M5StickC Plus2 into a Wi-Fi bridge for a Baxi (AUX-made, remote YKR-L/201E) air conditioner: a phone-friendly web UI, MQTT + Home Assistant discovery, a weekly schedule, power saving and on-device diagnostics. The AC is driven over IR with the =ELECTRA_AC=, =COOLIX= or =GREE= protocol (selectable in settings) re-implemented from scratch on the ESP32 RMT peripheral, bit-exact against IRremoteESP8266. * Hardware - *M5StickC Plus2* (ESP32-PICO-V3-02): IR LED on GPIO 19, ST7789V2 135x240 display, BtnA (GPIO 37) = power toggle, BtnB (GPIO 39) = temp cycle, battery ~200 mAh (voltage on GPIO 38, 1:2 divider), power HOLD on GPIO 4. - *AC*: Baxi / Rusklimat, remote *YKR-L/201E* — the AUX "YKR" family, =ELECTRA_AC= 13-byte frames. ** Protocol notes (found the hard way) - The AC ignores the stock IRremoteESP8266 power-off frame. Live-confirmed fix: byte 11 must be =0x05= (not the library default =0x08=) in OFF frames. See =firmware-stick-rs/ac-core/src/lib.rs= (~electra_frame~, =OFF_VARIANT_DEFAULT=); the web UI still allows overriding the encoding variant under Settings. - AC remotes are stateless receivers: every frame carries the FULL state (power, mode, temp, fan, swing) — never record-and-replay buttons. - Frame: header 9166/4470 µs, bit mark 646 µs, one/zero space 1647/547 µs, LSB-first, 38 kHz carrier, byte 12 = sum of bytes 0–11. * Repository layout #+begin_example firmware-stick-rs/ Rust firmware (esp-idf-svc, std) ac-core/ pure domain crate — host-tested (cargo +stable test) src/ main, ir (RMT), net (WiFi/NVS/MQTT), web, ui #+end_example The original Arduino/C++ firmware and its helper sketches (protocol tester, IR sniffer) were removed; they live in git history (=git log -- firmware-stick tools=). * Building & flashing One-time toolchain (Xtensa Rust via [[https://github.com/esp-rs/espup][espup]], plus =espflash= and =ldproxy= on =PATH=): #+begin_src sh espup install --targets esp32 #+end_src Build, flash, test: #+begin_src sh cd firmware-stick-rs source ~/export-esp.sh cargo build --release espflash flash --monitor target/xtensa-esp32-espidf/release/firmware-stick-rs # host tests for the pure core (frames, schedule, battery, parsing): cd ac-core && cargo +stable test #+end_src The first build downloads and compiles ESP-IDF into =.embuild/= (~2 GB, 15–30 min); subsequent builds take seconds. ** OTA updates The flash uses an A/B layout (=partitions.csv=: two 3 MB app slots), so after the initial serial flash all further updates go over Wi-Fi: #+begin_src sh # initial flash (or whenever the partition table changes): espflash flash --partition-table partitions.csv --port /dev/ttyACM0 \ target/xtensa-esp32-espidf/release/firmware-stick-rs # every update after that — build an app image and upload it: espflash save-image --chip esp32 \ target/xtensa-esp32-espidf/release/firmware-stick-rs /tmp/fw.bin curl --data-binary @/tmp/fw.bin http://<device-ip>/api/ota #+end_src Or simply pick the =.bin= in *Settings → Firmware update (OTA)* on the web page — with an upload progress bar. The device writes the image into the inactive slot (the bootloader validates it), reboots into it and keeps all NVS settings; Diagnostics shows which slot is running. *** Update from GitHub releases Easier still: every push to =main= makes CI build the firmware and publish a version-tagged GitHub release with the OTA image (skipped when the =Cargo.toml= version is already tagged). The *⬆️ Update from GitHub* button in Settings makes the device fetch the latest release over TLS, compare versions and flash itself if a newer one exists — progress is shown right on the page (=POST /api/update= / =/api/update/status= for scripts). So a routine upgrade is: bump the version, merge to main, press the button. Releases are *signed*: CI publishes a =manifest.json= next to the image (Ed25519 over version/target/size/sha256, key in the =OTA_SIGNING_KEY= repo secret, signer in =tools/sign-manifest=). The device verifies the signature, target, exact size and SHA-256 against its embedded public key before booting anything — a release without a valid manifest is refused. The manual =/api/ota= upload stays as the password-protected unsigned escape hatch for dev builds and recovery; if the signing key is ever lost, ship a new public key through it once. * First-time setup No credentials are compiled in. On a blank device: 1. It opens the Wi-Fi AP *AC-Remote*. The password is generated per-device (since v0.3.14) and the display switches to a provisioning screen: SSID, password and a QR code — scan it with the phone camera to join the AP directly (v0.3.16). Reading the screen is the ownership proof. (A configured device can be sent back into AP mode once via the 📡 button in the Wi-Fi modal; a normal power-cycle returns it to your network.) 2. Join it and open http://192.168.71.1 — you can control the AC right away, or open *Settings*, scan for your network, enter the password and save; the device reboots onto your LAN. 3. Its IP is shown on the display; everything (MQTT broker, schedule, OFF-frame variant) is configured from the web page. NVS survives reflashing. * Web UI Bilingual (RU/EN, auto-detected from the browser, toggle persists), icon-based controls, big temperature dial, mode-colored buttons. Sections: - *Schedule* (⏰) — up to 8 rules: time + weekday mask → power on/off. Applied without reboot, stored in NVS. Clock comes from SNTP; on save the browser also derives a POSIX timezone rule (v0.3.22), so DST transitions are handled automatically — no re-saving after clock changes. After a power gap, missed rules are replayed from at most the trailing 3 hours. - *Settings* (⚙️) — IR protocol (=ELECTRA= for AUX/Baxi/Rusklimat YKR remotes — the live-tested default; =COOLIX= for Midea & many OEMs; =GREE=), OFF-frame encoding variant (Electra only), Wi-Fi with a network scanner (fan-shaped signal icons, tap to pick), MQTT broker, and *Diagnostics* (🩺): uptime, last reset reason (=brownout= means the battery died), heap free/min, RSSI, device time, IR frame counter. - *Web password* (🔒, in Settings) — HTTP Basic Auth for the page and the whole API. Off until you set a password (up to 64 bytes), so set one *before* putting the device on a shared LAN — the open UI lets anyone set it first. The browser asks once and remembers; username is ignored (=curl -u x:PASSWORD=). Saving an empty field removes it; holding *BtnB while powering on* clears a forgotten password. MQTT and the GitHub updater are separate channels and unaffected. No TLS — credentials travel in the clear on your LAN. * HTTP API All endpoints require Basic Auth once a web password is set (=curl -u x:PASSWORD ...=); without one they are open. Everything that *changes* state is POST-only (since v0.3.13 — GETs like the old =/api/set?power=on= answer 405), and browser requests must be same-origin: a cross-site POST carries a foreign =Origin= header and is rejected with 403, which is what stops CSRF against cached browser credentials. Requests without an =Origin= header (curl, scripts, Home Assistant) pass as before, e.g.: =curl -u x:PW -X POST -d 'power=on&temp=24' http://<ip>/api/set= (If you ever front the device with a TLS reverse proxy, have the proxy strip or rewrite the =Origin= header — an =https://= origin is rejected.) | Endpoint | Method | Purpose | |-------------------+----------+------------------------------------------------| | =/= | GET | the web page | | =/api/status= | GET | state + battery JSON | | =/api/set= | POST | apply =power/mode/temp/fan/swing= form fields | | =/api/offvariant= | POST | =v=0..3= — OFF-frame encoding (NVS) | | =/api/protocol= | POST | =p=electra/coolix/gree= — IR protocol (NVS) | | =/api/scan= | GET | surrounding networks (ssid/rssi/sec) | | =/api/wifi= | POST | save STA credentials, reboot | | =/api/mqtt= | GET/POST | broker settings (empty host disables MQTT) | | =/api/schedule= | GET/POST | scheduler rules + timezone | | =/api/health= | GET | uptime/reset/heap/RSSI/time/IR counter/slot | | =/api/ota= | POST | raw app image → inactive OTA slot, reboot | | =/api/webauth= | POST | set/clear the web password (=password= field) | | =/api/update= | POST | start a GitHub-release update (see OTA above) | | =/api/apmode= | POST | reboot into the fallback AP once (next power-cycle returns to Wi-Fi) | * MQTT / Home Assistant The reported state is *assumed*, not sensed: IR is one-way, so the device shows the last state it transmitted. If the AC misses a frame or the original remote is used, they drift apart — the *📤 Resend* button in Settings (or =POST /api/resend=) pushes the full frame again. HA sees a =state_source: assumed= attribute on the climate entity. With a broker configured the device announces itself via MQTT discovery (=homeassistant/climate/…=) and appears in HA as a *climate* entity: modes off/auto/cool/dry/fan_only/heat, 16–32 °C, fan auto/low/medium/high, vertical swing, availability via LWT. Raw topics (usable without HA): =<DEVICE_ID>/mode|temp|fan|swing/set=, matching =…/state= (retained), =<DEVICE_ID>/availability=. * Power & battery - Wi-Fi max modem sleep (STA mode) + automatic light sleep with CPU frequency scaling (160→40 MHz) — the RMT driver holds a PM lock during IR transmission, so timings stay exact. - Backlight off after 30 s idle; the waking press is not forwarded to the AC. - Battery percent (LiPo discharge curve) and a runtime estimate (200 mAh / ~25 mA average) on the page, the display and =/api/status=. Charging is detected from voltage /steps/ between samples, not absolute thresholds — this unit's ADC reads ~50 mV high. - The AP fallback mode cannot save power (an AP must beacon); join a real network for battery use. * Design notes - ~AcState~ is the single source of truth; any change (button, web, MQTT, schedule) re-sends the full IR frame after a 300 ms debounce that coalesces bursts (e.g. HA slider drags) into one transmission. - All pure logic (frames, HA payloads, schedule math, battery model, form parsing) lives in the dependency-free =ac-core= crate with host unit tests; the firmware crate is thin hardware glue. - The device keeps working as a local remote with no Wi-Fi at all.
Not written in Markdown, so it's shown here as plain text — view it formatted on GitHub.
95 commits
Rust
81.4%
HTML
18.6%