commit 086a0408022c8277ca3c04b9c92834a0cd7a8876 Author: elizard Date: Thu Oct 8 10:05:56 2026 +0300 Initial ESPHome port for Wake Up Light II diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0636a16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +*.log +__pycache__/ +.DS_Store +/.esphome/ +/secrets.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c822bb --- /dev/null +++ b/README.md @@ -0,0 +1,442 @@ +# Wake Up Light II (Tuya WBR3 / RTL8720CF) → ESPHome + +Local, cloud-free ESPHome firmware and reverse-engineering notes for a **Wake Up Light II** sunrise alarm clock using a **Tuya WBR3** Wi-Fi module and a separate appliance MCU. + +This work was reverse-engineered from a real unit by sniffing the original Tuya MCU UART, dumping the startup handshake, mapping datapoints one function at a time, desoldering/programming the WBR3, and fixing ESPHome's WBR3 alternate-UART pin routing. + +> **Status:** usable. Main lamp, brightness, clock display mode, FM power/volume, both alarm enables/times, Alarm 1 sound/volume/wake-light/sunrise duration, sunset mode, ambient-light power, 12/24 h mode, local web UI, OTA, manual clock setting, and optional SNTP time sync are implemented. Several raw structures remain partially decoded. + +![Wake Up Light II](assets/lamp.jpg) + +## Identified device + +The stock MCU product-information reply is: + +```text +55 aa 03 01 ... +{"p":"ya39qahrcdhvxowz","v":"6.0.2","m":0} +``` + +So the identifiers found on this unit are: + +| Item | Value | +|---|---| +| Product name | Wake Up Light II | +| Tuya PID | `ya39qahrcdhvxowz` | +| Appliance-MCU firmware string | `6.0.2` | +| Wi-Fi module | Tuya `WBR3` | +| Wireless SoC / LibreTiny target | Realtek `RTL8720CF`, AmebaZ2 | +| ESPHome board | `wbr3` | +| Main-MCU protocol | TuyaMCU serial protocol | +| Main-MCU UART | 9600 baud, 8 data bits, no parity, 1 stop bit | +| WBR3 MCU-UART pins | PA13 = RX0, PA14 = TX0 | +| Flash layout | Tuya RTL8720CF layout, 2 × 896 KiB OTA application slots | + +The current `tuya-local` device definition contains **this exact PID** and identifies it as **Wake Up Light II / ACA002-II-WWA**: + +- Source: +- `tuya-local` project: + +That file is useful as a second source for the datapoint map, but it appears to be a **superset** of the physical unit tested here. In particular it defines Alarm 3/4 datapoints, while this lamp's physical interface and indications expose **two alarms**. This repository therefore does not assume DP123/124 are usable. + +## The two controllers + +### 1. Tuya WBR3 — Wi-Fi / ESPHome MCU + +![WBR3 top](assets/wbr3-top.jpg) + +The WBR3 is the module being replaced with ESPHome firmware. LibreTiny identifies it as a Realtek RTL8720CF/AmebaZ2 target. + +| Specification | Value | +|---|---| +| CPU | ARM Cortex-M33 / KM4 class | +| Clock | 100 MHz | +| SRAM | 256 KiB | +| Flash | 2 MiB | +| Supply | 3.0–3.6 V | +| Wi-Fi | 802.11 b/g/n, 2.4 GHz | +| LibreTiny board | `wbr3` | +| Programming UART | UART2: PA15/RX2, PA16/TX2 | +| Lamp communication UART | UART0: PA13/RX0, PA14/TX0 | + +References: + +- LibreTiny WBR3 board page: +- LibreTiny RTL8720CF/AmebaZ2 flashing guide: +- Tuya WBR3 datasheet: + +### 2. Main appliance MCU — IC1 + +![Main appliance MCU](assets/main-mcu.jpg) + +What is known experimentally: + +- It controls the lamp's local appliance logic: display/buttons, alarms, light modes and radio-facing state. +- It communicates with the WBR3 using the Tuya MCU serial protocol at **9600 8N1**. +- It reports product PID `ya39qahrcdhvxowz` and MCU firmware string `6.0.2` in the Tuya product-info response. +- The original device continues to operate locally when the WBR3 firmware is replaced, because ESPHome talks to this MCU instead of directly driving all lamp hardware. + +![Main PCB](assets/main-pcb.jpg) + + +## Stock Tuya UART handshake + +The original WBR3 sends the standard Tuya handshake to the appliance MCU. Examples captured from this unit: + +```text +WBR3 -> MCU +55 aa 00 00 00 00 ff heartbeat +55 aa 00 01 00 00 00 product-info request +55 aa 00 02 00 00 01 working-mode request +55 aa 00 03 00 01 00 03 Wi-Fi state report + +MCU -> WBR3 +55 aa 03 00 00 01 01 04 heartbeat reply +55 aa 03 01 00 2a ... product-info reply +``` + +The product payload decodes to: + +```json +{"p":"ya39qahrcdhvxowz","v":"6.0.2","m":0} +``` + +## Datapoint map + +“Confirmed” means directly observed on the physical unit. “Exact PID definition” means present in the `tuya-local` profile for `ya39qahrcdhvxowz`, but not necessarily verified on this exact hardware revision. + +| DP | Tuya type | Meaning | Status / notes | +|---:|---|---|---| +| 101 | bool | Main/dusk light power | **Confirmed** | +| 102 | integer | Main light brightness | **Confirmed**, range 10–1000. Observed steps: 10, 60, 110, 160, 210, 270, 320, 370, 420, 470, 530, 580, 630, 680, 730, 790, 840, 890, 940, 1000 | +| 103 | string | Clock time | **Confirmed**, `HHMMSS`, e.g. `173700` = 17:37:00; writable | +| 104 | integer | Clock/display brightness mode | **Confirmed** 0=off, 1=day/on, 3=auto; exact-PID definition also gives 2=night | +| 105 | bool | FM radio power | **Confirmed** | +| 106 | integer | Radio volume | **Confirmed**, 1–16 | +| 107 | raw/base64 | FM station/preset data | **Confirmed present**, encoding not decoded; examples `14:02`, `14:04`, `14:05`, `14:06` | +| 109 | bool | Alarm 1 enable | **Confirmed** | +| 116 | integer | Snooze duration | Exact PID definition only, 8–15 min | +| 117 | string | Snooze action | Exact PID definition only | +| 121 | bool | Sunset / sleep-aid mode | **Confirmed** | +| 122 | bool | Alarm 2 enable | **Confirmed** | +| 123 | bool | Alarm 3 | Exact PID definition only; **not exposed by this physical unit** | +| 124 | bool | Alarm 4 | Exact PID definition only; **not exposed by this physical unit** | +| 125 | bool | Network-time setting | Exact PID definition only | +| 126 | bool/button | Radio seek/stop | Exact PID definition only | +| 127 | raw | Alarm configuration structure | **Confirmed and partially decoded**, see below | +| 128 | raw | Sunset/sleep configuration | **Confirmed**, partially decoded only | +| 129 | bool | Ambient/color-light power | **Confirmed**; exact PID definition agrees | +| 130 | bool | Snooze | Exact PID definition only | +| 131 | bool | 12/24-hour mode | **Confirmed**, false/off=12 h, true/on=24 h | +| 132 | string | Ambient color/effect | Exact PID definition; exposed read-only in YAML until verified | + +The exact-PID `tuya-local` profile maps DP132 color strings to white/red/orange/yellow/lime/cyan/blue/magenta and also uses the same DP for an effect field. Treat that as reference information until this hardware revision is tested directly. + +## DP127 alarm structure + +DP127 is 16 bytes on this unit. The safest implementation is to **cache the MCU-provided packet and modify only decoded bytes**. + +| Byte(s) | Meaning | Confidence | +|---|---|---| +| 0 | Alarm 1 enable | confirmed | +| 1–2 | Alarm 1 time, big-endian minutes since midnight | confirmed | +| 3 | Alarm 1 weekday mask | confirmed field; bit-to-weekday order not decoded; `0x7F` = all days observed | +| 4 | Unknown; `0x1E` commonly observed | unknown | +| 5 | Alarm 1 wake-light enable | confirmed, 0/1 | +| 6 | Alarm 1 sound | confirmed: 1–7 built-in, 8=FM; family manual documents 0=no sound | +| 7 | Alarm 1 volume | confirmed, 1–16 | +| 8 | Unknown; `0x04` commonly observed | unknown | +| 9 | Sunrise lead/duration in minutes | confirmed; 10 and 20 observed; family manual says 10–60 min | +| 10 | Wake-light brightness | confirmed, 0–20; 0=off | +| 11 | Unknown | unknown | +| 12 | Alarm 2 enable | confirmed | +| 13–14 | Alarm 2 time, big-endian minutes since midnight | confirmed | +| 15 | Alarm 2 weekday mask | confirmed field; bit order not decoded | + +Time encoding examples: + +```text +14:00 = 840 minutes = 0x0348 -> 03:48 +15:00 = 900 minutes = 0x0384 -> 03:84 +15:01 = 901 minutes = 0x0385 -> 03:85 +``` + +Measured Alarm 1 packets include: + +```text +01:03:48:7F:1E:00:01:01:04:0A:00:00:00:03:48:7F +# A1 14:00, sound 1 + +01:03:85:7F:1E:01:02:02:04:14:0A:00:00:03:48:7F +# A1 15:01, sound 2, volume 2, wake light 10/20, sunrise 20 min +``` + +An important finding: changing **Alarm 2 sound, volume, wake-light brightness and sunrise time did not change DP127**. Only Alarm 2 enable/time/day fields are confirmed here. Do not assume the Alarm 1 middle block is shared with Alarm 2. + +## DP128 sunset/sleep structure + +DP128 is confirmed to carry sleep/sunset settings, but its full byte map is still TODO. Two captured examples are: + +```text +01:01:01:0A:0A:04:0A:0C:0A:01 +01:01:02:08:14:04:14:14:14:01 +``` + +The physical family manual describes sleep-aid duration, light brightness, sleep sound/radio and volume. More one-setting-at-a-time captures are needed before making DP128 writable. + +## Why an ESPHome UART patch is required + +The lamp uses **UART0 on WBR3 PA13/PA14**. LibreTiny documents PA13=RX0 and PA14=TX0, but ESPHome's LibreTiny UART component has historically selected only one board-default pin pair for each hardware UART. With stock code the Tuya component transmitted in software logs but received no replies and repeatedly failed at: + +```text +Initialization failed at init_state 0 +``` + +Relevant upstream references: + +- ESPHome issue #13596: +- Closed PR #13629 (“libretiny uart now allows more than one set of pins per uart”): + +The PR moved toward LibreTiny's `setPins()` API but was not merged. The targeted workaround used here does two things: + +1. Accepts `(TX=PA14, RX=PA13)` as hardware UART0. +2. Calls `Serial0.setPins(rx_pin, tx_pin)` before the normal two-argument `begin()`. + +> This is a **UART patch, not an I²C patch**. + +Patch files in this repository: + +- [`patches/wbr3-pa13-pa14-uart.patch`](patches/wbr3-pa13-pa14-uart.patch) +- [`patches/apply_uart_patch.py`](patches/apply_uart_patch.py) + +The patch file is written against the 2026.9-era ESPHome source layout. The helper script fails instead of patching blindly if the source shape changes. + +### Apply the patch to a pip/Conda ESPHome install + +Activate the same Python environment that runs `esphome`, then: + +```bash +python3 patches/apply_uart_patch.py +esphome clean wake-up-light-ii.yaml +esphome compile wake-up-light-ii.yaml +``` + +A successful patched boot should log: + +```text +Using alternate UART0 pins RX=PA13 TX=PA14 +``` + +ESPHome upgrades can overwrite the patched source file, so re-check after every upgrade. If upstream issue #13596 is fixed, remove the local patch rather than stacking it on top. + +## WBR3 wired programming — official LibreTiny wiring and order + +![WBR3 underside](assets/wbr3-bottom.jpg) + +Do not use the normal appliance UART (PA13/PA14) for ROM flashing. **RTL8720CF is flashed through UART2.** The following connections and sequence come from the LibreTiny WBR3/AmebaZ2 documentation. + +### Connections + +| PC / supply | WBR3 | +|---|---| +| USB-UART **TX** | **PA15 / RX2** | +| USB-UART **RX** | **PA16 / TX2** | +| USB-UART GND | GND | +| Stable regulated **3.3 V** | VCC | +| **3.3 V** boot strap | **PA00** | +| Leave floating or 3.3 V | **PA13**; do **not** pull it to GND | +| Brief reset to GND | **CEN** | + +PA00 is on the underside of WBR3, so LibreTiny notes that the module normally needs to be **desoldered** for UART flashing. + +### Official download-mode order + +LibreTiny's documented sequence is: + +1. Connect the USB-UART adapter. +2. Connect **PA00 to 3.3 V**. +3. Power-cycle the module **or** briefly short **CEN to GND** and release it. +4. Start the flashing process. + +If a serial terminal is open at the time, successful boot into ROM download mode may print: + +```text +Open Download Mode on UART +``` + +Authoritative references: + +- WBR3 quick flashing guide: +- RTL8720CF/AmebaZ2 flashing guide: + +## Backup, compile and flash + +Install tools (preferably in a venv/Conda environment): + +```bash +python3 -m pip install -U esphome ltchiptool +``` + +### 1. Back up the stock WBR3 flash first + +Enter download mode using the sequence above, then: + +```bash +ltchiptool list families +ltchiptool flash read ambz2 wake-up-light-stock.bin -d /dev/ttyUSB0 +sha256sum wake-up-light-stock.bin +ls -lh wake-up-light-stock.bin +``` + +WBR3 has 2 MiB flash, so preserve that dump somewhere safe. + +### 2. Apply the UART patch and compile + +```bash +python3 patches/apply_uart_patch.py +esphome clean wake-up-light-ii.yaml +esphome compile wake-up-light-ii.yaml +``` + +Find the generated UF2: + +```bash +find .esphome/build/wake-up-light-ii -name firmware.uf2 -type f -print +``` + +You can ask `ltchiptool` to identify it before writing: + +```bash +ltchiptool flash file \ + .esphome/build/wake-up-light-ii/.pioenvs/wake-up-light-ii/firmware.uf2 +``` + +### 3. Write ESPHome over UART2 + +Re-enter download mode and flash: + +```bash +ltchiptool flash write \ + .esphome/build/wake-up-light-ii/.pioenvs/wake-up-light-ii/firmware.uf2 \ + -d /dev/ttyUSB0 +``` + +`ltchiptool` documentation recommends the generated LibreTiny `.uf2` and auto-detects the correct file type/offsets: + + +### 4. Re-solder WBR3 and use OTA afterwards + +The supplied YAML creates a permanent standalone AP by default: + +```text +SSID: Wake-Up-Light-II +IP: 192.168.4.1 +Web: http://192.168.4.1/ +``` + +There is intentionally **no `captive_portal:`** in the final YAML. With a captive portal enabled, the user can be dropped into the Wi-Fi provisioning/OTA page instead of the normal ESPHome control UI. `web_server.local: true` embeds the web assets so the control page works without Internet access. + +Native ESPHome OTA: + +```bash +esphome upload wake-up-light-ii.yaml --device 192.168.4.1 +``` + +Web-server OTA if explicitly needed: + +```bash +esphome upload wake-up-light-ii.yaml \ + --device 192.168.4.1 \ + --ota-platform web_server +``` + +## ESPHome configuration + +The current self-contained configuration is: + +- [`wake-up-light-ii.yaml`](wake-up-light-ii.yaml) + +It includes: + +- WBR3 / RTL8720CF board definition. +- Patched UART0 on PA13/PA14 at 9600 8N1. +- TuyaMCU integration. +- Main light DP101/102. +- Writable lamp clock DP103. +- Clock display mode DP104. +- FM power DP105 and volume DP106. +- Alarm 1/2 enable and writable times. +- Writable Alarm 1 sound, volume, wake-light brightness and sunrise duration while preserving unknown DP127 bytes. +- Sunset/sleep power DP121. +- Ambient-light power DP129. +- 12/24 h mode DP131. +- Read-only diagnostics for DP107/127/128/132. +- Manual clock setting from the local web UI. +- Optional SNTP synchronization when the device has upstream network access. +- Permanent offline AP + local web server. +- Native ESPHome OTA + web-server OTA. + +### Time sync note + +The lamp can be used entirely offline. In standalone AP-only mode there is no route to public NTP servers, so use the **Lamp Clock** time control in the web UI. If you uncomment station Wi-Fi in the YAML, SNTP can update ESPHome's time and the firmware writes the current `HHMMSS` string back to DP103. The `tuya.time_id` integration is also enabled for standard Tuya time queries. + +## Flash/RAM headroom observed + +A working build during development reported approximately: + +```text +IRAM symbols: ~52 bytes +RAM: 5.1% (13,369 / 262,144 bytes) +Flash: 62.2% (570,973 / 917,504 bytes) +``` + +So there is substantial headroom for additional local features, while still leaving sensible OTA/application margin. + +## Related family manual + +A Dekala/Moes-style Wake Up Light II family manual is useful for understanding the physical feature ranges (alarm sounds, volume 1–16, wake-light brightness 0–20, sunrise 10–60 minutes, sleep aid, FM 87.5–108 MHz): + + + +That manual is for a related model/revision, so use it as behavioral reference rather than proof of identical electronics. + +## TODO / worthwhile additions + +For this lamp, the most worthwhile additions are: + +- **Finish DP127 decoding:** weekday-bit order and unknown bytes 4, 8 and 11. +- **Find Alarm 2 extended settings:** sound, volume, wake-light level and sunrise duration are stored somewhere other than the DP127 middle block on this unit. +- **Fully decode DP128:** make sunset/sleep duration, brightness, sound/radio choice and volume writable. +- **Decode DP107:** turn the raw FM payload into actual frequency/preset information and add previous/next/scan controls; verify DP126. +- **Ambient/color lamp control:** verify DP132 on this revision and expose color/effect controls rather than only DP129 power. +- **Snooze:** verify DP116/117/130 from the exact-PID definition and expose them safely. +- **Better local web UI:** move to Web Server v3 and group Light / Clock / Alarm 1 / Alarm 2 / Radio / Sunset / Diagnostics. +- **Fully autonomous schedules:** add software-only extra alarms or weekday schedules without depending on Tuya cloud/app limits. +- **Better clock correction:** configurable local NTP server, LAN-only synchronization, and optional drift measurement/correction. +- **Astronomical sunrise/sunset:** use ESPHome's local sun calculations for real dusk/dawn behavior. +- **Home Assistant native API:** optional `api:` support while retaining the standalone web UI. +- **Diagnostics:** Wi-Fi RSSI, uptime, reboot reason, ESPHome/LibreTiny version, Tuya-link state and raw-packet viewer. +- **Identify IC1:** obtain a board with readable top marking or otherwise identify the main appliance MCU. +- **Upstream the UART fix:** replace the local PA13/PA14 workaround when ESPHome gains proper LibreTiny alternate-UART pin mapping. + +## Safety / recovery notes + +- Make a full stock flash dump before writing custom firmware. +- WBR3 is a **3.3 V** device; do not power it from 5 V. +- Use common ground between the module supply and USB-UART adapter. +- Do not pull PA13 to GND when entering RTL8720CF download mode. + +## Credits / references + +Made with help of ChatGPT. This reverse-engineering work builds on ESPHome, LibreTiny, ltchiptool, Tuya's public module documentation, and the community `tuya-local` device definition. + +- ESPHome: +- ESPHome Tuya MCU component: +- ESPHome Web Server: +- LibreTiny WBR3: +- LibreTiny AmebaZ2: +- ltchiptool: +- Tuya WBR3 datasheet: +- `tuya-local` exact-PID definition: +- ESPHome issue #13596: +- ESPHome PR #13629: diff --git a/assets/lamp.jpg b/assets/lamp.jpg new file mode 100644 index 0000000..1b35801 Binary files /dev/null and b/assets/lamp.jpg differ diff --git a/assets/main-mcu.jpg b/assets/main-mcu.jpg new file mode 100644 index 0000000..855cdbc Binary files /dev/null and b/assets/main-mcu.jpg differ diff --git a/assets/main-pcb.jpg b/assets/main-pcb.jpg new file mode 100644 index 0000000..3260a09 Binary files /dev/null and b/assets/main-pcb.jpg differ diff --git a/assets/wbr3-bottom.jpg b/assets/wbr3-bottom.jpg new file mode 100644 index 0000000..8fef33b Binary files /dev/null and b/assets/wbr3-bottom.jpg differ diff --git a/assets/wbr3-top.jpg b/assets/wbr3-top.jpg new file mode 100644 index 0000000..ec6143a Binary files /dev/null and b/assets/wbr3-top.jpg differ diff --git a/patches/apply_uart_patch.py b/patches/apply_uart_patch.py new file mode 100644 index 0000000..182acc1 --- /dev/null +++ b/patches/apply_uart_patch.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Apply the Wake Up Light II WBR3 PA13/PA14 UART0 workaround to installed ESPHome. + +This is intentionally narrow: it only adds the alternate PA13/PA14 UART0 mapping +needed by this lamp, then calls LibreTiny Serial0.setPins() before begin(). +""" +from pathlib import Path +import shutil +import sys + +try: + import esphome +except ImportError: + sys.exit("ERROR: ESPHome is not importable in this Python environment") + +p = Path(esphome.__file__).parent / "components" / "uart" / "uart_component_libretiny.cpp" +if not p.exists(): + sys.exit(f"ERROR: not found: {p}") + +s = p.read_text() +marker = 'Using alternate UART0 pins RX=PA13 TX=PA14' +if marker in s: + print(f"Already patched: {p}") + sys.exit(0) + +old_select = '''#if LT_HW_UART0 + else if ((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX) && + !should_fallback_to_software_serial()) { + this->serial_ = &Serial0; + this->hardware_idx_ = 0; + } +#endif +''' +new_select = '''#if LT_HW_UART0 + else if ((((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && + (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX)) || + (tx_pin == 14 && rx_pin == 13)) && + !should_fallback_to_software_serial()) { + this->serial_ = &Serial0; + this->hardware_idx_ = 0; + } +#endif +''' + +old_begin = ' this->serial_->begin(this->baud_rate_, get_config());\n' +new_begin = ''' if (this->hardware_idx_ == 0 && tx_pin == 14 && rx_pin == 13) { + ESP_LOGI(TAG, "Using alternate UART0 pins RX=PA13 TX=PA14"); + Serial0.setPins(rx_pin, tx_pin); + } + this->serial_->begin(this->baud_rate_, get_config()); +''' + +if old_select not in s: + sys.exit( + "ERROR: UART0 selection block not found. ESPHome source has changed; " + "do not patch blindly. Compare with ESPHome issue #13596 / PR #13629." + ) +if old_begin not in s: + sys.exit("ERROR: serial begin() line not found; ESPHome source has changed") + +backup = p.with_suffix(p.suffix + ".wake-up-light-backup") +if not backup.exists(): + shutil.copy2(p, backup) + print(f"Backup: {backup}") + +s = s.replace(old_select, new_select, 1) +s = s.replace(old_begin, new_begin, 1) +p.write_text(s) +print(f"Patched: {p}") +print("Run: esphome clean wake-up-light-ii.yaml && esphome compile wake-up-light-ii.yaml") diff --git a/patches/wbr3-pa13-pa14-uart.patch b/patches/wbr3-pa13-pa14-uart.patch new file mode 100644 index 0000000..db91577 --- /dev/null +++ b/patches/wbr3-pa13-pa14-uart.patch @@ -0,0 +1,26 @@ +--- a/esphome/components/uart/uart_component_libretiny.cpp ++++ b/esphome/components/uart/uart_component_libretiny.cpp +@@ -64,8 +64,10 @@ void LibreTinyUARTComponent::setup() { + return; + } + #if LT_HW_UART0 +- else if ((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX) && +- !should_fallback_to_software_serial()) { ++ else if ((((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && ++ (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX)) || ++ (tx_pin == 14 && rx_pin == 13)) && ++ !should_fallback_to_software_serial()) { + this->serial_ = &Serial0; + this->hardware_idx_ = 0; + } +@@ -111,6 +113,10 @@ void LibreTinyUARTComponent::setup() { + return; + #endif + } ++ if (this->hardware_idx_ == 0 && tx_pin == 14 && rx_pin == 13) { ++ ESP_LOGI(TAG, "Using alternate UART0 pins RX=PA13 TX=PA14"); ++ Serial0.setPins(rx_pin, tx_pin); ++ } + this->serial_->begin(this->baud_rate_, get_config()); + } + void LibreTinyUARTComponent::dump_config() { diff --git a/wake-up-light-ii.yaml b/wake-up-light-ii.yaml new file mode 100644 index 0000000..3a49d4a --- /dev/null +++ b/wake-up-light-ii.yaml @@ -0,0 +1,586 @@ +############################################################################### +# Wake Up Light II -> ESPHome +# Tuya PID: ya39qahrcdhvxowz +# Hardware observed in this unit: Tuya WBR3 / Realtek RTL8720CF +# Main appliance MCU <-> WBR3: TuyaMCU protocol, UART0, 9600 8N1 +# WBR3 PA13 = RX0 (from appliance MCU) +# WBR3 PA14 = TX0 (to appliance MCU) +# +# IMPORTANT: stock ESPHome/LibreTiny UART selection may not route UART0 to +# PA13/PA14 on WBR3. Apply patches/wbr3-pa13-pa14-uart.patch (or the helper +# script in patches/) before compiling if Tuya stays at init_state 0. +############################################################################### + +substitutions: + device_name: wake-up-light-ii + friendly_name: "Wake Up Light II" + ap_ssid: "Wake-Up-Light-II" + ap_password: "wakeup1234" # Change before publishing/regular use. + timezone: "Europe/Berlin" # Change to your IANA timezone. + +esphome: + name: ${device_name} + friendly_name: ${friendly_name} + +rtl87xx: + board: wbr3 + +# Disable serial logging: PA13/PA14 belong to the Tuya MCU link. +logger: + baud_rate: 0 + level: DEBUG + +uart: + id: tuya_uart + rx_pin: PA13 + tx_pin: PA14 + baud_rate: 9600 + data_bits: 8 + parity: NONE + stop_bits: 1 + + # Useful while reverse-engineering additional datapoints: + # debug: + # direction: BOTH + +############################################################################### +# Cached state +############################################################################### + +globals: + # DP127 is a 16-byte alarm structure on this unit. Never build it from + # scratch: cache what the appliance MCU reported and only edit known bytes. + - id: alarm_raw + type: uint8_t[16] + restore_value: no + + - id: alarm_raw_valid + type: bool + restore_value: no + initial_value: 'false' + + - id: lamp_clock_hour + type: uint8_t + restore_value: no + initial_value: '0' + + - id: lamp_clock_minute + type: uint8_t + restore_value: no + initial_value: '0' + + - id: lamp_clock_second + type: uint8_t + restore_value: no + initial_value: '0' + + - id: lamp_clock_valid + type: bool + restore_value: no + initial_value: 'false' + +############################################################################### +# Optional network time +# +# SNTP works only if the lamp can reach an NTP server. In AP-only operation +# there is no upstream network, so use the writable "Lamp Clock" control. +############################################################################### + +time: + - platform: sntp + id: network_time + timezone: ${timezone} + servers: + - 0.pool.ntp.org + - 1.pool.ntp.org + - 2.pool.ntp.org + update_interval: 1h + + on_time_sync: + then: + - delay: 2s + - script.execute: sync_lamp_clock + + on_time: + - seconds: 0 + minutes: 0 + then: + - script.execute: sync_lamp_clock + +############################################################################### +# Tuya MCU +############################################################################### + +tuya: + id: tuya_mcu + time_id: network_time + + on_datapoint_update: + # DP103: ASCII HHMMSS, for example "173700" = 17:37:00. + - sensor_datapoint: 103 + datapoint_type: string + then: + - lambda: |- + if (x.size() != 6) return; + auto d = [](char c) -> int { return (c >= '0' && c <= '9') ? c - '0' : -1; }; + int h1=d(x[0]), h2=d(x[1]), m1=d(x[2]), m2=d(x[3]), s1=d(x[4]), s2=d(x[5]); + if (h1 < 0 || h2 < 0 || m1 < 0 || m2 < 0 || s1 < 0 || s2 < 0) return; + uint8_t hh = h1 * 10 + h2; + uint8_t mm = m1 * 10 + m2; + uint8_t ss = s1 * 10 + s2; + if (hh > 23 || mm > 59 || ss > 59) return; + id(lamp_clock_hour) = hh; + id(lamp_clock_minute) = mm; + id(lamp_clock_second) = ss; + id(lamp_clock_valid) = true; + char buf[16]; + snprintf(buf, sizeof(buf), "%02u:%02u:%02u", hh, mm, ss); + id(clock_time_text).publish_state(buf); + + # DP107: FM station/preset data. Encoding is not decoded yet. + - sensor_datapoint: 107 + datapoint_type: raw + then: + - lambda: |- + id(radio_station_raw).publish_state(format_hex_pretty(x)); + + # Keep cached DP127 enable bytes coherent when the dedicated enable DPs move. + - sensor_datapoint: 109 + datapoint_type: bool + then: + - lambda: |- + if (id(alarm_raw_valid)) id(alarm_raw)[0] = x ? 1 : 0; + + - sensor_datapoint: 122 + datapoint_type: bool + then: + - lambda: |- + if (id(alarm_raw_valid)) id(alarm_raw)[12] = x ? 1 : 0; + + # DP127: alarm configuration. Confirmed 16-byte layout on this unit: + # [00] Alarm 1 enabled + # [01:02] Alarm 1 time = minutes since midnight, big-endian + # [03] Alarm 1 weekday mask (0x7F observed = all days; bit order unknown) + # [04] unknown (0x1E observed) + # [05] Alarm 1 wake-light enable (0/1) + # [06] Alarm 1 sound: 0=off, 1..7=built-in, 8=FM + # [07] Alarm 1 volume 1..16 + # [08] unknown (0x04 observed) + # [09] sunrise lead/duration in minutes (10..60 according to manual) + # [10] wake-light brightness 0..20 + # [11] unknown + # [12] Alarm 2 enabled + # [13:14] Alarm 2 time = minutes since midnight, big-endian + # [15] Alarm 2 weekday mask + # Alarm 2 sound/volume/light/duration changes did NOT change DP127. + - sensor_datapoint: 127 + datapoint_type: raw + then: + - lambda: |- + id(alarm_settings_raw).publish_state(format_hex_pretty(x)); + if (x.size() != 16) { + ESP_LOGW("alarm", "Unexpected DP127 size: %u", (unsigned) x.size()); + return; + } + for (size_t i = 0; i < 16; i++) id(alarm_raw)[i] = x[i]; + id(alarm_raw_valid) = true; + + uint16_t a1 = (static_cast(x[1]) << 8) | x[2]; + uint16_t a2 = (static_cast(x[13]) << 8) | x[14]; + unsigned a1_h = a1 / 60, a1_m = a1 % 60; + unsigned a2_h = a2 / 60, a2_m = a2 % 60; + + char sound[20]; + if (x[6] == 0) snprintf(sound, sizeof(sound), "Sound off"); + else if (x[6] == 8) snprintf(sound, sizeof(sound), "FM"); + else snprintf(sound, sizeof(sound), "Sound %u", (unsigned) x[6]); + + char buf[256]; + snprintf(buf, sizeof(buf), + "A1 %s %02u:%02u days=0x%02X | %s vol=%u | light=%s %u/20 | sunrise=%u min | A2 %s %02u:%02u days=0x%02X", + x[0] ? "ON" : "OFF", a1_h, a1_m, x[3], sound, x[7], + x[5] ? "ON" : "OFF", x[10], x[9], + x[12] ? "ON" : "OFF", a2_h, a2_m, x[15]); + id(alarm_summary).publish_state(buf); + + # DP128: sleep/sunset structure, not fully decoded yet. + - sensor_datapoint: 128 + datapoint_type: raw + then: + - lambda: |- + id(sunset_settings_raw).publish_state(format_hex_pretty(x)); + + # DP132 exists in the exact-PID tuya-local definition as ambient color/effect. + # Keep it read-only until this hardware revision is mapped directly. + - sensor_datapoint: 132 + datapoint_type: string + then: + - lambda: |- + id(ambient_mode_raw).publish_state(x); + +############################################################################### +# Helper scripts +############################################################################### + +script: + - id: send_alarm_settings + mode: restart + then: + - lambda: |- + if (!id(alarm_raw_valid)) { + ESP_LOGW("alarm", "DP127 has not been received yet; refusing to overwrite it"); + return; + } + std::vector data(id(alarm_raw), id(alarm_raw) + 16); + ESP_LOGD("alarm", "Writing DP127: %s", format_hex_pretty(data).c_str()); + id(tuya_mcu).set_raw_datapoint_value(127, data); + + - id: sync_lamp_clock + mode: restart + then: + - lambda: |- + auto now = id(network_time).now(); + if (!now.is_valid()) { + ESP_LOGW("clock", "No valid network time; use the Lamp Clock control in the web UI"); + return; + } + char buf[7]; + snprintf(buf, sizeof(buf), "%02u%02u%02u", now.hour, now.minute, now.second); + ESP_LOGI("clock", "Syncing lamp clock to %s", buf); + id(tuya_mcu).set_string_datapoint_value(103, std::string(buf)); + +############################################################################### +# Main lamp +############################################################################### + +light: + - platform: tuya + id: main_light + name: "Main Light" + switch_datapoint: 101 + dimmer_datapoint: 102 + min_value: 10 + max_value: 1000 + gamma_correct: 1.0 + default_transition_length: 0s + +############################################################################### +# Writable clock/alarm times +############################################################################### + +datetime: + - platform: template + id: lamp_clock + name: "Lamp Clock" + type: time + update_interval: 2s + lambda: |- + if (!id(lamp_clock_valid)) return {}; + ESPTime t{}; + t.hour = id(lamp_clock_hour); + t.minute = id(lamp_clock_minute); + t.second = id(lamp_clock_second); + return t; + set_action: + - lambda: |- + char buf[7]; + snprintf(buf, sizeof(buf), "%02u%02u%02u", x.hour, x.minute, x.second); + ESP_LOGI("clock", "Setting lamp clock to %s", buf); + id(tuya_mcu).set_string_datapoint_value(103, std::string(buf)); + + - platform: template + id: alarm_1_time + name: "Alarm 1 Time" + type: time + update_interval: 2s + lambda: |- + if (!id(alarm_raw_valid)) return {}; + uint16_t mins = (static_cast(id(alarm_raw)[1]) << 8) | id(alarm_raw)[2]; + if (mins >= 1440) return {}; + ESPTime t{}; + t.hour = mins / 60; + t.minute = mins % 60; + t.second = 0; + return t; + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + uint16_t mins = static_cast(x.hour) * 60 + x.minute; + id(alarm_raw)[1] = (mins >> 8) & 0xFF; + id(alarm_raw)[2] = mins & 0xFF; + - script.execute: send_alarm_settings + + - platform: template + id: alarm_2_time + name: "Alarm 2 Time" + type: time + update_interval: 2s + lambda: |- + if (!id(alarm_raw_valid)) return {}; + uint16_t mins = (static_cast(id(alarm_raw)[13]) << 8) | id(alarm_raw)[14]; + if (mins >= 1440) return {}; + ESPTime t{}; + t.hour = mins / 60; + t.minute = mins % 60; + t.second = 0; + return t; + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + uint16_t mins = static_cast(x.hour) * 60 + x.minute; + id(alarm_raw)[13] = (mins >> 8) & 0xFF; + id(alarm_raw)[14] = mins & 0xFF; + - script.execute: send_alarm_settings + +############################################################################### +# Selects +############################################################################### + +select: + - platform: tuya + id: clock_display_mode + name: "Clock Display" + int_datapoint: 104 + options: + 0: "Off" + 1: "Day" + 2: "Night" + 3: "Auto" + + - platform: template + id: alarm_1_sound + name: "Alarm 1 Sound" + options: + - "Off" + - "Sound 1" + - "Sound 2" + - "Sound 3" + - "Sound 4" + - "Sound 5" + - "Sound 6" + - "Sound 7" + - "FM Radio" + update_interval: 2s + lambda: |- + if (!id(alarm_raw_valid)) return {}; + uint8_t s = id(alarm_raw)[6]; + if (s == 0) return std::string("Off"); + if (s == 8) return std::string("FM Radio"); + if (s >= 1 && s <= 7) return std::string("Sound ") + std::to_string(s); + return {}; + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + uint8_t value = 0; + if (x == "Sound 1") value = 1; + else if (x == "Sound 2") value = 2; + else if (x == "Sound 3") value = 3; + else if (x == "Sound 4") value = 4; + else if (x == "Sound 5") value = 5; + else if (x == "Sound 6") value = 6; + else if (x == "Sound 7") value = 7; + else if (x == "FM Radio") value = 8; + id(alarm_raw)[6] = value; + - script.execute: send_alarm_settings + +############################################################################### +# Numbers +############################################################################### + +number: + - platform: tuya + id: radio_volume + name: "Radio Volume" + number_datapoint: 106 + min_value: 1 + max_value: 16 + step: 1 + + - platform: template + id: alarm_1_volume + name: "Alarm 1 Volume" + min_value: 1 + max_value: 16 + step: 1 + update_interval: 2s + lambda: |- + if (!id(alarm_raw_valid)) return {}; + return static_cast(id(alarm_raw)[7]); + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + id(alarm_raw)[7] = static_cast(x); + - script.execute: send_alarm_settings + + - platform: template + id: alarm_1_wake_brightness + name: "Alarm 1 Wake Light" + min_value: 0 + max_value: 20 + step: 1 + update_interval: 2s + lambda: |- + if (!id(alarm_raw_valid)) return {}; + if (id(alarm_raw)[5] == 0) return 0.0f; + return static_cast(id(alarm_raw)[10]); + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + uint8_t level = static_cast(x); + if (level == 0) { + id(alarm_raw)[5] = 0; + id(alarm_raw)[10] = 0; + } else { + id(alarm_raw)[5] = 1; + id(alarm_raw)[10] = level; + } + - script.execute: send_alarm_settings + + - platform: template + id: alarm_1_sunrise_duration + name: "Alarm 1 Sunrise Duration" + unit_of_measurement: "min" + min_value: 10 + max_value: 60 + step: 10 + update_interval: 2s + lambda: |- + if (!id(alarm_raw_valid)) return {}; + return static_cast(id(alarm_raw)[9]); + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + id(alarm_raw)[9] = static_cast(x); + - script.execute: send_alarm_settings + +############################################################################### +# Switches +############################################################################### + +switch: + - platform: tuya + id: fm_radio + name: "FM Radio" + switch_datapoint: 105 + + - platform: tuya + id: alarm_1_enable + name: "Alarm 1" + switch_datapoint: 109 + + - platform: tuya + id: alarm_2_enable + name: "Alarm 2" + switch_datapoint: 122 + + - platform: tuya + id: sunset_mode + name: "Sunset / Sleep Mode" + switch_datapoint: 121 + + - platform: tuya + id: ambient_light + name: "Ambient Light" + switch_datapoint: 129 + + - platform: tuya + id: clock_24_hour + name: "24 Hour Clock" + switch_datapoint: 131 + +############################################################################### +# Diagnostics / still-undecoded payloads +############################################################################### + +text_sensor: + - platform: template + id: clock_time_text + name: "Clock Time" + update_interval: never + + - platform: template + id: radio_station_raw + name: "Radio Station Raw" + update_interval: never + + - platform: template + id: alarm_summary + name: "Alarm Settings" + update_interval: never + + - platform: template + id: alarm_settings_raw + name: "Alarm Settings Raw" + update_interval: never + + - platform: template + id: sunset_settings_raw + name: "Sunset Settings Raw" + update_interval: never + + - platform: template + id: ambient_mode_raw + name: "Ambient Mode Raw" + update_interval: never + +############################################################################### +# Buttons +############################################################################### + +button: + - platform: template + name: "Sync Lamp Clock Now" + icon: "mdi:clock-sync" + on_press: + - script.execute: sync_lamp_clock + + - platform: restart + name: "Restart Wake Up Light" + +############################################################################### +# Networking +# +# Default: permanent standalone AP. Connect to Wake-Up-Light-II and open +# http://192.168.4.1/ . No Internet is required for the local web UI. +# +# To also join a normal 2.4 GHz network (needed for public SNTP), uncomment: +# ssid: !secret wifi_ssid +# password: !secret wifi_password +# The AP below then becomes fallback AP. +# +# Do NOT add captive_portal if you want the normal control UI at 192.168.4.1; +# captive_portal presents the Wi-Fi provisioning/OTA page instead. +############################################################################### + +wifi: + # ssid: !secret wifi_ssid + # password: !secret wifi_password + ap: + ssid: ${ap_ssid} + password: ${ap_password} + +web_server: + port: 80 + version: 2 + local: true + # Recommended on a shared LAN: + # auth: + # username: admin + # password: !secret web_password + +ota: + - platform: esphome + - platform: web_server + +############################################################################### +# Exact-PID tuya-local definition also lists these DPs, but they are not enabled +# above until verified on this physical two-alarm hardware revision: +# DP116 snooze duration (8..15 min) +# DP117 snooze action +# DP125 network-time switch +# DP126 radio seek/stop +# DP130 snooze +# DP132 ambient color/effect (currently shown read-only as Ambient Mode Raw) +# It also lists DP123/124 as alarm 3/4, but this unit has only two physical +# alarms/indications, so they are intentionally not exposed here. +###############################################################################