[GUIDE] Fully Working ESPHome Config for KingArt PS-16-DZ WiFi Dimmer (ESP8266 + Nuvoton MCU)

Hey everyone :wave:

After seeing a lot of partial or outdated configs for the KingArt PS-16-DZ dimmer, I finally got mine running reliably in Home Assistant via ESPHome β€” full dimming and two-way sync with the wall touch button.

This version:

:white_check_mark: Works on the stock ESP8285 / ESP-01M hardware inside the dimmer
:white_check_mark: Communicates with the onboard Nuvoton N76E003 MCU via UART (AT+UPDATE / AT+STATUS + <ESC> terminator β€” no CR/LF)
:white_check_mark: Fully syncs on/off and brightness between wall control ↔ Home Assistant
:white_check_mark: Fixes the β€œ:warning: Script is already running (mode: single)” spam by using mode: restart
:white_check_mark: Includes clear, beginner-friendly commentary so you can understand what each line does

You can drop this YAML straight into ESPHome (replace your Wi-Fi & API secrets).


:jigsaw: Full Working YAML

# KingArt PS-16-DZ Wi-Fi Dimmer
# Works with ESPHome 2025.9+
# Maintainer: Andrew (community share)
# Full commentary included for learning

esphome:
  name: lounge-lights
  friendly_name: Lounge Lights

  # Run at startup to sync with MCU
  on_boot:
    priority: -10
    then:
      - delay: 800ms
      - lambda: |-
          id(uart_bus).write_str("AT+STATUS"); id(uart_bus).write_byte(0x1B);
          id(uart_bus).write_str("AT+QUERY");  id(uart_bus).write_byte(0x1B);
          ESP_LOGD("ps16dz", "Boot sync: AT+STATUS / AT+QUERY <ESC>");

esp8266:
  board: esp01_1m
  restore_from_flash: true

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  fast_connect: true
  manual_ip:
    static_ip: XXX.XXX.XXX.XXX      # Insert the static IP of the device
    gateway: XXX.XXX.XXX.XXX      # Insert the gateway IP address
    subnet: 255.255.255.0
  ap:
    ssid: "LoungeLights-Setup"
    password: !secret ap_password

logger:
  baud_rate: 0
  level: INFO

uart:
  id: uart_bus
  tx_pin: GPIO1    # ESP TX β†’ MCU RX
  rx_pin: GPIO3    # ESP RX ← MCU TX
  baud_rate: 19200
  rx_buffer_size: 256

# --- Globals ----------------------------------------------------
globals:
  - id: sequence_counter
    type: uint32_t
    restore_value: yes
    initial_value: '1'

  - id: last_brightness_pct
    type: int
    restore_value: yes
    initial_value: '100'

  - id: last_rx_ms
    type: uint32_t
    restore_value: no
    initial_value: '0'

# --- UART RX parser / keep-alive --------------------------------
interval:
  - interval: 100ms
    then:
      - lambda: |-
          static std::string buf;
          static uint32_t last_byte_ms = 0;
          const size_t MAX_FRAME = 256;

          while (id(uart_bus).available()) {
            uint8_t c;
            if (!id(uart_bus).read_byte(&c)) break;
            last_byte_ms = millis();
            id(last_rx_ms) = last_byte_ms;

            if (c == 0xFF) continue;
            buf.push_back((char)c);
            if (buf.size() > MAX_FRAME) { buf.clear(); continue; }

            if (c == 0x1B) {
              if (buf.find("RESULT") != std::string::npos ||
                  buf.find("UPDATE") != std::string::npos) {
                id(uart_bus).write_str("AT+SEND=ok");
                id(uart_bus).write_byte(0x1B);
              }

              bool is_on = false;
              if (buf.find("\"switch\":\"on\"")  != std::string::npos) is_on = true;
              if (buf.find("\"switch\":\"off\"") != std::string::npos) is_on = false;

              size_t p = buf.find("\"bright\":");
              if (p != std::string::npos) {
                int v = 0;
                for (size_t i = p + 9; i < buf.size(); i++) {
                  if (isdigit(buf[i])) v = v * 10 + (buf[i]-'0'); else break;
                }
                v = std::max(10, std::min(v, 100));
                id(last_brightness_pct) = v;
              }

              if (buf.find("UPDATE") != std::string::npos) {
                if (!is_on) id(lounge_light).turn_off().perform();
                else {
                  auto call = id(lounge_light).turn_on();
                  call.set_brightness((float) id(last_brightness_pct) / 100.0f);
                  call.perform();
                }
              }

              ESP_LOGD("ps16dz", "RX: %s", buf.c_str());
              buf.clear();
            }
          }
          if (!buf.empty() && (millis() - last_byte_ms) > 500) buf.clear();

  - interval: 10s
    then:
      - lambda: |-
          if (id(last_rx_ms) == 0 || (millis() - id(last_rx_ms)) > 15000) {
            id(uart_bus).write_str("AT+STATUS"); id(uart_bus).write_byte(0x1B);
            id(uart_bus).write_str("AT+QUERY");  id(uart_bus).write_byte(0x1B);
            ESP_LOGD("ps16dz", "Probe: AT+STATUS / AT+QUERY <ESC>");
          }

