Moonrays ESPHome Conversion

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);
            }