I got a Moonrays 120W Low Voltage Smart Landscape Lighting Transformer the other day and was assuming the worst. I didn't want to use the included smart features and I figured overall it would be a piece of junk. Not so!
It’s actually well-designed and constructed. It has a few simple timer and light-sensor modes that physically de-power the WiFi module while engaged. It retains it's memory though power cycles so it says on whichever mode you left it. It has in internal current sensor to detect load, and a low-power standby supply which keeps the control circuit running without the need of powering up the inefficient main transformer. The control card is separate from the power supply PCB, making it easy to disassemble, and the whole electronics unit is actually detachable from the main body. Exterior screws are stainless. Props to these guys for not skimping on anything.
The smart features are implemented on a WR3L wireless module, while a small MCU manages the low-level control via Tuya serial protocol. ESPHome has support for the WR3L module, so this should be easy. A little UART sniffing and I was able to identify some key Data Point mappings:
DP 0x14 / 20 switch [Relay State Feedback]
DP 0x15 / 21 enum [load state Feedback 0-4]
DP 0x6C / 108 enum [Mode Control. Countdown = 0, Auto = 1, Timer = 2, Astro = 3, Off = 4]
DP 0x7A / 122 bool [Relay State Control]
There are a bunch of other DPs as well but these ones will meet my needs. With that, to the YAML. Oddly, at the time of writing, this code only seems to work on ESPHome 2026.4.2 and LibreTiny 1.10.0. The newer versions seem to have bugs related to the RTL8710, perhaps just because it's a newer part that isn't well tested yet. There's also a bug where wifi sometimes fails and you need to wait 90 seconds for it to reboot, which thankfully isn't a big problem in this application.
To flash this thing the first time you'll need to open it up and remove the control board. Apply 3.3V power to the 4th pin on the connector, ground and the two log_UART pins can be tapped into on the right side of the WR3L. You'll need to pull log_TX low on boot to put it into flash mode, after which ltchiptool will handle the rest for you. Before you flash, dump the stock firmware as a backup. Once flashed, it's all yours. Local control for the win!
This YAML will basically expose 3 modes: Manual, Auto (light sensor), and Off. In manual mode Home Assistant can directly control the relay. Power and relay status feedback are provided, but unfortunately I don't think the light sensor is exposed.
substitutions:
device_name: tuya-moonray
friendly_name: "Moonray"
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
rtl87xx:
board: wr3l # Moonrays model 29287
framework:
version: 1.10.0 # LibreTiny version forced here. Compile with ESPHome 2026.4.2
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
fast_connect: true
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Moonrays"
password: !secret ap_password
captive_portal:
api:
encryption:
key: !secret api_key
ota:
platform: esphome
logger:
baud_rate: 0
# WR3L / RTL8710BX:
# UART2 PA30/PA29 is usually flashing/debug.
# The Tuya MCU link is commonly UART0 on Tx/Rx PA23/PA18
uart:
id: tuya_uart
rx_pin: PA18 # UART0 RX
tx_pin: PA23 # UART0 TX
baud_rate: 9600
rx_buffer_size: 125
debug:
direction: BOTH
dummy_receiver: false
after:
bytes: 8
sequence:
- lambda: |-
std::string s;
for (uint8_t b : bytes) {
char buf[4];
snprintf(buf, sizeof(buf), "%02X ", b);
s += buf;
}
ESP_LOGD("uart_debug", "%s: %s",
direction == UART_DIRECTION_RX ? "RX" : "TX",
s.c_str());
switch:
- platform: tuya
name: "Relay"
id: relay
switch_datapoint: 122
select:
- platform: tuya
name: "Mode"
enum_datapoint: 108
optimistic: true
options:
0: "Manual" # Countdown
1: "Auto"
#2: "Timer"
#3: "Astro"
4: "Off"
binary_sensor:
- platform: tuya
name: "Relay Status"
sensor_datapoint: 20
text_sensor:
- platform: template
name: "Load Feedback"
id: load_feedback
sensor:
- platform: template
name: "Load Feedback Percent"
id: load_feedback_percent
unit_of_measurement: "%"
accuracy_decimals: 0
tuya:
on_datapoint_update:
- sensor_datapoint: 21
datapoint_type: enum
then:
- lambda: |-
static const char *labels[] = {"<20%", "~40%", "~60%", "~80%", "100%"};
static const int values[] = {20, 40, 60, 80, 100};
if (x <= 4) {
id(load_feedback).publish_state(labels[x]);
id(load_feedback_percent).publish_state(values[x]);
} else {
id(load_feedback).publish_state("Unknown");
id(load_feedback_percent).publish_state(NAN);
}