# --- Scripts -----------------------------------------------------
script:
  - id: send_switch
    parameters: { p_on: bool }
    then:
      - lambda: |-
          const std::string seqs = std::to_string(++id(sequence_counter));
          const std::string sw = p_on ? "on" : "off";
          const std::string line = "AT+UPDATE=\"sequence\":\"" + seqs +
                                   "\",\"switch\":\"" + sw + "\"";
          id(uart_bus).write_str(line.c_str());
          id(uart_bus).write_byte(0x1B);

  - id: send_bright
    parameters: { p_level_0_100: int }
    then:
      - lambda: |-
          int b = std::clamp(p_level_0_100, 10, 100);
          id(last_brightness_pct) = b;
          const std::string seqs = std::to_string(++id(sequence_counter));
          const std::string line = "AT+UPDATE=\"sequence\":\"" + seqs +
                                   "\",\"bright\":" + std::to_string(b);
          id(uart_bus).write_str(line.c_str());
          id(uart_bus).write_byte(0x1B);

  - id: send_on_with_bright
    mode: restart     # fixes β€œalready running” warning
    parameters: { p_level_0_100: int }
    then:
      - lambda: |-
          id(send_switch).execute(true);
      - delay: 20ms
      - lambda: |-
          id(send_bright).execute(std::clamp(p_level_0_100, 10, 100));

# --- Light entity -----------------------------------------------
output:
  - platform: template
    id: ps16_uart_output
    type: float
    write_action:
      - lambda: |-
          int pct = (int) roundf(state * 100.0f);
          if (pct <= 0) id(send_switch).execute(false);
          else id(send_on_with_bright).execute(std::max(pct, 10));

light:
  - platform: monochromatic
    id: lounge_light
    name: "Lounge Light"
    output: ps16_uart_output
    gamma_correct: 1.0
    default_transition_length: 250ms
    restore_mode: RESTORE_DEFAULT_ON

api:
  encryption:
    key: "XXXXXXXXX" # device API key

ota:
  platform: esphome
  password: !secret ota_password

sensor:
  - platform: uptime
    name: "Lounge Uptime"
  - platform: wifi_signal
    name: "Lounge WiFi Signal"

button:
  - platform: restart
    name: "Restart Lounge Lights"

captive_portal:

:hammer_and_wrench: Hardware Notes

  • TX ↔ RX pins might be reversed depending on revision.
    If you see TX traffic but no replies when touching the wall switch, swap them (tx_pin: GPIO3 / rx_pin: GPIO1).
  • Baud rate = 19200 bps.
  • Brightness range = 10–100 % (device ignores < 10).

:mag: Known Quirks

  • Occasional β€œSafe mode took a long time…” warnings after boot are harmless.
  • The MCU can miss very rapid slider moves β€” this YAML adds a small debounce to help.

:heart: Why I Shared This

I had to piece this together from the Blakadder template, UART captures, and a lot of trial and error.
Hopefully this saves someone else a few hours (and nerves).

Happy flashing!
β€” Andrew