############################################################################### # 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) # # Stock ESPHome/LibreTiny UART selection may not route UART0 to PA13/PA14 on # WBR3, so this config loads the patched UART component in components/uart # (patches/wbr3-pa13-pa14-uart.patch applied to ESPHome 2026.10.2). ############################################################################### substitutions: device_name: wake-up-light-ii friendly_name: "Wake Up Light II" ap_ssid: "Wake-Up-Light-II" timezone: "Europe/Berlin" # Change to your IANA timezone. # Patched UART component. Keep ref equal to the tag this file is released under. external_components: - source: type: git url: https://mangavalk.com/mangavalk/esphome-wake-up-light-ii.git ref: v1.0.3 components: [uart] refresh: 1d # Controls for each alarm live in alarm_block.yaml. packages: alarm_1: !include { file: alarm_block.yaml, vars: { n: "1", o: "0", dp: "109" } } alarm_2: !include { file: alarm_block.yaml, vars: { n: "2", o: "12", dp: "122" } } alarm_3: !include { file: alarm_block.yaml, vars: { n: "3", o: "24", dp: "123" } } alarm_4: !include { file: alarm_block.yaml, vars: { n: "4", o: "36", dp: "124" } } esphome: name: ${device_name} friendly_name: ${friendly_name} rtl87xx: board: wbr3 # v1.14.0 fixes the restart hang after OTA (LibreTiny issue #396). Drop this # once ESPHome's default framework version is 1.14.0 or newer. framework: version: 1.14.0 source: https://github.com/libretiny-eu/libretiny.git#v1.14.0 # 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 48 bytes on this unit: 4 alarm blocks of 12 bytes. Never build it # from scratch: cache what the appliance MCU reported and only edit known bytes. - id: alarm_raw type: uint8_t[48] 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' # DP128: sunset settings, 10 bytes. Cached and edited like DP127. - id: sunset_raw type: uint8_t[10] restore_value: no - id: sunset_raw_valid type: bool restore_value: no initial_value: 'false' # DP107: stations found by a scan, and the current station. - id: radio_total type: uint8_t restore_value: no initial_value: '0' - id: radio_current type: uint8_t restore_value: no initial_value: '0' - id: radio_valid type: bool restore_value: no initial_value: 'false' # Knocks counted in the current pattern window. - id: knock_count type: int restore_value: no initial_value: '0' - id: bedtime_start_brightness type: float restore_value: no initial_value: '0' # When the MCU last sent its once-a-minute clock report. - id: last_mcu_report type: uint32_t restore_value: no initial_value: '0' # Last display state the schedule applied: -1 none, 0 day, 1 night. - id: display_schedule_last type: int restore_value: no initial_value: '-1' ############################################################################### # 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 - script.execute: apply_display_schedule # Home Assistant is preferred when connected, so the clock doesn't need internet. - platform: homeassistant id: ha_time on_time_sync: then: - delay: 2s - script.execute: sync_lamp_clock - script.execute: apply_display_schedule interval: - interval: 1h then: - script.execute: sync_lamp_clock - interval: 60s then: - script.execute: apply_display_schedule ############################################################################### # 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: |- id(last_mcu_report) = millis(); 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: [0] stations found by a scan (0 = none yet), [1] current station. - sensor_datapoint: 107 datapoint_type: raw then: - lambda: |- id(radio_station_raw).publish_state(format_hex_pretty(x)); if (x.size() != 2) return; id(radio_total) = x[0]; id(radio_current) = x[1]; id(radio_valid) = true; id(radio_stations_found).publish_state(x[0]); # 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; id(publish_alarm_summaries).execute(); - sensor_datapoint: 122 datapoint_type: bool then: - lambda: |- if (id(alarm_raw_valid)) id(alarm_raw)[12] = x ? 1 : 0; id(publish_alarm_summaries).execute(); - sensor_datapoint: 123 datapoint_type: bool then: - lambda: |- if (id(alarm_raw_valid)) id(alarm_raw)[24] = x ? 1 : 0; id(publish_alarm_summaries).execute(); - sensor_datapoint: 124 datapoint_type: bool then: - lambda: |- if (id(alarm_raw_valid)) id(alarm_raw)[36] = x ? 1 : 0; id(publish_alarm_summaries).execute(); # DP127: alarm configuration, 48 bytes on this unit: 4 blocks of 12 bytes, # Alarm 1 at offset 0, 2 at 12, 3 at 24, 4 at 36. Layout per Tuya's data # model for this family (tuya-local issue #3821): # [00] enabled # [01:02] time = minutes since midnight, big-endian # [03] repeat days: bit 0 = Monday ... bit 6 = Sunday, 0 = one time # [04] ring duration (Tuya says 10-20, this lamp reports 30; any value is kept) # [05] mode bits: bit 0=light, bit 1=radio. Values above 3 are ignored. # [06] sound 0..7, named in alarm_block.yaml. Writing 8 or more turns on the # radio bit and resets it to 1. # Locked at 1 in radio modes. # [07] volume 1..16 (any value is kept) # [08] radio station. Not per alarm: every write sets all 4 to the current # radio station, so don't try to set it here. # [09] sunrise lead time in minutes, 10..60 (any value is kept) # [10] brightness after sunrise, 1..20. Forced to 0 without the light bit, and # 0 clears the light bit. # [11] snooze: 0=allowed, 1=not allowed. Values above 1 become 1. # The MCU doesn't range-check most bytes, so the controls must. - sensor_datapoint: 127 datapoint_type: raw then: - lambda: |- id(alarm_settings_raw).publish_state(format_hex_pretty(x)); if (x.size() != 48) { ESP_LOGW("alarm", "Unexpected DP127 size: %u", (unsigned) x.size()); return; } for (size_t i = 0; i < 48; i++) id(alarm_raw)[i] = x[i]; id(alarm_raw_valid) = true; id(publish_alarm_summaries).execute(); # DP128: sunset settings, 10 bytes, per Tuya's data model: # [0] switch (unused) [1] mode: 0=sound, 1=sound+light, 2=radio, 3=radio+light # [2] sound 1..4 [3] sound volume 1..16 [4] sound duration 10..120 # [5] radio station 1..60 [6] radio duration 10..120 # [7] starting brightness 1..20 [8] sunset duration 10..120 # [9] snooze: 0=allowed, 1=not allowed # Bytes 4, 6 and 8 are kept equal, as Tuya's definition asks. - sensor_datapoint: 128 datapoint_type: raw then: - lambda: |- id(sunset_settings_raw).publish_state(format_hex_pretty(x)); if (x.size() != 10) { ESP_LOGW("sunset", "Unexpected DP128 size: %u", (unsigned) x.size()); return; } for (size_t i = 0; i < 10; i++) id(sunset_raw)[i] = x[i]; id(sunset_raw_valid) = true; ############################################################################### # Helper scripts ############################################################################### script: # A knock asks the lamp's MCU to snooze (DP130). Forced because the MCU may # leave DP130 on, and a normal write is skipped when the value looks unchanged. # Mode single plus the delay ignores repeat knocks for 3 seconds. - id: knock_snooze mode: single then: - lambda: |- ESP_LOGI("knock", "Sending snooze (DP130)"); id(tuya_mcu).force_set_boolean_datapoint_value(130, true); - delay: 3s # Restarted by every knock, so this runs once the knocking has stopped. # Exactly two knocks toggle the light; any other count does nothing here. - id: knock_pattern mode: restart then: - delay: 800ms - lambda: |- int count = id(knock_count); id(knock_count) = 0; ESP_LOGI("knock", "Knock pattern finished: %d", count); if (count == 2 && id(double_knock_light).state) { id(main_light).toggle().perform(); } # Writes the display mode only when the schedule crosses into day or night, # so changing Clock Display by hand holds until the next crossing. - id: apply_display_schedule mode: restart then: - lambda: |- if (!id(display_schedule_enabled).state) return; auto now = id(ha_time).now(); if (!now.is_valid()) now = id(network_time).now(); if (!now.is_valid()) return; int start = id(display_night_start).hour * 60 + id(display_night_start).minute; int end = id(display_night_end).hour * 60 + id(display_night_end).minute; int cur = now.hour * 60 + now.minute; bool night; if (start == end) night = false; else if (start < end) night = cur >= start && cur < end; else night = cur >= start || cur < end; int state = night ? 1 : 0; if (state == id(display_schedule_last)) return; id(display_schedule_last) = state; // DP104: 0=off, 1=day, 2=night, 3=auto. int value; if (night) value = id(display_night_level).active_index().value_or(1) == 0 ? 0 : 2; else value = id(display_day_level).active_index().value_or(0) == 0 ? 1 : 3; ESP_LOGI("display", "Schedule: %s, setting DP104 to %d", night ? "night" : "day", value); id(tuya_mcu).set_integer_datapoint_value(104, value); # 30 steps over the chosen length. Mode restart means pressing start again # begins a new fade from the current brightness. - id: bedtime_fade mode: restart then: - lambda: |- float b = 0.0f; id(main_light).remote_values.as_brightness(&b); id(bedtime_start_brightness) = b; - repeat: count: 30 then: - delay: !lambda 'return static_cast(id(bedtime_fade_minutes).state) * 2000;' - lambda: |- if (!id(main_light).remote_values.is_on()) return; float b = id(bedtime_start_brightness) * (29 - iteration) / 30.0f; auto call = id(main_light).make_call(); if (iteration >= 29 || b < 0.02f) call.set_state(false); else call.set_brightness(b); call.set_transition_length(0); call.perform(); - id: radio_step mode: queued parameters: step: int then: - lambda: |- if (!id(radio_valid) || id(radio_total) == 0) { ESP_LOGW("radio", "No stations found yet; run Radio Scan first"); return; } int total = id(radio_total); int next = ((id(radio_current) - 1 + step) % total + total) % total + 1; std::vector data = {id(radio_total), static_cast(next)}; id(tuya_mcu).set_raw_datapoint_value(107, data); - id: radio_sleep mode: restart then: - delay: !lambda 'return static_cast(id(radio_sleep_minutes).state) * 60000;' - switch.turn_off: fm_radio - id: publish_alarm_summaries mode: restart then: - lambda: |- if (!id(alarm_raw_valid)) return; text_sensor::TextSensor *summaries[4] = { id(alarm_1_summary), id(alarm_2_summary), id(alarm_3_summary), id(alarm_4_summary)}; static const char *const modes[4] = {"sound", "sound+light", "radio", "radio+light"}; for (int n = 0; n < 4; n++) { const uint8_t *b = id(alarm_raw) + n * 12; uint16_t mins = (static_cast(b[1]) << 8) | b[2]; char buf[120]; snprintf(buf, sizeof(buf), "%s %02u:%02u days=0x%02X %s sound=%u vol=%u station=%u sunrise=%u min light=%u/20 ring=%u min", b[0] ? "ON" : "OFF", mins / 60, mins % 60, b[3], b[5] < 4 ? modes[b[5]] : "?", b[6], b[7], b[8], b[9], b[10], b[4]); summaries[n]->publish_state(buf); } # The MCU echoes every write and that echo refreshes the cache, so wait for # quick changes to finish and send them together. - id: send_sunset_settings mode: restart then: - delay: 500ms - lambda: |- if (!id(sunset_raw_valid)) { ESP_LOGW("sunset", "DP128 has not been received yet; refusing to overwrite it"); return; } std::vector data(id(sunset_raw), id(sunset_raw) + 10); id(tuya_mcu).set_raw_datapoint_value(128, data); - id: send_alarm_settings mode: restart then: - delay: 500ms - 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) + 48); 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(ha_time).now(); if (!now.is_valid()) now = id(network_time).now(); if (!now.is_valid()) { ESP_LOGW("clock", "No valid time source; 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 # Keep at 0: a transition here writes to the MCU on every loop, about 50 # commands a second. Bedtime fade uses its own slow steps instead. default_transition_length: 0s web_server: sorting_group_id: group_light sorting_weight: 1 ############################################################################### # Clock and display schedule times ############################################################################### datetime: - platform: template id: lamp_clock name: "Lamp Clock" type: time update_interval: 2s web_server: sorting_group_id: group_clock sorting_weight: 1 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: display_night_start name: "Display Night Start" type: time optimistic: true restore_value: true initial_value: "22:30:00" web_server: sorting_group_id: group_clock sorting_weight: 11 on_value: - lambda: id(display_schedule_last) = -1; - script.execute: apply_display_schedule - platform: template id: display_night_end name: "Display Night End" type: time optimistic: true restore_value: true initial_value: "07:00:00" web_server: sorting_group_id: group_clock sorting_weight: 12 on_value: - lambda: id(display_schedule_last) = -1; - script.execute: apply_display_schedule ############################################################################### # Selects ############################################################################### select: - platform: template id: sunset_mode_select name: "Sunset Type" options: - "Sound" - "Sound + Light" - "Radio" - "Radio + Light" update_interval: 2s web_server: sorting_group_id: group_sunset sorting_weight: 2 lambda: |- if (!id(sunset_raw_valid)) return {}; switch (id(sunset_raw)[1]) { case 0: return std::string("Sound"); case 1: return std::string("Sound + Light"); case 2: return std::string("Radio"); case 3: return std::string("Radio + Light"); default: return {}; } set_action: - lambda: |- if (!id(sunset_raw_valid)) return; uint8_t value = 0; if (x == "Sound + Light") value = 1; else if (x == "Radio") value = 2; else if (x == "Radio + Light") value = 3; id(sunset_raw)[1] = value; // The MCU drops a light mode if brightness is 0, so give it a level. if ((value == 1 || value == 3) && id(sunset_raw)[7] == 0) id(sunset_raw)[7] = 15; - script.execute: send_sunset_settings - platform: template id: sunset_sound name: "Sunset Sound" options: - "Sound 1" - "Sound 2" - "Sound 3" - "Sound 4" update_interval: 2s web_server: sorting_group_id: group_sunset sorting_weight: 3 lambda: |- if (!id(sunset_raw_valid)) return {}; uint8_t s = id(sunset_raw)[2]; if (s >= 1 && s <= 4) return std::string("Sound ") + std::to_string(s); return {}; set_action: - lambda: |- if (!id(sunset_raw_valid)) return; uint8_t value = 1; if (x == "Sound 2") value = 2; else if (x == "Sound 3") value = 3; else if (x == "Sound 4") value = 4; id(sunset_raw)[2] = value; - script.execute: send_sunset_settings - platform: tuya id: clock_display_mode name: "Clock Display" int_datapoint: 104 options: 0: "Off" 1: "Day" 2: "Night" 3: "Auto" web_server: sorting_group_id: group_clock sorting_weight: 3 - platform: template id: display_night_level name: "Display At Night" options: - "Off" - "Night" initial_option: "Night" optimistic: true restore_value: true web_server: sorting_group_id: group_clock sorting_weight: 13 on_value: - lambda: id(display_schedule_last) = -1; - script.execute: apply_display_schedule - platform: template id: display_day_level name: "Display In Daytime" options: - "Day" - "Auto" initial_option: "Day" optimistic: true restore_value: true web_server: sorting_group_id: group_clock sorting_weight: 14 on_value: - lambda: id(display_schedule_last) = -1; - script.execute: apply_display_schedule # Labels for these two come from Tuya's data model, not from testing. # This MCU reports both DPs as enums, so values count from 0. - platform: tuya id: snooze_action name: "Snooze Action" enum_datapoint: 117 options: 0: "Sound + Radio" 1: "Light" 2: "Radio" 3: "Radio + Sound + Light" web_server: sorting_group_id: group_snooze sorting_weight: 3 - platform: tuya id: ambient_color name: "Ambient Color" enum_datapoint: 132 options: 0: "Cycle" 1: "Red" 2: "Orange" 3: "Yellow" 4: "Green" 5: "Cyan" 6: "Blue" 7: "Purple" web_server: sorting_group_id: group_light sorting_weight: 3 ############################################################################### # Numbers ############################################################################### number: # Only meaningful after a Radio Scan has found stations. - platform: template id: radio_station name: "Radio Station" min_value: 1 max_value: 60 step: 1 update_interval: 2s web_server: sorting_group_id: group_radio sorting_weight: 2.5 lambda: |- if (!id(radio_valid) || id(radio_current) == 0) return {}; return static_cast(id(radio_current)); set_action: - lambda: |- if (!id(radio_valid) || id(radio_total) == 0) { ESP_LOGW("radio", "No stations found yet; run Radio Scan first"); return; } uint8_t station = static_cast(x); if (station > id(radio_total)) station = id(radio_total); std::vector data = {id(radio_total), station}; id(tuya_mcu).set_raw_datapoint_value(107, data); - platform: template id: sunset_volume name: "Sunset Volume" min_value: 1 max_value: 16 step: 1 update_interval: 2s web_server: sorting_group_id: group_sunset sorting_weight: 4 lambda: |- if (!id(sunset_raw_valid)) return {}; return static_cast(id(sunset_raw)[3]); set_action: - lambda: |- if (!id(sunset_raw_valid)) return; id(sunset_raw)[3] = static_cast(x); - script.execute: send_sunset_settings - platform: template id: sunset_duration name: "Sunset Duration" unit_of_measurement: "min" min_value: 10 max_value: 120 step: 5 update_interval: 2s web_server: sorting_group_id: group_sunset sorting_weight: 5 lambda: |- if (!id(sunset_raw_valid)) return {}; return static_cast(id(sunset_raw)[8]); set_action: - lambda: |- if (!id(sunset_raw_valid)) return; uint8_t minutes = static_cast(x); id(sunset_raw)[4] = minutes; id(sunset_raw)[6] = minutes; id(sunset_raw)[8] = minutes; - script.execute: send_sunset_settings - platform: template id: sunset_brightness name: "Sunset Start Brightness" min_value: 1 max_value: 20 step: 1 update_interval: 2s web_server: sorting_group_id: group_sunset sorting_weight: 6 lambda: |- if (!id(sunset_raw_valid)) return {}; return static_cast(id(sunset_raw)[7]); set_action: - lambda: |- if (!id(sunset_raw_valid)) return; id(sunset_raw)[7] = static_cast(x); - script.execute: send_sunset_settings - platform: template id: sunset_radio_station name: "Sunset Radio Station" min_value: 1 max_value: 60 step: 1 update_interval: 2s web_server: sorting_group_id: group_sunset sorting_weight: 7 lambda: |- if (!id(sunset_raw_valid)) return {}; return static_cast(id(sunset_raw)[5]); set_action: - lambda: |- if (!id(sunset_raw_valid)) return; id(sunset_raw)[5] = static_cast(x); - script.execute: send_sunset_settings - platform: tuya id: radio_volume name: "Radio Volume" number_datapoint: 106 min_value: 1 max_value: 16 step: 1 web_server: sorting_group_id: group_radio sorting_weight: 2 - platform: tuya id: snooze_duration name: "Snooze Duration" number_datapoint: 116 unit_of_measurement: "min" min_value: 8 max_value: 15 step: 1 web_server: sorting_group_id: group_snooze sorting_weight: 2 - platform: template id: bedtime_fade_minutes name: "Bedtime Fade Length" unit_of_measurement: "min" min_value: 1 max_value: 120 step: 1 initial_value: 30 optimistic: true restore_value: true web_server: sorting_group_id: group_light sorting_weight: 5 - platform: template id: radio_sleep_minutes name: "Radio Sleep Length" unit_of_measurement: "min" min_value: 5 max_value: 180 step: 5 initial_value: 30 optimistic: true restore_value: true web_server: sorting_group_id: group_radio sorting_weight: 5 ############################################################################### # Switches ############################################################################### switch: - platform: tuya id: fm_radio name: "FM Radio" switch_datapoint: 105 web_server: sorting_group_id: group_radio sorting_weight: 1 - platform: tuya id: sunset_mode name: "Sunset / Sleep Mode" switch_datapoint: 121 web_server: sorting_group_id: group_sunset sorting_weight: 1 - platform: tuya id: ambient_light name: "Ambient Light" switch_datapoint: 129 web_server: sorting_group_id: group_light sorting_weight: 2 - platform: tuya id: clock_24_hour name: "24 Hour Clock" switch_datapoint: 131 web_server: sorting_group_id: group_clock sorting_weight: 4 # Tuya's data model calls this online time synchronization. - platform: tuya id: network_time_switch name: "Network Time" switch_datapoint: 125 web_server: sorting_group_id: group_clock sorting_weight: 5 - platform: template id: display_schedule_enabled name: "Display Night Schedule" optimistic: true restore_mode: RESTORE_DEFAULT_OFF web_server: sorting_group_id: group_clock sorting_weight: 10 on_turn_on: - lambda: id(display_schedule_last) = -1; - script.execute: apply_display_schedule - platform: template id: double_knock_light name: "Double Knock Toggles Light" optimistic: true restore_mode: RESTORE_DEFAULT_ON web_server: sorting_group_id: group_snooze sorting_weight: 5 ############################################################################### # Diagnostics / still-undecoded payloads ############################################################################### text_sensor: - platform: template id: clock_time_text name: "Clock Time" update_interval: never web_server: sorting_group_id: group_clock sorting_weight: 2 - platform: template id: radio_station_raw name: "Radio Station Raw" update_interval: never web_server: sorting_group_id: group_radio sorting_weight: 9 - platform: template id: alarm_settings_raw name: "Alarm Settings Raw" update_interval: never web_server: sorting_group_id: group_device sorting_weight: 10 - platform: template id: sunset_settings_raw name: "Sunset Settings Raw" update_interval: never web_server: sorting_group_id: group_device sorting_weight: 11 - platform: wifi_info ip_address: name: "IP Address" web_server: sorting_group_id: group_device sorting_weight: 1 - platform: debug reset_reason: name: "Last Restart Reason" web_server: sorting_group_id: group_device sorting_weight: 4 - platform: version name: "ESPHome Version" hide_timestamp: true web_server: sorting_group_id: group_device sorting_weight: 6 - platform: libretiny version: name: "LibreTiny Version" web_server: sorting_group_id: group_device sorting_weight: 7 ############################################################################### # Device health ############################################################################### debug: update_interval: 60s sensor: - platform: template id: radio_stations_found name: "Radio Stations Found" accuracy_decimals: 0 update_interval: never web_server: sorting_group_id: group_radio sorting_weight: 8 - platform: wifi_signal name: "WiFi Signal" update_interval: 60s web_server: sorting_group_id: group_device sorting_weight: 2 - platform: uptime name: "Uptime" update_interval: 60s web_server: sorting_group_id: group_device sorting_weight: 3 ############################################################################### # Knock sensor: switch between 3.3V and PA18, active high # One knock snoozes straight away. Exactly two knocks also toggle the light. ############################################################################### binary_sensor: # The MCU reports its clock every minute, so silence means the link is down. - platform: template name: "MCU Link" device_class: connectivity web_server: sorting_group_id: group_device sorting_weight: 0.5 lambda: |- return id(last_mcu_report) != 0 && millis() - id(last_mcu_report) < 150000; - platform: gpio name: "Knock" icon: "mdi:gesture-tap" pin: number: PA18 mode: input: true pulldown: true web_server: sorting_group_id: group_snooze sorting_weight: 4 filters: # Stretch short hits so Home Assistant sees them, and merge bounces into # one press. - delayed_off: 200ms on_press: - script.execute: knock_snooze - lambda: id(knock_count) += 1; - script.execute: knock_pattern ############################################################################### # Buttons ############################################################################### button: - platform: template name: "Snooze" icon: "mdi:alarm-snooze" web_server: sorting_group_id: group_snooze sorting_weight: 1 on_press: - script.execute: knock_snooze - platform: template name: "Start Bedtime Fade" icon: "mdi:weather-night" web_server: sorting_group_id: group_light sorting_weight: 6 on_press: - script.execute: bedtime_fade - platform: template name: "Cancel Bedtime Fade" icon: "mdi:cancel" web_server: sorting_group_id: group_light sorting_weight: 7 on_press: - script.stop: bedtime_fade - platform: template name: "Sync Lamp Clock Now" icon: "mdi:clock-sync" web_server: sorting_group_id: group_clock sorting_weight: 6 on_press: - script.execute: sync_lamp_clock # DP126: 1 starts a channel search, 0 ends it, per Tuya's data model. - platform: template name: "Radio Scan" icon: "mdi:radio-tower" web_server: sorting_group_id: group_radio sorting_weight: 3 on_press: - lambda: id(tuya_mcu).force_set_boolean_datapoint_value(126, true); - platform: template name: "Radio Scan Stop" icon: "mdi:stop" web_server: sorting_group_id: group_radio sorting_weight: 4 on_press: - lambda: id(tuya_mcu).force_set_boolean_datapoint_value(126, false); - platform: template name: "Next Station" icon: "mdi:skip-next" web_server: sorting_group_id: group_radio sorting_weight: 2.7 on_press: - script.execute: id: radio_step step: 1 - platform: template name: "Previous Station" icon: "mdi:skip-previous" web_server: sorting_group_id: group_radio sorting_weight: 2.6 on_press: - script.execute: id: radio_step step: -1 - platform: template name: "Start Radio Sleep Timer" icon: "mdi:timer-outline" web_server: sorting_group_id: group_radio sorting_weight: 6 on_press: - script.execute: radio_sleep - platform: template name: "Cancel Radio Sleep Timer" icon: "mdi:timer-off-outline" web_server: sorting_group_id: group_radio sorting_weight: 7 on_press: - script.stop: radio_sleep - platform: restart name: "Restart Wake Up Light" web_server: sorting_group_id: group_device sorting_weight: 5 ############################################################################### # 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: !secret ap_password api: encryption: key: !secret api_encryption_key web_server: port: 80 version: 3 local: true sorting_groups: - id: group_light name: "Light" sorting_weight: 10 - id: group_clock name: "Clock and Display" sorting_weight: 20 - id: group_sunset name: "Sunset" sorting_weight: 25 - id: group_snooze name: "Snooze and Knock" sorting_weight: 40 - id: group_radio name: "Radio" sorting_weight: 50 - id: group_device name: "Device" sorting_weight: 90 # Recommended on a shared LAN: # auth: # username: admin # password: !secret web_password ota: - platform: esphome - platform: web_server