############################################################################### # 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. ###############################################################################