Decode DP127 as 48 bytes (4 alarms), plus DP128, DP107 and DP126, from Tuya's data model. Add day switches, alarm and sunset controls, a knock sensor on PA18, display schedule, bedtime fade, radio timer and grouped web UI. Pin LibreTiny v1.14.0 for the OTA restart fix. Ship the patched UART as an external component so a server needs only a short YAML. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1133 lines
33 KiB
YAML
1133 lines
33 KiB
YAML
###############################################################################
|
|
# 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.0
|
|
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'
|
|
|
|
# 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
|
|
|
|
on_time:
|
|
- seconds: 0
|
|
minutes: 0
|
|
then:
|
|
- script.execute: sync_lamp_clock
|
|
- seconds: 0
|
|
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: |-
|
|
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)
|
|
# [05] mode: 0=sound, 1=sound+light, 2=radio, 3=radio+light
|
|
# [06] sound (this project also observed 8=FM)
|
|
# [07] sound volume 1..16
|
|
# [08] radio station 1..60
|
|
# [09] sunrise lead time in minutes, 10..60
|
|
# [10] brightness after sunrise, 1..20
|
|
# [11] snooze: 0=allowed, 1=not allowed
|
|
- 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(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<uint32_t>(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_sleep
|
|
mode: restart
|
|
then:
|
|
- delay: !lambda 'return static_cast<uint32_t>(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<uint16_t>(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<uint8_t> 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<uint8_t> 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(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
|
|
# 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<float>(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<uint8_t>(x);
|
|
if (station > id(radio_total)) station = id(radio_total);
|
|
std::vector<uint8_t> 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<float>(id(sunset_raw)[3]);
|
|
set_action:
|
|
- lambda: |-
|
|
if (!id(sunset_raw_valid)) return;
|
|
id(sunset_raw)[3] = static_cast<uint8_t>(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<float>(id(sunset_raw)[8]);
|
|
set_action:
|
|
- lambda: |-
|
|
if (!id(sunset_raw_valid)) return;
|
|
uint8_t minutes = static_cast<uint8_t>(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<float>(id(sunset_raw)[7]);
|
|
set_action:
|
|
- lambda: |-
|
|
if (!id(sunset_raw_valid)) return;
|
|
id(sunset_raw)[7] = static_cast<uint8_t>(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<float>(id(sunset_raw)[5]);
|
|
set_action:
|
|
- lambda: |-
|
|
if (!id(sunset_raw_valid)) return;
|
|
id(sunset_raw)[5] = static_cast<uint8_t>(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
|
|
|
|
###############################################################################
|
|
# 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:
|
|
- 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: "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
|