From e409c3da4812860040c810b7dc1eb6c964183b96 Mon Sep 17 00:00:00 2001 From: RockBase-Ronnie Date: Thu, 24 Sep 2026 17:08:10 +0800 Subject: [PATCH 1/4] Support RockBase IoT NM-EPD-420-4C with EP42YR_400x300 --- devices/nm-epd-420-4c.yaml | 149 +++++++++++ devices/nm-epd-420.simple-config-preset.json | 107 ++++++++ docs/DEVICE_NM_EPD_420_4C.md | 267 +++++++++++++++++++ include/opendisplay_structs.h | 20 +- src/config_parser.cpp | 8 + src/display_service.cpp | 16 ++ src/main.cpp | 53 ++++ src/main.h | 3 + src/sensor_aht20.cpp | 100 +++++++ src/sensor_aht20.h | 6 + src/sensor_sht40.cpp | 8 +- src/sensor_sht40.h | 4 + tools/od-device-cli.py | 2 +- 13 files changed, 734 insertions(+), 9 deletions(-) create mode 100644 devices/nm-epd-420-4c.yaml create mode 100644 devices/nm-epd-420.simple-config-preset.json create mode 100644 docs/DEVICE_NM_EPD_420_4C.md create mode 100644 src/sensor_aht20.cpp create mode 100644 src/sensor_aht20.h diff --git a/devices/nm-epd-420-4c.yaml b/devices/nm-epd-420-4c.yaml new file mode 100644 index 0000000..c10025d --- /dev/null +++ b/devices/nm-epd-420-4c.yaml @@ -0,0 +1,149 @@ +# NM-EPD-420-4C — OpenDisplay device config (RockBase IoT, ESP32-S3) +# +# 4.2" four-colour (B/W/R/Y) variant. Same PCB and same pin map as the +# tri-colour NM-EPD-420 and the B/W NM-EPD-420-BW; only the fitted panel +# differs, so only `panel_ic_type` / `color_scheme` / `full_update_mC` change +# between the three variants. +# +# Encode to a config packet and flash it in, either as a factory config: +# +# uv run tools/od-device-cli.py encode-config --input devices/nm-epd-420-4c.yaml +# OPENDISPLAY_FACTORY_CONFIG_HEX="" pio run -e esp32-s3-N16R8 +# +# or over BLE onto a running device: +# +# uv run tools/od-device-cli.py write-config --addr AA:BB:CC:DD:EE:FF \ +# --input devices/nm-epd-420-4c.yaml +# +# Build target: esp32-s3-N16R8 (ESP32-S3, 16 MB QIO flash, 8 MB OPI PSRAM). +# No dedicated env: the NM-EPD-420 is exactly what that env already targets. +version: 1 + +system_config: + ic_type: 2 # OD_IC_TYPE_ESP32S3 + communication_modes: 0x1 # BLE. Wi-Fi/LAN would add 0x4 (esp32-s3-N16R8 builds OPENDISPLAY_ENABLE_WIFI). + # bit5 (0x20) = OD_DEVICE_FLAG_NM420_INIT: firmware drives the board's unused + # peripheral enable/gate pins LOW right after config load. The NM-EPD-420 fits + # a LoRa module, an ES8311 codec, an audio PA and an AHT20, all active-HIGH + # enabled (GPIO47 MODULE_EN, GPIO44 CODEC_EN, GPIO41 PA_CTRL, GPIO40 TEMP_CTL) + # LoRa/audio remain unused; AHT20 is powered only for a wake-time measurement. + # Without the flag these gates would be left at the + # ESP32 reset default (input, no pull) and float the rails on. + # GPIO43 (ADC_EN) also defaults LOW; battery sampling pulses it HIGH briefly. + device_flags: 0x20 # bit5 = NM420 init; no display power switch, no XIAO/WS init, no battery latch + pwr_pin: 0xFF + pwr_pin_2: 0xFF + pwr_pin_3: 0xFF + +manufacturer_data: + manufacturer_id: 0 # OD_MANUFACTURER_DIY — RockBase is not in the ManufacturerId table yet + board_type: 0 + board_revision: 1 + # simple_config_* indices are written by the opendisplay.org toolbox when a + # user picks this board there; left at 0 for a hand-flashed config. + +power_option: + power_mode: 1 # OD_POWER_MODE_BATTERY + battery_capacity_mah: 600 + sleep_timeout_ms: 40000 + tx_power: 8 + sleep_flags: 0x0 # Allow configured button wake; GPIO0 BOOT is an ESP32-S3 RTC wake pin + # Battery sense: GPIO3 through a 1:2 divider, gated by GPIO43 (PIN_ADC_EN, + # active HIGH). ESP32-S3 ADC is 12-bit @ 11 dB (~3.3 V full scale), so + # scaling = 3.3 V / 4095 counts * 2 (divider) = 0.001612 V/count + # -> voltage_scaling_factor = 161, the same value the M5Stack PaperS3 + # preset uses (identical pin + identical divider). + battery_sense_pin: 3 + battery_sense_enable_pin: 43 + battery_sense_flags: 0x0 + capacity_estimator: 1 # OD_CAPACITY_EST_LI_ION + voltage_scaling_factor: 161 + deep_sleep_current_ua: 0 + deep_sleep_time_seconds: 1800 # 30-minute timer fallback; GPIO0 BOOT also wakes from deep sleep + # No charger GPIOs are exposed on the NM-EPD-420 (charge management is + # standalone on-board), so no charge_enable_pin / charge_state_pin. + charge_enable_pin: 0xFF + charge_state_pin: 0xFF + charger_flags: 0x0 + screen_timeout_seconds: 0 # 4-colour panel: power the panel straight down after a refresh + +displays: + - instance_number: 0 + display_technology: 1 # OD_DISPLAY_TECH_E_PAPER + # 58 = OD_PANEL_IC_EP42YR_400X300 ("4.2\" BWRY 400x300", bb_epaper + # EP42YR_400x300 - the Good Display 4.2" four-colour class: JD79668 / + # GDEM042F52, and the GDEY0420F51 (HX8717) it replaces). Same value the + # opendisplay.org preset `ep42yr-400x300` (index 11) carries. + panel_ic_type: 58 + pixel_width: 400 + pixel_height: 300 + # Panel datasheet active area is 84.8 x 63.6 mm. Deliberately rounded the + # same way the opendisplay.org preset `ep42yr-400x300` rounds it, so a device + # flashed from this file and a device configured from the preset agree + # byte-for-byte. + active_width_mm: 84 + active_height_mm: 63 + legacy_tag_type: 0 + rotation: 0 + reset_pin: 5 + busy_pin: 6 + dc_pin: 4 + cs_pin: 46 + data_pin: 1 + clk_pin: 2 + cs_pin_2: 0xFF + # Four-colour panels have no usable partial refresh (25-30 s full update, + # and the protocol's partial path is 1bpp-only anyway). + partial_update_support: 0 + color_scheme: 3 # OD_COLOR_SCHEME_BWRY -> 2 bpp + # streaming decompression (bit0) | direct write (bit3) | pipe write (bit4) + transmission_modes: 25 + full_update_mC: 165 + +data_buses: + # AHT20 (0x38) and the disabled ES8311 (0x18) share this bus. + - instance_number: 0 + bus_type: 1 # OD_BUS_TYPE_I2C + pin_1: 38 # SCL + pin_2: 39 # SDA + pin_3: 0xFF + pin_4: 0xFF + pin_5: 0xFF + pin_6: 0xFF + pin_7: 0xFF + bus_speed_hz: 400000 + bus_flags: 0x0 + pullups: 0x0 + pulldowns: 0x0 + +sensors: + - instance_number: 0 + sensor_type: 7 # OD_SENSOR_TYPE_AHT20; 6 is nPM1300 on other firmware + bus_id: 0 + i2c_addr_7bit: 0x38 + msd_data_start_byte: 7 # 3-byte SHT40-compatible temperature/humidity snapshot + +binary_inputs: + # USER key on GPIO45, active LOW with an external pull-up. + # BOOT key on GPIO0 is also active LOW. It is an ESP32-S3 ROM-download + # strap, so keep it released at reset; it is otherwise valid for RTC wake. + # button_data_byte_index 0 publishes the button state into MSD dynamic byte 0; + # >10 disables the input entirely. + - instance_number: 0 + input_type: 1 # OD_INPUT_TYPE_BUTTON + display_as: 1 # OD_INPUT_DISPLAY_AS_BUTTON + input_pin_1: 45 + input_pin_2: 0 + input_pin_3: 0xFF + input_pin_4: 0xFF + input_pin_5: 0xFF + input_pin_6: 0xFF + input_pin_7: 0xFF + input_pin_8: 0xFF + pins_used: 0x3 + invert: 0x3 # pressed = LOW + pullups: 0x0 + pulldowns: 0x0 + button_data_byte_index: 0 + power_off_flags: 0x0 + power_off_hold_sec: 0 diff --git a/devices/nm-epd-420.simple-config-preset.json b/devices/nm-epd-420.simple-config-preset.json new file mode 100644 index 0000000..f8721ef --- /dev/null +++ b/devices/nm-epd-420.simple-config-preset.json @@ -0,0 +1,107 @@ +{ + "_note": "Fragment to append to opendisplay.org httpdocs/firmware/toolbox/simple-config-presets.json (repo OpenDisplay/opendisplay.org). Merge the driverBoards entry into driverBoards[], and the id->index entries into httpdocs/firmware/toolbox/simple-config-id-registry.json in the same change. The display preset it points at already exists: ep42yr-400x300 (index 11). No new powerOptions entry is needed.", + "driverBoards": [ + { + "id": "nm-epd-420-4c", + "name": "RockBase IoT NM-EPD-420 (ESP32-S3)", + "connectorPins": [ + 24 + ], + "systemConfig": { + "ic_type": "2", + "communication_modes": "0x1", + "device_flags": "0x20", + "pwr_pin": "0xff", + "pwr_pin_2": "0xff", + "pwr_pin_3": "0xff", + "reserved": "0x0" + }, + "manufacturerData": { + "manufacturer_id": "0", + "board_type": "0", + "board_revision": "0x1", + "reserved": "0x0" + }, + "displayPins": { + "reset": "5", + "busy": "6", + "dc": "4", + "cs": "46", + "data": "1", + "clk": "2", + "cs2": "0xff" + }, + "powerDefaults": { + "battery_capacity_mah": "600", + "sleep_timeout_ms": "40000", + "sleep_flags": "0x0", + "battery_sense_pin": "3", + "battery_sense_enable_pin": "43", + "battery_sense_flags": "0x0", + "capacity_estimator": "1", + "voltage_scaling_factor": "161", + "deep_sleep_current_ua": "0", + "deep_sleep_time_seconds": "1800" + }, + "buttons": { + "instance_number": "0x0", + "input_type": "1", + "display_as": "1", + "inputpin1": "45", + "inputpin2": "0", + "inputpin3": "0xff", + "inputpin4": "0xff", + "inputpin5": "0xff", + "inputpin6": "0xff", + "inputpin7": "0xff", + "inputpin8": "0xff", + "isused": "0x3", + "invert": "0x3", + "pullups": "0x0", + "pulldowns": "0x0", + "button_data_byte_index": "0", + "power_off_flags": "0x0", + "power_off_hold_sec": "0", + "reserved": "0x0" + }, + "dataBus": { + "instance_number": "0x0", + "bus_type": "1", + "pin_1": "38", + "pin_2": "39", + "pin_3": "0xff", + "pin_4": "0xff", + "pin_5": "0xff", + "pin_6": "0xff", + "pin_7": "0xff", + "bus_speed_hz": "0x61a80", + "bus_flags": "0x0", + "pullups": "0x0", + "pulldowns": "0x0", + "reserved": "0x0" + }, + "sensorData": { + "instance_number": "0", + "sensor_type": "7", + "bus_id": "0", + "i2c_addr_7bit": "0x38", + "msd_data_start_byte": "7" + }, + "defaultDisplay": "ep42yr-400x300", + "defaultPower": "battery-650", + "installConfig": { + "type": "esp32", + "manifest": "https://opendisplay.org/firmware/toolbox/bin/esp32-s3-N16R8_full.json", + "firmwareLetter": "O", + "githubRepo": "https://github.com/OpenDisplay/Firmware" + }, + "transmission_modes": "25", + "index": 32 + } + ], + "idRegistry": { + "driverBoards": { + "nm-epd-420-4c": 32 + } + } +} diff --git a/docs/DEVICE_NM_EPD_420_4C.md b/docs/DEVICE_NM_EPD_420_4C.md new file mode 100644 index 0000000..31827d8 --- /dev/null +++ b/docs/DEVICE_NM_EPD_420_4C.md @@ -0,0 +1,267 @@ +# NM-EPD-420-4C — device support notes + +RockBase IoT **NM-EPD-420-4C**: ESP32-S3 board, 4.2" **400×300 four-colour +(B/W/R/Y)** e-paper, 25–30 s full refresh, no partial refresh. + +Upstream hardware repo: +(that repo ships factory *test* firmware, not an application firmware). + +Status: **uncommitted work in progress on branch `nm-epd-420`**. The current +firmware has been exercised on an NM-EPD-420-4C: full-screen refresh, physical +buttons, deep-sleep BOOT wake, and battery reporting work. AHT20 advertisement +data requires a host-side compatibility update before Home Assistant can expose +temperature and humidity entities. Read [Remaining Hardware Checks](#6-remaining-hardware-checks) +before treating this as a production configuration. + +--- + +## 1. Build target — no new PlatformIO env + +The board is ESP32-S3 with **16 MB QIO flash + OPI PSRAM**, which is exactly what +`esp32-s3-N16R8` already describes. Per `CLAUDE.md` ("do not just create new pio +targets for new devices — check whether the existing ones work with a new +config"), no env was added: + +```bash +pio run -e esp32-s3-N16R8 +``` + +Everything device-specific — panel, pins, colour scheme, battery divider — lives +in the runtime config packet, not in build flags. The only build-time choice that +could differ is Wi-Fi: `esp32-s3-N16R8` builds with `-DOPENDISPLAY_ENABLE_WIFI` +(LAN transport). Set `communication_modes` to `0x5` (BLE|WiFi) if you want it. + +## 2. The panel, and why the PanelIC value is `58` + +Three names describe the same panel class, so it is worth writing down which one +this firmware uses. + +| Layer | Name | Notes | +|---|---|---| +| Fitted panel | **GDEY0420F51** (a.k.a. GDEY042F51) | Good Display 4.2" B/W/R/Y, controller **HX8717**, 400×300, 84.8×63.6 mm active, full update 28 s, EOL | +| Panel's own successor | **GDEM042F52** | Good Display's announced replacement for the GDEY042F51; controller **JD79668** | +| bb_epaper / OpenDisplay | `EP42YR_400x300` = `OD_PANEL_IC_EP42YR_400X300` = **58** | documented in-tree as "GDEM042F52" | +| M3 / EPD-nRF5 driver line | `OD_PANEL_IC_JD79668_420_BWRY` = **1004** | same panel, named after the controller instead of after the bb_epaper panel id | + +The existing bb_epaper panel ID is a candidate for the fitted HX8717, not a +verified drop-in replacement for its GxEPD2 driver: + +1. Good Display publishes **GDEM042F52 as the drop-in upgrade for GDEY042F51** + ("we recommend upgrading to our new model GDEM042F52"), which only makes sense + if the interface and command set carry over. +2. `GxEPD2_420c_GDEY0420F51::_InitDisplay()` and bb_epaper's + `epd42yr_init_full` share a controller family and 400×300 TRES, but the + latter omits several power/timing registers and uses different BTST values. +3. `src/display_service.cpp: mapEpd()` already maps `0x003A` to + `EP42YR_400x300`, so a software path exists; it has not been tested with + this HX8717 panel. + +For a first hardware trial, no framebuffer or new build target is required; +the panel is addressed as `panel_ic_type: 58` — +the same value the opendisplay.org preset `ep42yr-400x300` (index 11) carries. + +### Firmware change made on this branch + +One `case` in `src/display_service.cpp` (`mapEpd()`), mapping the M3-line +controller-named value onto the same bb_epaper panel: + +```c +case OD_PANEL_IC_JD79668_420_BWRY: return EP42YR_400x300; // 1004 +``` + +Before this, a device configured with `panel_ic_type: 1004` silently fell through +to `EP_PANEL_UNDEFINED` → "No display found". Nothing else in the render or +transfer path needed touching: `getBitsPerPixel()` already returns 2 for +`OD_COLOR_SCHEME_BWRY`, and the partial-refresh handlers already NACK any +`bpp != 1` panel, which matches the panel's "no partial refresh" reality. + +## 3. Pin map + +From `src/config.h` in the hardware repo, cross-checked against +`docs/sch/SCH_NM-EPD-420-V1.0.pdf`. + +| Function | GPIO | Config field | +|---|---|---| +| EPD SCK | 2 | `displays[0].clk_pin` | +| EPD MOSI | 1 | `displays[0].data_pin` | +| EPD CS | 46 | `displays[0].cs_pin` | +| EPD DC | 4 | `displays[0].dc_pin` | +| EPD RST | 5 | `displays[0].reset_pin` | +| EPD BUSY | 6 | `displays[0].busy_pin` | +| Battery ADC | 3 | `power_option.battery_sense_pin` | +| Battery ADC enable (HIGH) | 43 | `power_option.battery_sense_enable_pin` | +| I²C SCL (AHT20 / ES8311) | 38 | `data_buses[0].pin_1` | +| I²C SDA | 39 | `data_buses[0].pin_2` | +| USER key (active LOW) | 45 | `binary_inputs[0].input_pin_1` | +| BOOT key (active LOW) | 0 | `binary_inputs[0].input_pin_2`; ESP32-S3 ROM download strap and deep-sleep wake | +| SD/LoRa SPI | 7,8,9,10,11,12,13,14 | not OpenDisplay config concepts, never claimed | +| Module enables, all active HIGH | 47 LoRa EN, 44 codec EN, 41 audio PA, 40 AHT20 gate | flag bit 5 defaults them LOW; AHT20 briefly HIGH at wake, see §5 | + +`battery_sense_pin: 3` with a 1:2 divider → `voltage_scaling_factor: 161` +(`3.3 V / 4095 counts × 2 = 0.001612 V/count`). That is the same pin, divider and +factor the M5Stack PaperS3 preset uses, which is the cross-check. + +## 4. Files on this branch + +- `devices/nm-epd-420-4c.yaml` — full device config in `tools/od-device-cli.py` + YAML form. It currently encodes to a **229-byte** packet (version 1, CRC-16 over + the body); verified by a decode→encode round-trip. +- `devices/nm-epd-420.simple-config-preset.json` — the fragment to merge into + `OpenDisplay/opendisplay.org`'s + `httpdocs/firmware/toolbox/simple-config-presets.json` plus the matching + `simple-config-id-registry.json` entry. `driverBoards` index **32**; the display + preset is the existing `ep42yr-400x300` (11); power reuses `battery-650` (9) + with board-specific 600 mAh, 40 s idle hold and 1800 s timer wake overrides. +- `src/display_service.cpp` — the `1004 → EP42YR_400x300` mapping above. +- `include/opendisplay_structs.h` — new `OD_DEVICE_FLAG_NM420_INIT` (bit 5), the + matching `DeviceFlags` changelog entry, and the group comment now reading + "bits 6-7 reserved". Additive only, so no version bump: it accrues under + *Unreleased*, exactly as the 2.1 notes describe. +- `src/main.h` / `src/main.cpp` — `DEVICE_FLAG_NM420_INIT`, default-off GPIO + initialization and deep-sleep output hold on GPIO40/41/43/44/47. +- `src/config_parser.cpp` — calls `nm420_init()` after a config loads when the flag + is set, next to the existing `ws_pp_init()` hook. +- `src/sensor_aht20.*` — one CRC-checked temperature/humidity sample at each + boot/wake, published in the SHT40-compatible 3-byte MSD dynamic slot 7–9. +- `bin/NM-EPD-420-4C_factory_full.bin` — current all-in-one ESP32-S3 image: + bootloader, partition table, `boot_app0`, application, and the NM420 factory + config. Flash the complete image at offset `0x0`. + +### Flashing + +The combined image can be flashed at **`0x0`** with esptool or a compatible +ESP32-S3 flashing utility. It provisions its embedded configuration only when +the device has no stored configuration. A board already holding an older config +must receive the current YAML over BLE: + +```powershell +$env:PYTHONUTF8 = '1' +uv run tools/od-device-cli.py write-config --addr AA:BB:CC:DD:EE:FF ` + --input devices/nm-epd-420-4c.yaml +``` + +To rebuild the combined image with the current configuration: + +```powershell +$env:PYTHONUTF8 = '1' +$env:OPENDISPLAY_FACTORY_CONFIG_HEX = uv run tools/od-device-cli.py encode-config ` + --input devices/nm-epd-420-4c.yaml +$env:PATH = "$HOME\.platformio\packages\xtensa-esp-elf\bin;$env:PATH" +pio run -e esp32-s3-N16R8 +Copy-Item .pio\build\esp32-s3-N16R8\firmware.factory.bin ` + bin\NM-EPD-420-4C_factory_full.bin +``` + +## 5. Low-power peripherals and wake-time sensing + +All four peripheral enable lines are active HIGH: + +| Peripheral | Enable pin | Level this branch forces | +|---|---|---| +| SX126x LoRa module | 47 — `PIN_MODULE_EN` / LoRa EN | LOW | +| ES8311 codec | 44 — `PIN_CODEC_EN` | LOW | +| Audio PA | 41 — `PA_CTRL` | LOW | +| AHT20 | 40 — `PIN_TEMP_CTL` | LOW except for one bounded measurement after each boot/wake | + +The OpenDisplay config format has **no field for a spare output pin**: a pin that +belongs to no display / bus / input / LED / sensor block does not exist as far as +the firmware is concerned, so `nm-epd-420-4c.yaml` cannot switch them off. Left +alone they keep the ESP32 reset default — input, no pull — and each rail then sits +wherever the board's own resistors leave it, which is not a guarantee that the +part is off. + +The NM420 flag selects a board hook to set these rails LOW after config load. +It also parks ADC_EN GPIO43 LOW between voltage reads; the ADC reader still +pulses it HIGH for its measurement. Immediately before deep sleep the firmware +holds all five outputs LOW with ESP32-S3 pad hold, releasing it after wake and +reestablishing LOW before the next reading. This does not control the interval +between a cold power-on reset and successful config load: board pull resistors +and real current measurements must be checked. + +```c +/* include/opendisplay_structs.h */ +#define OD_DEVICE_FLAG_NM420_INIT (1u << 5) /* bit 5; bits 6-7 stay reserved */ + +/* src/main.cpp — called from config_parser.cpp after a config loads, and only + when device_flags bit 5 is set */ +void nm420_init(){ /* 47, 44, 41, 40, 43 default LOW; release sleep holds */ } +``` + +The config sets `device_flags: 0x20`. Because the switch is a config bit and not a +`#define` or a board name, one firmware binary still covers every other device, +and an NM-EPD-420 flashed without the bit behaves exactly as it did before. + +Deep sleep starts after a 40-second idle hold and has a 1800-second timer fallback. +GPIO45 (USER) is not an ESP32-S3 deep-sleep wake pin, but GPIO0 (BOOT) is an RTC +pad, so `sleep_flags: 0` arms it as an active-LOW wake source. The firmware skips +arming a button already held at sleep entry, avoiding an immediate wake loop. + +GPIO0 remains an ESP32-S3 ROM-download strap: release BOOT before reset or power-on +unless entering the ROM downloader intentionally. Its board pull-up keeps it HIGH +while asleep; hardware testing must confirm the pull-up is strong enough through the +full deep-sleep current range. + +Per peripheral: + +- **AHT20 temperature/humidity.** SensorType 7 at I²C `0x38` powers GPIO40 for + a 100 ms startup period, then performs up to two CRC-checked measurements. A + successful sample is written in the three-byte SHT40-compatible MSD format at + dynamic bytes 7–9 and logged as `AHT20: wake-time sample ...`; a failed sample + publishes `FF FF FF` and logs `AHT20: wake-time sample failed ...`. The rail is + then driven LOW and is not polled again while awake. With no other I²C client, + `Wire` is closed before supply removal. Board-level I²C pull-ups may still + back-power an unpowered sensor, so measure the AHT20 rail after shutdown rather + than assuming zero leakage. +- **Home Assistant compatibility.** The current RockBase IoT/OpenDisplay Home + Assistant integration. That version has + `SensorType.SHT40 = 4` but does not define AHT20 type 7, and the integration + creates ambient entities only for SHT40. It therefore ignores this board's + valid AHT20 configuration even when the firmware advertises a good sample. + Do not change the config to type 4: firmware would send SHT40 I²C commands to + the AHT20 and publish an invalid reading. The host-side fix is to add type 7 + to py-opendisplay and create equivalent ambient entities in the HA integration. +- **ES8311 codec + PA, LMD4737 PDM mic.** Audio has no protocol block at all. +- **SD card / SX126x LoRa.** No protocol block; the HSPI pins (7-14) are never + claimed. The module is nevertheless genuinely powered off now, rather than merely + unused. + +## 6. Remaining hardware checks + +Full refresh, buttons, BOOT wake, and battery reporting have been verified. The +following still need targeted measurements or broader visual tests: + +1. **Colour packing order.** `EP42YR_400x300` uses `u8Colors_4clr_v2`; the + host-side 2 bpp BWRY packing has to match it or red and yellow come out + swapped. Send a full-swatch test image first. +2. **Battery scaling.** 161 is derived. Compare `read-msd` against a multimeter + on the cell over its operating range. +3. **Rotation.** `rotation: 0` assumes the panel is mounted in its native 400×300 + landscape orientation. Change to 1/2/3 if the image lands rotated. +4. **BUSY polarity and long refreshes.** The hardware repo drives BUSY as "high + while refreshing"; + bb_epaper's UC81xx path expects the same. If every refresh times out, suspect + this first. +5. **AHT20 rail and host integration.** Check GPIO40 falls LOW after a successful + and failed sample, then update py-opendisplay and Home Assistant before relying + on HA temperature/humidity entities. + +## 7. Verification done so far + +**Uncommitted.** No commit exists; all changes sit in the working tree on branch +`nm-epd-420`. The rule applied here: a change that has not been exercised on +NM-EPD-420-4C hardware does not get committed. + +- Config packet encodes and decodes cleanly (`od-device-cli.py`): 229 bytes, + CRC valid, sensor type 7 / MSD slot 7, device flag 0x20, GPIO0+GPIO45 button + configuration, and a 1800-second timer fallback round-trip. +- `pio run -e esp32-s3-N16R8` compiled and linked the full firmware, generating + `firmware.bin` and the combined `firmware.factory.bin`. On this Windows machine PlatformIO + Core 6.1.19 did not automatically expose the already-installed compiler; + prepending `%USERPROFILE%\\.platformio\\packages\\xtensa-esp-elf\\bin` to PATH + for that one command resolved it. The only compiler warning was in existing + Wi-Fi code, unrelated to this board. +- `pio run -e nrf52840custom` also compiled and linked successfully with the + shared SHT40 MSD packing function used by the new AHT20 path. +- NM-EPD-420-4C hardware test: full refresh, physical buttons, BOOT deep-sleep + wake, and battery reporting work. See [Remaining Hardware Checks](#6-remaining-hardware-checks) + for the work that remains before release. diff --git a/include/opendisplay_structs.h b/include/opendisplay_structs.h index f8b0cb2..5598084 100644 --- a/include/opendisplay_structs.h +++ b/include/opendisplay_structs.h @@ -79,6 +79,15 @@ * encryption active). Firmware sets it when encryption_enabled and master * key are non-zero; dynamic[11] is all 0xFF when no display is configured. * (Folded back from the Firmware side, PR #157 — parity restore.) + * - DeviceFlags bit5: OD_DEVICE_FLAG_NM420_INIT (apply RockBase NM-EPD-420 + * low-power init -- drive the board's unused peripheral enable/gate pins + * LOW after config load: LoRa EN, ES8311 CODEC_EN, audio PA_CTRL, AHT20 + * TEMP_CTL and battery ADC_EN; AHT20/ADC_EN pulse on only while sampled). + * Bits 5-7 were reserved, so the DeviceFlags group + * comment now reads bits 6-7 reserved. + * - SensorType 6 is nPM1300 (already used by another OpenDisplay firmware + * family); SensorType 7 is AHT20, publishing the same 3-byte + * temperature/humidity MSD value as SHT40. GPIO40 gates its supply. * - Add each payload-layout change here as it lands. On the next version bump, * move these under a new "MAJOR.MINOR (YYYY-MM-DD)" heading. * @@ -428,12 +437,13 @@ enum ManufacturerId { #define OD_COMM_MODE_OEPL (1u << 1) /* @doc "OEPL (OpenEPaperLink) transfer supported" */ #define OD_COMM_MODE_WIFI (1u << 2) /* @doc "WiFi transfer supported" */ -/* SystemConfig.device_flags @bits DeviceFlags (bits 5-7 reserved). */ +/* SystemConfig.device_flags @bits DeviceFlags (bits 6-7 reserved). */ #define OD_DEVICE_FLAG_PWR_PIN (1u << 0) /* @doc "device has external power management for the display etc." */ #define OD_DEVICE_FLAG_XIAO_INIT (1u << 1) /* @doc "apply Seeed XIAO low-power init" */ #define OD_DEVICE_FLAG_WS_PP_INIT (1u << 2) /* @doc "apply Waveshare PhotoPainter low-power init" */ #define OD_DEVICE_FLAG_PWR_LATCH (1u << 3) /* @doc "self-holding MOSFET latch on pwr_pin_2; optional long-press shutdown button on pwr_pin_3" */ #define OD_DEVICE_FLAG_PWR_LATCH_DFF (1u << 4) /* @doc "74AHC1G79 D-FF latch: pwr_pin_2=D (hold), pwr_pin_3=CP (clock); command 0x0052 releases latch" */ +#define OD_DEVICE_FLAG_NM420_INIT (1u << 5) /* @doc "apply RockBase NM-EPD-420 low-power init: default peripheral and ADC enable pins LOW; hold them LOW in deep sleep" */ /** @struct SystemConfig @packet 0x01 @required * @doc "Host IC identity, communication capabilities, and power-management pins. @@ -789,7 +799,9 @@ enum SensorType { OD_SENSOR_TYPE_HUMIDITY = 2, /**< @doc "generic humidity sensor" */ OD_SENSOR_TYPE_AXP2101 = 3, /**< @doc "AXP2101 power-management IC" */ OD_SENSOR_TYPE_SHT40 = 4, /**< @doc "Sensirion SHT40 (I2C); writes 3 LE bytes into MSD dynamic data (21 bits: 0.1C, 0.1% RH)" */ - OD_SENSOR_TYPE_BQ27220 = 5 /**< @doc "TI BQ27220 fuel gauge (I2C 0x55); voltage in MSD bytes 14-15, one packed dynamic byte (bits 0-6 SOC%, bit7 charging)" */ + OD_SENSOR_TYPE_BQ27220 = 5, /**< @doc "TI BQ27220 fuel gauge (I2C 0x55); voltage in MSD bytes 14-15, one packed dynamic byte (bits 0-6 SOC%, bit7 charging)" */ + OD_SENSOR_TYPE_NPM1300 = 6, /**< @doc "nPM1300 PMIC (other firmware family; not implemented by this firmware)" */ + OD_SENSOR_TYPE_AHT20 = 7 /**< @doc "AHT20 (I2C 0x38); wake-time sample in SHT40-compatible 3-byte MSD format. NM-EPD-420 power gate GPIO40 is off between samples." */ }; /** @struct SensorData @packet 0x23 @repeatable max=4 @@ -799,8 +811,8 @@ struct SensorData { uint8_t instance_number; /**< @doc "0-based sensor-block index." */ uint16_t sensor_type; /**< @enum SensorType @endian le @doc "sensor type." */ uint8_t bus_id; /**< @doc "data_bus instance this sensor lives on." */ - uint8_t i2c_addr_7bit; /**< @doc "I2C 7-bit address; 0 or 0xFF = per-sensor default (SHT40 0x44, BQ27220 0x55)." @default 0xFF */ - uint8_t msd_data_start_byte; /**< @doc "first dynamicreturndata index this sensor publishes into. SHT40 uses 3 bytes (0/0xFF = default 7); BQ27220 uses 1 packed byte (0xFF = do not publish)." @default 0xFF */ + uint8_t i2c_addr_7bit; /**< @doc "I2C 7-bit address; 0 or 0xFF = per-sensor default (SHT40 0x44, BQ27220 0x55, AHT20 0x38)." @default 0xFF */ + uint8_t msd_data_start_byte; /**< @doc "first dynamicreturndata index this sensor publishes into. SHT40/AHT20 use 3 bytes (0/0xFF = default 7); BQ27220 uses 1 packed byte (0xFF = do not publish)." @default 0xFF */ uint8_t reserved[24]; /**< @reserved @doc "future use; must be 0." */ } __attribute__((packed)); OD_STATIC_ASSERT(sizeof(struct SensorData) == 30, "SensorData wire size"); diff --git a/src/config_parser.cpp b/src/config_parser.cpp index 5e0bfde..a587609 100644 --- a/src/config_parser.cpp +++ b/src/config_parser.cpp @@ -30,6 +30,7 @@ using namespace Adafruit_LittleFS_Namespace; #define DEVICE_FLAG_WS_PP_INIT (1 << 2) #define DEVICE_FLAG_BATTERY_LATCH (1 << 3) #define DEVICE_FLAG_PWR_LATCH_DFF (1 << 4) +#define DEVICE_FLAG_NM420_INIT (1 << 5) #endif // The parse-time dumps and printConfigSummary() are od_log_debug, which the @@ -53,6 +54,7 @@ extern bool wifiInitialized; void xiaoinit(); void powerDownExternalFlashFromConfig(void); void ws_pp_init(); +void nm420_init(); extern bool encryptionInitialized; // Defined in main.h (the single-inclusion globals header), so it needs an extern @@ -746,6 +748,7 @@ void printConfigSummary(){ od_log_debug(" WS_PP_INIT flag: %s", (globalConfig.system_config.device_flags & DEVICE_FLAG_WS_PP_INIT) ? "enabled" : "disabled"); od_log_debug(" BATTERY_LATCH flag: %s", (globalConfig.system_config.device_flags & DEVICE_FLAG_BATTERY_LATCH) ? "enabled" : "disabled"); od_log_debug(" PWR_LATCH_DFF flag: %s", (globalConfig.system_config.device_flags & DEVICE_FLAG_PWR_LATCH_DFF) ? "enabled" : "disabled"); + od_log_debug(" NM420_INIT flag: %s", (globalConfig.system_config.device_flags & DEVICE_FLAG_NM420_INIT) ? "enabled" : "disabled"); od_log_debug("Power Pin: %u", globalConfig.system_config.pwr_pin); od_log_debug("Power Pin 2: %u", globalConfig.system_config.pwr_pin_2); od_log_debug("Power Pin 3: %u", globalConfig.system_config.pwr_pin_3); @@ -950,6 +953,11 @@ void full_config_init() { ws_pp_init(); od_log_info("ws_pp_init() completed"); } + if (globalConfig.loaded && (globalConfig.system_config.device_flags & DEVICE_FLAG_NM420_INIT)) { + od_log_info("Device flag DEVICE_FLAG_NM420_INIT is set, calling nm420_init()..."); + nm420_init(); + od_log_info("nm420_init() completed"); + } // Must run after config load: latch pins/flag come from globalConfig. powerLatchBegin(); } else { diff --git a/src/display_service.cpp b/src/display_service.cpp index 592e5ec..3f3a9d5 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -8,6 +8,7 @@ #include "od_log.h" #include "buzzer_control.h" #include "sensor_sht40.h" +#include "sensor_aht20.h" #include "sensor_bq27220.h" #include "sensor_axp2101.h" #include "communication.h" @@ -845,6 +846,17 @@ int mapEpd(int id){ case 0x004A: return EP40_SPECTRA_400x600; case 0x004B: return EP27_176x264; case 0x004C: return EP27_176x264_4GRAY; + // Values 1000+ are the M3 / EPD-nRF5 driver line's PanelIC block, which + // names the same physical panels by their controller instead of by the + // bb_epaper EP* identifier. Only the ones with a bb_epaper equivalent are + // mapped; everything else in that range falls through to UNDEFINED. + // + // 1004 "JD79668 4.2\" B/W/R/Y" is the controller-side name for the same + // 4.2" four-colour class as OD_PANEL_IC_EP42YR_400X300 (0x003A): the Good + // Display GDEM042F52 / JD79668, and the GDEY0420F51 (HX8717) it replaced. + // They share one UC81xx-family command set, so they share one init + // sequence. Configs written by the other firmware line then work here. + case OD_PANEL_IC_JD79668_420_BWRY: return EP42YR_400x300; // 1004 default: return EP_PANEL_UNDEFINED; } } @@ -1188,6 +1200,9 @@ void initSensors(){ else if(sensor->sensor_type == OD_SENSOR_TYPE_SHT40){ od_log_debug(" SHT40 (I2C + MSD slot)"); } + else if(sensor->sensor_type == OD_SENSOR_TYPE_AHT20){ + od_log_debug(" AHT20 (wake-time I2C + MSD slot)"); + } else if(sensor->sensor_type == OD_SENSOR_TYPE_BQ27220){ od_log_debug(" BQ27220 fuel gauge (MSD voltage + optional dynamic SOC/status bytes)"); } @@ -1850,6 +1865,7 @@ float readChipTemperature() { void updatemsdata(){ // od_log_debug("updatemsdata() called (mloopcounter: %u)", mloopcounter); + pollAht20SensorsForMsd(); pollSht40SensorsForMsd(); pollBq27220ForMsd(); float batteryVoltage = readBatteryVoltage(); diff --git a/src/main.cpp b/src/main.cpp index 1f1b322..7b828ea 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,11 @@ #include "od_log.h" #include "watchdog.h" +#if defined(TARGET_ESP32) && defined(CONFIG_IDF_TARGET_ESP32S3) +#include "driver/gpio.h" +static constexpr uint8_t kNm420OffPins[] = {47, 44, 41, 40, 43}; +#endif + #if defined(TARGET_ESP32) && defined(OPENDISPLAY_LOG_UART) #include #ifndef OPENDISPLAY_LOG_UART_RX @@ -1228,6 +1233,7 @@ void enterDeepSleep(bool force, uint16_t overrideSleepSeconds) { od_log_flush(); // drain UART/Serial prior to deep sleep delay(100); // Brief delay to ensure serial output is sent powerLatchHoldForSleep(); + nm420HoldForSleep(); esp_deep_sleep_start(); } #endif @@ -1453,6 +1459,53 @@ void ws_pp_init(){ od_log_info("Photo Printer initialized"); } +// RockBase IoT NM-EPD-420 (ESP32-S3 + 4.2" panel), selected by +// DEVICE_FLAG_NM420_INIT. +// +// The board carries a LoRa module, an ES8311 codec, an audio PA and an AHT20 +// temperature sensor. None of them is supported by this firmware, and none of +// their enable lines can be described in a config packet -- the wire format has +// no "spare output pin" field, so the pins simply do not appear in any block. +// Untouched they keep the ESP32 reset default (input, no pull), which leaves each +// rail at whatever the board's own resistor decides; drive them to their OFF +// level explicitly instead. +// +// All four are active HIGH (HIGH = on): +// GPIO47 MODULE_EN / LoRa EN LoRa module power +// GPIO44 CODEC_EN ES8311 power +// GPIO41 PA_CTRL external audio amplifier enable +// GPIO40 TEMP_CTL AHT20 power gate +// +// GPIO43 (ADC_EN, active HIGH) defaults LOW as well; the battery reader pulses +// it HIGH only during a measurement, then LOW again while the CPU is awake. +// The configured pins (panel 1/2/4/5/6/46, I2C 38/39, USER key 45) are driven by +// their own init blocks and must not be touched here. +void nm420_init(){ +#if defined(TARGET_ESP32) && defined(CONFIG_IDF_TARGET_ESP32S3) + for (uint8_t pin : kNm420OffPins) { + gpio_set_level((gpio_num_t)pin, 0); + gpio_set_direction((gpio_num_t)pin, GPIO_MODE_OUTPUT); + gpio_hold_dis((gpio_num_t)pin); + pinMode(pin, OUTPUT); + digitalWrite(pin, LOW); + } + od_log_info("NM-EPD-420 init: peripheral enables and battery ADC gate LOW"); +#endif +} + +#ifdef TARGET_ESP32 +void nm420HoldForSleep() { +#if defined(CONFIG_IDF_TARGET_ESP32S3) + if (!globalConfig.loaded || !(globalConfig.system_config.device_flags & DEVICE_FLAG_NM420_INIT)) return; + for (uint8_t pin : kNm420OffPins) { + digitalWrite(pin, LOW); + gpio_hold_en((gpio_num_t)pin); + } + gpio_deep_sleep_hold_en(); +#endif +} +#endif + #ifdef TARGET_NRF void powerDownExternalFlashFromConfig(void) { if (!globalConfig.loaded || globalConfig.flash_config_count == 0) { diff --git a/src/main.h b/src/main.h index 430b3c1..34b7c2a 100644 --- a/src/main.h +++ b/src/main.h @@ -56,6 +56,7 @@ using namespace Adafruit_LittleFS_Namespace; #define DEVICE_FLAG_WS_PP_INIT (1 << 2) // Bit 2: Call ws_pp_init() after config load (Waveshare Photo Printer) #define DEVICE_FLAG_BATTERY_LATCH (1 << 3) // Bit 3: Self-holding battery latch on pwr_pin_2; optional active-low long-press shutdown button on pwr_pin_3 #define DEVICE_FLAG_PWR_LATCH_DFF (1 << 4) // Bit 4: 74AHC1G79 D-FF latch; pwr_pin_2=D, pwr_pin_3=CP; release via command 0x0052 +#define DEVICE_FLAG_NM420_INIT (1 << 5) // Bit 5: Call nm420_init() after config load (RockBase NM-EPD-420 -- holds the unused peripheral enable rails LOW) #ifdef TARGET_NRF // The Bluefruit stack objects (BLEDfu / BLEService / BLECharacteristic) are now @@ -191,7 +192,9 @@ bool powerDownExternalFlash(uint8_t mosiPin, uint8_t misoPin, uint8_t sckPin, ui void powerDownExternalFlashFromConfig(void); void xiaoinit(); void ws_pp_init(); +void nm420_init(); #ifdef TARGET_ESP32 +void nm420HoldForSleep(); void fullSetupAfterConnection(); // force: sleep even with a client connected (explicit host request 0x0053). // overrideSleepSeconds: nonzero replaces deep_sleep_time_seconds for this one diff --git a/src/sensor_aht20.cpp b/src/sensor_aht20.cpp new file mode 100644 index 0000000..ff11f78 --- /dev/null +++ b/src/sensor_aht20.cpp @@ -0,0 +1,100 @@ +#include "sensor_aht20.h" +#include "sensor_sht40.h" +#include "display_service.h" +#include "structs.h" +#include "od_log.h" + +#include +#include + +extern struct GlobalConfig globalConfig; + +static uint8_t aht20Crc(const uint8_t* data, uint8_t length) { + uint8_t crc = 0xFF; + for (uint8_t index = 0; index < length; index++) { + crc ^= data[index]; + for (uint8_t bit = 0; bit < 8; bit++) { + crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31) : (uint8_t)(crc << 1); + } + } + return crc; +} + +static bool aht20Command(uint8_t address, const uint8_t* command, uint8_t length) { + Wire.beginTransmission(address); + Wire.write(command, length); + return Wire.endTransmission() == 0; +} + +static bool aht20Read(uint8_t address, int16_t* temperatureCenti, uint16_t* humidityCenti) { + const uint8_t initialize[] = {0xBE, 0x08, 0x00}; + if (!aht20Command(address, initialize, sizeof(initialize))) return false; + delay(10); + + const uint8_t trigger[] = {0xAC, 0x33, 0x00}; + for (uint8_t attempt = 0; attempt < 2; attempt++) { + if (!aht20Command(address, trigger, sizeof(trigger))) return false; + delay(85); + if (Wire.requestFrom(address, (size_t)7, true) != 7) continue; + + uint8_t sample[7]; + for (uint8_t index = 0; index < sizeof(sample); index++) sample[index] = Wire.read(); + if ((sample[0] & 0x88) != 0x08 || aht20Crc(sample, 6) != sample[6]) continue; + + const uint32_t humidityRaw = ((uint32_t)sample[1] << 12) | + ((uint32_t)sample[2] << 4) | (sample[3] >> 4); + const uint32_t temperatureRaw = ((uint32_t)(sample[3] & 0x0F) << 16) | + ((uint32_t)sample[4] << 8) | sample[5]; + *humidityCenti = (uint16_t)(((uint64_t)humidityRaw * 10000u + 524288u) / 1048576u); + *temperatureCenti = (int16_t)(((uint64_t)temperatureRaw * 20000u + 524288u) / 1048576u - 5000); + return true; + } + return false; +} + +void pollAht20SensorsForMsd(void) { + static bool sampledThisBoot = false; + if (sampledThisBoot) return; + + for (uint8_t index = 0; index < globalConfig.sensor_count; index++) { + const SensorData* sensor = &globalConfig.sensors[index]; + if (sensor->sensor_type != OD_SENSOR_TYPE_AHT20) continue; + sampledThisBoot = true; + + uint8_t start = sensor->msd_data_start_byte; + if (start == 0 || start == 0xFF) start = 7; + if (start > 8) return; + +#if defined(TARGET_ESP32) && defined(CONFIG_IDF_TARGET_ESP32S3) + const bool gated = (globalConfig.system_config.device_flags & OD_DEVICE_FLAG_NM420_INIT) != 0; + if (gated) { + digitalWrite(40, HIGH); + delay(100); + } +#else + const bool gated = false; +#endif + int16_t temperatureCenti = 0; + uint16_t humidityCenti = 0; + uint8_t busId = sensor->bus_id == 0xFF ? 0 : sensor->bus_id; + const uint8_t address = (sensor->i2c_addr_7bit == 0 || sensor->i2c_addr_7bit == 0xFF) + ? 0x38 : sensor->i2c_addr_7bit; + bool valid = initOrRestoreWireForBus(busId) && + aht20Read(address, &temperatureCenti, &humidityCenti); + if (gated) { + if (globalConfig.sensor_count == 1 && globalConfig.touch_controller_count == 0) { + invalidateOpenDisplayWire(); + } + digitalWrite(40, LOW); + } + if (valid) { + writeTempHumidityMsd(start, temperatureCenti, humidityCenti); + od_log_info("AHT20: wake-time sample %.2f C, %.2f %%RH (bus %u, addr 0x%02X)", + temperatureCenti / 100.0f, humidityCenti / 100.0f, busId, address); + } else { + writeTempHumidityMsdInvalid(start); + od_log_warn("AHT20: wake-time sample failed (bus %u, addr 0x%02X)", busId, address); + } + return; + } +} \ No newline at end of file diff --git a/src/sensor_aht20.h b/src/sensor_aht20.h new file mode 100644 index 0000000..203e6b7 --- /dev/null +++ b/src/sensor_aht20.h @@ -0,0 +1,6 @@ +#ifndef SENSOR_AHT20_H +#define SENSOR_AHT20_H + +void pollAht20SensorsForMsd(void); + +#endif \ No newline at end of file diff --git a/src/sensor_sht40.cpp b/src/sensor_sht40.cpp index 99d068d..c6c05d8 100644 --- a/src/sensor_sht40.cpp +++ b/src/sensor_sht40.cpp @@ -172,7 +172,7 @@ static void sht40_probe_bus_once(uint8_t bus_id) { } } -static void write_sht40_invalid(uint8_t start) { +void writeTempHumidityMsdInvalid(uint8_t start) { dynamicreturndata[start] = 0xFF; dynamicreturndata[start + 1] = 0xFF; dynamicreturndata[start + 2] = 0xFF; @@ -190,7 +190,7 @@ static int round_centi_to_deci(int16_t c) { // MSD (3 bytes LE): v = rh_deci | (tu << 10); rh_deci 0..1000 = 0..100.0% RH (0.1% steps); // tu = temp(0.1°C) + 400. Decode: t_deci = (v>>10 & 0x7FF) - 400; temp_centi = t_deci*10; rh_centi = (v&0x3FF)*10 -static void write_sht40_msd(uint8_t start, int16_t temp_centi, uint16_t rh_centi) { +void writeTempHumidityMsd(uint8_t start, int16_t temp_centi, uint16_t rh_centi) { int t_deci = round_centi_to_deci(temp_centi); if (t_deci < -400) { t_deci = -400; @@ -266,10 +266,10 @@ void pollSht40SensorsForMsd(void) { err, bid, globalConfig.data_buses[bid].pin_1, globalConfig.data_buses[bid].pin_2); logged_fail = true; } - write_sht40_invalid(start); + writeTempHumidityMsdInvalid(start); continue; } logged_fail = false; - write_sht40_msd(start, tc, rhc); + writeTempHumidityMsd(start, tc, rhc); } } diff --git a/src/sensor_sht40.h b/src/sensor_sht40.h index fa78d83..3f44c40 100644 --- a/src/sensor_sht40.h +++ b/src/sensor_sht40.h @@ -1,7 +1,11 @@ #ifndef SENSOR_SHT40_H #define SENSOR_SHT40_H +#include + void initSht40Sensors(void); void pollSht40SensorsForMsd(void); +void writeTempHumidityMsdInvalid(uint8_t start); +void writeTempHumidityMsd(uint8_t start, int16_t temp_centi, uint16_t rh_centi); #endif diff --git a/tools/od-device-cli.py b/tools/od-device-cli.py index 27e5107..2af7e8a 100755 --- a/tools/od-device-cli.py +++ b/tools/od-device-cli.py @@ -538,7 +538,7 @@ class Block(NamedTuple): # sets only trigger a warning (not an error) on encode - the firmware may define values # this tool doesn't know about yet. VALID_ENUMS: dict[tuple[str, str], set[int]] = { - ("sensors", "sensor_type"): {1, 2, 3, 4, 5}, # SENSOR_TYPE_TEMPERATURE..BQ27220 + ("sensors", "sensor_type"): {1, 2, 3, 4, 5, 6, 7}, # SENSOR_TYPE_TEMPERATURE..AHT20 ("displays", "color_scheme"): {0, 1, 2, 3, 4, 5, 6, 7, 8, 100, 101, 102}, # COLOR_SCHEME_MONO..BWGBRY_SPLIT, RGB565/RGB888/RGB16BPC ("touch_controllers", "touch_ic_type"): {0, 1}, # TOUCH_IC_NONE, TOUCH_IC_GT911 ("power_option", "capacity_estimator"): {1, 2, 3, 4, 5}, # CAPACITY_EST_LI_ION..SEEED_LI_ION From 24851718a6838260a52201a50ea524e42277c8d8 Mon Sep 17 00:00:00 2001 From: RockBase-iot Date: Mon, 12 Oct 2026 13:59:44 +0800 Subject: [PATCH 2/4] Remove devices/ YAML config per maintainer review; device config moves to the website toolbox preset --- devices/nm-epd-420-4c.yaml | 149 ------------------------------------- 1 file changed, 149 deletions(-) delete mode 100644 devices/nm-epd-420-4c.yaml diff --git a/devices/nm-epd-420-4c.yaml b/devices/nm-epd-420-4c.yaml deleted file mode 100644 index c10025d..0000000 --- a/devices/nm-epd-420-4c.yaml +++ /dev/null @@ -1,149 +0,0 @@ -# NM-EPD-420-4C — OpenDisplay device config (RockBase IoT, ESP32-S3) -# -# 4.2" four-colour (B/W/R/Y) variant. Same PCB and same pin map as the -# tri-colour NM-EPD-420 and the B/W NM-EPD-420-BW; only the fitted panel -# differs, so only `panel_ic_type` / `color_scheme` / `full_update_mC` change -# between the three variants. -# -# Encode to a config packet and flash it in, either as a factory config: -# -# uv run tools/od-device-cli.py encode-config --input devices/nm-epd-420-4c.yaml -# OPENDISPLAY_FACTORY_CONFIG_HEX="" pio run -e esp32-s3-N16R8 -# -# or over BLE onto a running device: -# -# uv run tools/od-device-cli.py write-config --addr AA:BB:CC:DD:EE:FF \ -# --input devices/nm-epd-420-4c.yaml -# -# Build target: esp32-s3-N16R8 (ESP32-S3, 16 MB QIO flash, 8 MB OPI PSRAM). -# No dedicated env: the NM-EPD-420 is exactly what that env already targets. -version: 1 - -system_config: - ic_type: 2 # OD_IC_TYPE_ESP32S3 - communication_modes: 0x1 # BLE. Wi-Fi/LAN would add 0x4 (esp32-s3-N16R8 builds OPENDISPLAY_ENABLE_WIFI). - # bit5 (0x20) = OD_DEVICE_FLAG_NM420_INIT: firmware drives the board's unused - # peripheral enable/gate pins LOW right after config load. The NM-EPD-420 fits - # a LoRa module, an ES8311 codec, an audio PA and an AHT20, all active-HIGH - # enabled (GPIO47 MODULE_EN, GPIO44 CODEC_EN, GPIO41 PA_CTRL, GPIO40 TEMP_CTL) - # LoRa/audio remain unused; AHT20 is powered only for a wake-time measurement. - # Without the flag these gates would be left at the - # ESP32 reset default (input, no pull) and float the rails on. - # GPIO43 (ADC_EN) also defaults LOW; battery sampling pulses it HIGH briefly. - device_flags: 0x20 # bit5 = NM420 init; no display power switch, no XIAO/WS init, no battery latch - pwr_pin: 0xFF - pwr_pin_2: 0xFF - pwr_pin_3: 0xFF - -manufacturer_data: - manufacturer_id: 0 # OD_MANUFACTURER_DIY — RockBase is not in the ManufacturerId table yet - board_type: 0 - board_revision: 1 - # simple_config_* indices are written by the opendisplay.org toolbox when a - # user picks this board there; left at 0 for a hand-flashed config. - -power_option: - power_mode: 1 # OD_POWER_MODE_BATTERY - battery_capacity_mah: 600 - sleep_timeout_ms: 40000 - tx_power: 8 - sleep_flags: 0x0 # Allow configured button wake; GPIO0 BOOT is an ESP32-S3 RTC wake pin - # Battery sense: GPIO3 through a 1:2 divider, gated by GPIO43 (PIN_ADC_EN, - # active HIGH). ESP32-S3 ADC is 12-bit @ 11 dB (~3.3 V full scale), so - # scaling = 3.3 V / 4095 counts * 2 (divider) = 0.001612 V/count - # -> voltage_scaling_factor = 161, the same value the M5Stack PaperS3 - # preset uses (identical pin + identical divider). - battery_sense_pin: 3 - battery_sense_enable_pin: 43 - battery_sense_flags: 0x0 - capacity_estimator: 1 # OD_CAPACITY_EST_LI_ION - voltage_scaling_factor: 161 - deep_sleep_current_ua: 0 - deep_sleep_time_seconds: 1800 # 30-minute timer fallback; GPIO0 BOOT also wakes from deep sleep - # No charger GPIOs are exposed on the NM-EPD-420 (charge management is - # standalone on-board), so no charge_enable_pin / charge_state_pin. - charge_enable_pin: 0xFF - charge_state_pin: 0xFF - charger_flags: 0x0 - screen_timeout_seconds: 0 # 4-colour panel: power the panel straight down after a refresh - -displays: - - instance_number: 0 - display_technology: 1 # OD_DISPLAY_TECH_E_PAPER - # 58 = OD_PANEL_IC_EP42YR_400X300 ("4.2\" BWRY 400x300", bb_epaper - # EP42YR_400x300 - the Good Display 4.2" four-colour class: JD79668 / - # GDEM042F52, and the GDEY0420F51 (HX8717) it replaces). Same value the - # opendisplay.org preset `ep42yr-400x300` (index 11) carries. - panel_ic_type: 58 - pixel_width: 400 - pixel_height: 300 - # Panel datasheet active area is 84.8 x 63.6 mm. Deliberately rounded the - # same way the opendisplay.org preset `ep42yr-400x300` rounds it, so a device - # flashed from this file and a device configured from the preset agree - # byte-for-byte. - active_width_mm: 84 - active_height_mm: 63 - legacy_tag_type: 0 - rotation: 0 - reset_pin: 5 - busy_pin: 6 - dc_pin: 4 - cs_pin: 46 - data_pin: 1 - clk_pin: 2 - cs_pin_2: 0xFF - # Four-colour panels have no usable partial refresh (25-30 s full update, - # and the protocol's partial path is 1bpp-only anyway). - partial_update_support: 0 - color_scheme: 3 # OD_COLOR_SCHEME_BWRY -> 2 bpp - # streaming decompression (bit0) | direct write (bit3) | pipe write (bit4) - transmission_modes: 25 - full_update_mC: 165 - -data_buses: - # AHT20 (0x38) and the disabled ES8311 (0x18) share this bus. - - instance_number: 0 - bus_type: 1 # OD_BUS_TYPE_I2C - pin_1: 38 # SCL - pin_2: 39 # SDA - pin_3: 0xFF - pin_4: 0xFF - pin_5: 0xFF - pin_6: 0xFF - pin_7: 0xFF - bus_speed_hz: 400000 - bus_flags: 0x0 - pullups: 0x0 - pulldowns: 0x0 - -sensors: - - instance_number: 0 - sensor_type: 7 # OD_SENSOR_TYPE_AHT20; 6 is nPM1300 on other firmware - bus_id: 0 - i2c_addr_7bit: 0x38 - msd_data_start_byte: 7 # 3-byte SHT40-compatible temperature/humidity snapshot - -binary_inputs: - # USER key on GPIO45, active LOW with an external pull-up. - # BOOT key on GPIO0 is also active LOW. It is an ESP32-S3 ROM-download - # strap, so keep it released at reset; it is otherwise valid for RTC wake. - # button_data_byte_index 0 publishes the button state into MSD dynamic byte 0; - # >10 disables the input entirely. - - instance_number: 0 - input_type: 1 # OD_INPUT_TYPE_BUTTON - display_as: 1 # OD_INPUT_DISPLAY_AS_BUTTON - input_pin_1: 45 - input_pin_2: 0 - input_pin_3: 0xFF - input_pin_4: 0xFF - input_pin_5: 0xFF - input_pin_6: 0xFF - input_pin_7: 0xFF - input_pin_8: 0xFF - pins_used: 0x3 - invert: 0x3 # pressed = LOW - pullups: 0x0 - pulldowns: 0x0 - button_data_byte_index: 0 - power_off_flags: 0x0 - power_off_hold_sec: 0 From 5cf6adf8c2ee0d8b4982a189ddec6b10fcafb70d Mon Sep 17 00:00:00 2001 From: RockBase-iot Date: Mon, 12 Oct 2026 13:59:52 +0800 Subject: [PATCH 3/4] Remove simple-config preset fragment; will be submitted to OpenDisplay/opendisplay.org instead --- devices/nm-epd-420.simple-config-preset.json | 107 ------------------- 1 file changed, 107 deletions(-) delete mode 100644 devices/nm-epd-420.simple-config-preset.json diff --git a/devices/nm-epd-420.simple-config-preset.json b/devices/nm-epd-420.simple-config-preset.json deleted file mode 100644 index f8721ef..0000000 --- a/devices/nm-epd-420.simple-config-preset.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "_note": "Fragment to append to opendisplay.org httpdocs/firmware/toolbox/simple-config-presets.json (repo OpenDisplay/opendisplay.org). Merge the driverBoards entry into driverBoards[], and the id->index entries into httpdocs/firmware/toolbox/simple-config-id-registry.json in the same change. The display preset it points at already exists: ep42yr-400x300 (index 11). No new powerOptions entry is needed.", - "driverBoards": [ - { - "id": "nm-epd-420-4c", - "name": "RockBase IoT NM-EPD-420 (ESP32-S3)", - "connectorPins": [ - 24 - ], - "systemConfig": { - "ic_type": "2", - "communication_modes": "0x1", - "device_flags": "0x20", - "pwr_pin": "0xff", - "pwr_pin_2": "0xff", - "pwr_pin_3": "0xff", - "reserved": "0x0" - }, - "manufacturerData": { - "manufacturer_id": "0", - "board_type": "0", - "board_revision": "0x1", - "reserved": "0x0" - }, - "displayPins": { - "reset": "5", - "busy": "6", - "dc": "4", - "cs": "46", - "data": "1", - "clk": "2", - "cs2": "0xff" - }, - "powerDefaults": { - "battery_capacity_mah": "600", - "sleep_timeout_ms": "40000", - "sleep_flags": "0x0", - "battery_sense_pin": "3", - "battery_sense_enable_pin": "43", - "battery_sense_flags": "0x0", - "capacity_estimator": "1", - "voltage_scaling_factor": "161", - "deep_sleep_current_ua": "0", - "deep_sleep_time_seconds": "1800" - }, - "buttons": { - "instance_number": "0x0", - "input_type": "1", - "display_as": "1", - "inputpin1": "45", - "inputpin2": "0", - "inputpin3": "0xff", - "inputpin4": "0xff", - "inputpin5": "0xff", - "inputpin6": "0xff", - "inputpin7": "0xff", - "inputpin8": "0xff", - "isused": "0x3", - "invert": "0x3", - "pullups": "0x0", - "pulldowns": "0x0", - "button_data_byte_index": "0", - "power_off_flags": "0x0", - "power_off_hold_sec": "0", - "reserved": "0x0" - }, - "dataBus": { - "instance_number": "0x0", - "bus_type": "1", - "pin_1": "38", - "pin_2": "39", - "pin_3": "0xff", - "pin_4": "0xff", - "pin_5": "0xff", - "pin_6": "0xff", - "pin_7": "0xff", - "bus_speed_hz": "0x61a80", - "bus_flags": "0x0", - "pullups": "0x0", - "pulldowns": "0x0", - "reserved": "0x0" - }, - "sensorData": { - "instance_number": "0", - "sensor_type": "7", - "bus_id": "0", - "i2c_addr_7bit": "0x38", - "msd_data_start_byte": "7" - }, - "defaultDisplay": "ep42yr-400x300", - "defaultPower": "battery-650", - "installConfig": { - "type": "esp32", - "manifest": "https://opendisplay.org/firmware/toolbox/bin/esp32-s3-N16R8_full.json", - "firmwareLetter": "O", - "githubRepo": "https://github.com/OpenDisplay/Firmware" - }, - "transmission_modes": "25", - "index": 32 - } - ], - "idRegistry": { - "driverBoards": { - "nm-epd-420-4c": 32 - } - } -} From 3a501158a602bdcb28076da0ed0cfaa9b7922dfd Mon Sep 17 00:00:00 2001 From: RockBase-iot Date: Mon, 12 Oct 2026 13:59:58 +0800 Subject: [PATCH 4/4] Remove docs/DEVICE_NM_EPD_420_4C.md per maintainer review; doc file not required --- docs/DEVICE_NM_EPD_420_4C.md | 267 ----------------------------------- 1 file changed, 267 deletions(-) delete mode 100644 docs/DEVICE_NM_EPD_420_4C.md diff --git a/docs/DEVICE_NM_EPD_420_4C.md b/docs/DEVICE_NM_EPD_420_4C.md deleted file mode 100644 index 31827d8..0000000 --- a/docs/DEVICE_NM_EPD_420_4C.md +++ /dev/null @@ -1,267 +0,0 @@ -# NM-EPD-420-4C — device support notes - -RockBase IoT **NM-EPD-420-4C**: ESP32-S3 board, 4.2" **400×300 four-colour -(B/W/R/Y)** e-paper, 25–30 s full refresh, no partial refresh. - -Upstream hardware repo: -(that repo ships factory *test* firmware, not an application firmware). - -Status: **uncommitted work in progress on branch `nm-epd-420`**. The current -firmware has been exercised on an NM-EPD-420-4C: full-screen refresh, physical -buttons, deep-sleep BOOT wake, and battery reporting work. AHT20 advertisement -data requires a host-side compatibility update before Home Assistant can expose -temperature and humidity entities. Read [Remaining Hardware Checks](#6-remaining-hardware-checks) -before treating this as a production configuration. - ---- - -## 1. Build target — no new PlatformIO env - -The board is ESP32-S3 with **16 MB QIO flash + OPI PSRAM**, which is exactly what -`esp32-s3-N16R8` already describes. Per `CLAUDE.md` ("do not just create new pio -targets for new devices — check whether the existing ones work with a new -config"), no env was added: - -```bash -pio run -e esp32-s3-N16R8 -``` - -Everything device-specific — panel, pins, colour scheme, battery divider — lives -in the runtime config packet, not in build flags. The only build-time choice that -could differ is Wi-Fi: `esp32-s3-N16R8` builds with `-DOPENDISPLAY_ENABLE_WIFI` -(LAN transport). Set `communication_modes` to `0x5` (BLE|WiFi) if you want it. - -## 2. The panel, and why the PanelIC value is `58` - -Three names describe the same panel class, so it is worth writing down which one -this firmware uses. - -| Layer | Name | Notes | -|---|---|---| -| Fitted panel | **GDEY0420F51** (a.k.a. GDEY042F51) | Good Display 4.2" B/W/R/Y, controller **HX8717**, 400×300, 84.8×63.6 mm active, full update 28 s, EOL | -| Panel's own successor | **GDEM042F52** | Good Display's announced replacement for the GDEY042F51; controller **JD79668** | -| bb_epaper / OpenDisplay | `EP42YR_400x300` = `OD_PANEL_IC_EP42YR_400X300` = **58** | documented in-tree as "GDEM042F52" | -| M3 / EPD-nRF5 driver line | `OD_PANEL_IC_JD79668_420_BWRY` = **1004** | same panel, named after the controller instead of after the bb_epaper panel id | - -The existing bb_epaper panel ID is a candidate for the fitted HX8717, not a -verified drop-in replacement for its GxEPD2 driver: - -1. Good Display publishes **GDEM042F52 as the drop-in upgrade for GDEY042F51** - ("we recommend upgrading to our new model GDEM042F52"), which only makes sense - if the interface and command set carry over. -2. `GxEPD2_420c_GDEY0420F51::_InitDisplay()` and bb_epaper's - `epd42yr_init_full` share a controller family and 400×300 TRES, but the - latter omits several power/timing registers and uses different BTST values. -3. `src/display_service.cpp: mapEpd()` already maps `0x003A` to - `EP42YR_400x300`, so a software path exists; it has not been tested with - this HX8717 panel. - -For a first hardware trial, no framebuffer or new build target is required; -the panel is addressed as `panel_ic_type: 58` — -the same value the opendisplay.org preset `ep42yr-400x300` (index 11) carries. - -### Firmware change made on this branch - -One `case` in `src/display_service.cpp` (`mapEpd()`), mapping the M3-line -controller-named value onto the same bb_epaper panel: - -```c -case OD_PANEL_IC_JD79668_420_BWRY: return EP42YR_400x300; // 1004 -``` - -Before this, a device configured with `panel_ic_type: 1004` silently fell through -to `EP_PANEL_UNDEFINED` → "No display found". Nothing else in the render or -transfer path needed touching: `getBitsPerPixel()` already returns 2 for -`OD_COLOR_SCHEME_BWRY`, and the partial-refresh handlers already NACK any -`bpp != 1` panel, which matches the panel's "no partial refresh" reality. - -## 3. Pin map - -From `src/config.h` in the hardware repo, cross-checked against -`docs/sch/SCH_NM-EPD-420-V1.0.pdf`. - -| Function | GPIO | Config field | -|---|---|---| -| EPD SCK | 2 | `displays[0].clk_pin` | -| EPD MOSI | 1 | `displays[0].data_pin` | -| EPD CS | 46 | `displays[0].cs_pin` | -| EPD DC | 4 | `displays[0].dc_pin` | -| EPD RST | 5 | `displays[0].reset_pin` | -| EPD BUSY | 6 | `displays[0].busy_pin` | -| Battery ADC | 3 | `power_option.battery_sense_pin` | -| Battery ADC enable (HIGH) | 43 | `power_option.battery_sense_enable_pin` | -| I²C SCL (AHT20 / ES8311) | 38 | `data_buses[0].pin_1` | -| I²C SDA | 39 | `data_buses[0].pin_2` | -| USER key (active LOW) | 45 | `binary_inputs[0].input_pin_1` | -| BOOT key (active LOW) | 0 | `binary_inputs[0].input_pin_2`; ESP32-S3 ROM download strap and deep-sleep wake | -| SD/LoRa SPI | 7,8,9,10,11,12,13,14 | not OpenDisplay config concepts, never claimed | -| Module enables, all active HIGH | 47 LoRa EN, 44 codec EN, 41 audio PA, 40 AHT20 gate | flag bit 5 defaults them LOW; AHT20 briefly HIGH at wake, see §5 | - -`battery_sense_pin: 3` with a 1:2 divider → `voltage_scaling_factor: 161` -(`3.3 V / 4095 counts × 2 = 0.001612 V/count`). That is the same pin, divider and -factor the M5Stack PaperS3 preset uses, which is the cross-check. - -## 4. Files on this branch - -- `devices/nm-epd-420-4c.yaml` — full device config in `tools/od-device-cli.py` - YAML form. It currently encodes to a **229-byte** packet (version 1, CRC-16 over - the body); verified by a decode→encode round-trip. -- `devices/nm-epd-420.simple-config-preset.json` — the fragment to merge into - `OpenDisplay/opendisplay.org`'s - `httpdocs/firmware/toolbox/simple-config-presets.json` plus the matching - `simple-config-id-registry.json` entry. `driverBoards` index **32**; the display - preset is the existing `ep42yr-400x300` (11); power reuses `battery-650` (9) - with board-specific 600 mAh, 40 s idle hold and 1800 s timer wake overrides. -- `src/display_service.cpp` — the `1004 → EP42YR_400x300` mapping above. -- `include/opendisplay_structs.h` — new `OD_DEVICE_FLAG_NM420_INIT` (bit 5), the - matching `DeviceFlags` changelog entry, and the group comment now reading - "bits 6-7 reserved". Additive only, so no version bump: it accrues under - *Unreleased*, exactly as the 2.1 notes describe. -- `src/main.h` / `src/main.cpp` — `DEVICE_FLAG_NM420_INIT`, default-off GPIO - initialization and deep-sleep output hold on GPIO40/41/43/44/47. -- `src/config_parser.cpp` — calls `nm420_init()` after a config loads when the flag - is set, next to the existing `ws_pp_init()` hook. -- `src/sensor_aht20.*` — one CRC-checked temperature/humidity sample at each - boot/wake, published in the SHT40-compatible 3-byte MSD dynamic slot 7–9. -- `bin/NM-EPD-420-4C_factory_full.bin` — current all-in-one ESP32-S3 image: - bootloader, partition table, `boot_app0`, application, and the NM420 factory - config. Flash the complete image at offset `0x0`. - -### Flashing - -The combined image can be flashed at **`0x0`** with esptool or a compatible -ESP32-S3 flashing utility. It provisions its embedded configuration only when -the device has no stored configuration. A board already holding an older config -must receive the current YAML over BLE: - -```powershell -$env:PYTHONUTF8 = '1' -uv run tools/od-device-cli.py write-config --addr AA:BB:CC:DD:EE:FF ` - --input devices/nm-epd-420-4c.yaml -``` - -To rebuild the combined image with the current configuration: - -```powershell -$env:PYTHONUTF8 = '1' -$env:OPENDISPLAY_FACTORY_CONFIG_HEX = uv run tools/od-device-cli.py encode-config ` - --input devices/nm-epd-420-4c.yaml -$env:PATH = "$HOME\.platformio\packages\xtensa-esp-elf\bin;$env:PATH" -pio run -e esp32-s3-N16R8 -Copy-Item .pio\build\esp32-s3-N16R8\firmware.factory.bin ` - bin\NM-EPD-420-4C_factory_full.bin -``` - -## 5. Low-power peripherals and wake-time sensing - -All four peripheral enable lines are active HIGH: - -| Peripheral | Enable pin | Level this branch forces | -|---|---|---| -| SX126x LoRa module | 47 — `PIN_MODULE_EN` / LoRa EN | LOW | -| ES8311 codec | 44 — `PIN_CODEC_EN` | LOW | -| Audio PA | 41 — `PA_CTRL` | LOW | -| AHT20 | 40 — `PIN_TEMP_CTL` | LOW except for one bounded measurement after each boot/wake | - -The OpenDisplay config format has **no field for a spare output pin**: a pin that -belongs to no display / bus / input / LED / sensor block does not exist as far as -the firmware is concerned, so `nm-epd-420-4c.yaml` cannot switch them off. Left -alone they keep the ESP32 reset default — input, no pull — and each rail then sits -wherever the board's own resistors leave it, which is not a guarantee that the -part is off. - -The NM420 flag selects a board hook to set these rails LOW after config load. -It also parks ADC_EN GPIO43 LOW between voltage reads; the ADC reader still -pulses it HIGH for its measurement. Immediately before deep sleep the firmware -holds all five outputs LOW with ESP32-S3 pad hold, releasing it after wake and -reestablishing LOW before the next reading. This does not control the interval -between a cold power-on reset and successful config load: board pull resistors -and real current measurements must be checked. - -```c -/* include/opendisplay_structs.h */ -#define OD_DEVICE_FLAG_NM420_INIT (1u << 5) /* bit 5; bits 6-7 stay reserved */ - -/* src/main.cpp — called from config_parser.cpp after a config loads, and only - when device_flags bit 5 is set */ -void nm420_init(){ /* 47, 44, 41, 40, 43 default LOW; release sleep holds */ } -``` - -The config sets `device_flags: 0x20`. Because the switch is a config bit and not a -`#define` or a board name, one firmware binary still covers every other device, -and an NM-EPD-420 flashed without the bit behaves exactly as it did before. - -Deep sleep starts after a 40-second idle hold and has a 1800-second timer fallback. -GPIO45 (USER) is not an ESP32-S3 deep-sleep wake pin, but GPIO0 (BOOT) is an RTC -pad, so `sleep_flags: 0` arms it as an active-LOW wake source. The firmware skips -arming a button already held at sleep entry, avoiding an immediate wake loop. - -GPIO0 remains an ESP32-S3 ROM-download strap: release BOOT before reset or power-on -unless entering the ROM downloader intentionally. Its board pull-up keeps it HIGH -while asleep; hardware testing must confirm the pull-up is strong enough through the -full deep-sleep current range. - -Per peripheral: - -- **AHT20 temperature/humidity.** SensorType 7 at I²C `0x38` powers GPIO40 for - a 100 ms startup period, then performs up to two CRC-checked measurements. A - successful sample is written in the three-byte SHT40-compatible MSD format at - dynamic bytes 7–9 and logged as `AHT20: wake-time sample ...`; a failed sample - publishes `FF FF FF` and logs `AHT20: wake-time sample failed ...`. The rail is - then driven LOW and is not polled again while awake. With no other I²C client, - `Wire` is closed before supply removal. Board-level I²C pull-ups may still - back-power an unpowered sensor, so measure the AHT20 rail after shutdown rather - than assuming zero leakage. -- **Home Assistant compatibility.** The current RockBase IoT/OpenDisplay Home - Assistant integration. That version has - `SensorType.SHT40 = 4` but does not define AHT20 type 7, and the integration - creates ambient entities only for SHT40. It therefore ignores this board's - valid AHT20 configuration even when the firmware advertises a good sample. - Do not change the config to type 4: firmware would send SHT40 I²C commands to - the AHT20 and publish an invalid reading. The host-side fix is to add type 7 - to py-opendisplay and create equivalent ambient entities in the HA integration. -- **ES8311 codec + PA, LMD4737 PDM mic.** Audio has no protocol block at all. -- **SD card / SX126x LoRa.** No protocol block; the HSPI pins (7-14) are never - claimed. The module is nevertheless genuinely powered off now, rather than merely - unused. - -## 6. Remaining hardware checks - -Full refresh, buttons, BOOT wake, and battery reporting have been verified. The -following still need targeted measurements or broader visual tests: - -1. **Colour packing order.** `EP42YR_400x300` uses `u8Colors_4clr_v2`; the - host-side 2 bpp BWRY packing has to match it or red and yellow come out - swapped. Send a full-swatch test image first. -2. **Battery scaling.** 161 is derived. Compare `read-msd` against a multimeter - on the cell over its operating range. -3. **Rotation.** `rotation: 0` assumes the panel is mounted in its native 400×300 - landscape orientation. Change to 1/2/3 if the image lands rotated. -4. **BUSY polarity and long refreshes.** The hardware repo drives BUSY as "high - while refreshing"; - bb_epaper's UC81xx path expects the same. If every refresh times out, suspect - this first. -5. **AHT20 rail and host integration.** Check GPIO40 falls LOW after a successful - and failed sample, then update py-opendisplay and Home Assistant before relying - on HA temperature/humidity entities. - -## 7. Verification done so far - -**Uncommitted.** No commit exists; all changes sit in the working tree on branch -`nm-epd-420`. The rule applied here: a change that has not been exercised on -NM-EPD-420-4C hardware does not get committed. - -- Config packet encodes and decodes cleanly (`od-device-cli.py`): 229 bytes, - CRC valid, sensor type 7 / MSD slot 7, device flag 0x20, GPIO0+GPIO45 button - configuration, and a 1800-second timer fallback round-trip. -- `pio run -e esp32-s3-N16R8` compiled and linked the full firmware, generating - `firmware.bin` and the combined `firmware.factory.bin`. On this Windows machine PlatformIO - Core 6.1.19 did not automatically expose the already-installed compiler; - prepending `%USERPROFILE%\\.platformio\\packages\\xtensa-esp-elf\\bin` to PATH - for that one command resolved it. The only compiler warning was in existing - Wi-Fi code, unrelated to this board. -- `pio run -e nrf52840custom` also compiled and linked successfully with the - shared SHT40 MSD packing function used by the new AHT20 path. -- NM-EPD-420-4C hardware test: full refresh, physical buttons, BOOT deep-sleep - wake, and battery reporting work. See [Remaining Hardware Checks](#6-remaining-hardware-checks) - for the work that remains before release.