zerowidth positive lookahead

Reverse Engineering my Christmas Tree

RF remote control of a Balsam Hill Christmas tree with ESPHome

A few years ago we purchased a Balsam Hill pre-lit Christmas tree. The tree came with a remote control to change the lights between color, clear, both, turn them off, and adjust their brightness. When setting up the tree for the season, I was curious: could I control the tree from Home Assistant? A smart plug would work for on/off, but I wanted to be able to do everything the remote can do by talking to my smart watch.

The first step was to figure out how the remote control works. It’s not an IR device, so it had to be RF. The remote has an FCC ID on the back, TK, and the testing report is available online. Based on the report we can find out more about the device. Understandably the block diagrams are not published, but the important detail is there: it’s a 433MHz transmitter using ASK (Amplitude Shift Keying) modulation.

To decode and clone the remote, I purchased a couple m5stack AtomS3 Lite ESP32S3 devices. I also bought two RF modules: a SYN513R receiver and a SYN115 transmitter transmitter. These both have the same Grove connector as the Atom S3 Lite, for an easy connection.

My goal was to use the receiver to capture the signals from the remote, and use the transmitter to reproduce them. I used the ESPHome platform, since it integrates easily with home assistant, runs an embedded web server, supports over-the-air updates, and uses a simple YAML configuration format.

Initial capture attempt

To try and capture the protocol, I used a simple config:

esphome:
  name: reader
esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf
  variant: esp32s3
remote_receiver:
  pin:
    number: 1
    mode:
      input: true
    inverted: true
  use_dma: true
  dump:
    - raw

This captured plenty of noise and some signals, but unfortunately not the right ones. Along with library deprecation warnings and buffer overflows, which were addressed by esphome/esphome#7770, I simply could not seem to get a reliable repeatable capture. I tried many different capture configs to adjust timings and filter sensitivity to no effect, and even tried direct platformio and esp-idf code in case the esphome libraries themselves were missing or misinterpreting the captured data.

It only occurred to me later that I should try to “read my own writes”. I added a simple raw transmission using the transmitter module to see if I could capture a known pattern:

remote_transmitter:
  id: tx
  pin:
    number: 2
    mode:
      output: true
  carrier_duty_percent: 100%
  clock_resolution: 1000000
button:
  - platform: template
    name: "test_signal"
    id: test_signal
    on_press:
      then:
        - remote_transmitter.transmit_raw:
            code: [
              # sync
              10000, -5000,
              1000, -1000,
              2000, -1000,
              1000, -1000,
              2000, -1000,
              1000, -1000,
              2000, -1000,
              1000, -1000,
              2000, -1000,
            ]
            repeat:
              times: 10
              wait_time: 0s

Logic analyzer and SDR

Even with this known signal, I was capturing something but nothing reliably. Since I wasn’t sure that the problem wasn’t the software, I switched to looking at the hardware. I connected a logic analyzer to the RX module’s output pin, and saw a clear signal from the remote:

Logic analyzer trace showing the remote signal

Using measurements from the captured signal, I was able to set up a test broadcast. Unfortunately, I missed one of the pulses in my attempt to reproduce the signal and it didn’t work. Not noticing my mistake I used SDR++ and a newly acquired RTL-SDR BLOG V3 software-defined radio to capture the 433MHz broadcasts directly. Viewing them in Audacity, I saw that the OOK signal matched what I saw with the logic analyzer, with one extra detail: it appears that the remote broadcast was further modulated by a 375HZ or so wave.

Audacity waveform showing the remote signal with low frequency modulation

Subsequent captures showed that the low-frequency modulation phase wasn’t aligned with with the ASK signal, so it didn’t appear to be a requirement to duplicate it. I was later able to send an unmodulated signal to control the tree, so I’ve disregarded it as a curiosity for now.

Using the measurements from the SDR in audacity and double-checking against the logic analyzer trace, I was able to get the Christmas tree to change color, correcting the missed bit in the process:

buttons:
  - platform: template
    name: "Christmas Tree Color and Clear"
    id: color_clear
    on_press:
      then:
        - remote_transmitter.transmit_raw:
            code: [
              # Initial sync pulse
              10200, -5200,

              # Preamble: 8 long, 4 short, 4 long, 4 short
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100,
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100,
              1000, -1100, 1000, -1100, 1000, -1100, 1000, -1100,
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100,
              1000, -1100, 1000, -1100, 1000, -1100, 1000, -1100,

              # 1 short
              1000, -1100,

              # 1 long
              1100, -3100,

              # 1 short
              1000, -1100,

              # 6 long
              1100, -3100,
              1100, -3100,
              1100, -3100,
              1100, -3100,
              1100, -3100,
              1100, -3100,

              # 1 short
              1000, -1100,

              # 1 long
              1100, -3100,

              # 1 short
              1000, -1100,

              # 1 long?, plus gap
              1100, -12000
            ]
            repeat:
              times: 6
              wait_time: 0s

Broadcasting this signal worked!

rtl_433

Now that I had an SDR, I learned about some tools to use with it. For 433MHz in particular, rtl_433 can capture and decode a huge variety of radio protocols. Capturing a remote button press with rtl_433 -A:

rtl_433 version 24.10 (2024-10-30) inputs file rtl_tcp RTL-SDR with TLS
Detected OOK package	@0.325484s
Analyzing pulses...
Total count:  203,  width: 821.97 ms		(205492 S)
Pulse width distribution:
 [ 0] count:    6,  width: 10496 us [10116;10592]	(2624 S)
 [ 1] count:  197,  width: 1120 us [1068;1152]	( 280 S)
Gap width distribution:
 [ 0] count:    6,  width: 5272 us [5260;5292]	(1318 S)
 [ 1] count:  120,  width: 3104 us [3076;3144]	( 776 S)
 [ 2] count:   71,  width:  992 us [964;1020]	( 248 S)
 [ 3] count:    5,  width: 12600 us [12588;12656]	(3150 S)
Pulse period distribution:
 [ 0] count:   11,  width: 14824 us [13660;15880]	(3706 S)
 [ 1] count:  120,  width: 4228 us [4180;4276]	(1057 S)
 [ 2] count:   71,  width: 2116 us [2088;2160]	( 529 S)
Pulse timing distribution:
 [ 0] count:   11,  width: 11452 us [10116;12656]	(2863 S)
 [ 1] count:  268,  width: 1084 us [964;1152]	( 271 S)
 [ 2] count:    6,  width: 5272 us [5260;5292]	(1318 S)
 [ 3] count:  120,  width: 3104 us [3076;3144]	( 776 S)
 [ 4] count:    1,  width: 100004 us [100004;100004]	(25001 S)

This also links to a PWM trace visualization on the rtl_433 site, which is a convenient way to view the waveform.

PWM visualization of the signal

However, it’s incorrectly deciding it’s a PWM signal rather than PPM. I explicitly requested PPM analysis with rtl_433 -A -R pulse_slicer_ppm, which links to a PPM interpretation of the waveform.

PPM visualization of the signal

The decode still isn’t quite right, missing a bit and a final pulse at the end. Still, using both visualizations and the timing information, I was able to define a custom decoder to get more accurate decodes with rtl_433 -X "n=tree,m=OOK_PPM,short=1100,long=3100,reset=11450,gap=5270". Unlike the PPM visualization, this interprets long gaps as 1 and short as 0:

time      : @0.323212s
model     : tree         count     : 2             num_rows  : 2             rows      :
len       : 0            data      : ,
len       : 32           data      : ff0f05fa
codes     : {0}0, {32}ff0f05fa

That provided the following for the remote’s transmissions:

Button Hex Binary
Color ff0f 03fc 1111 1111 0000 1111 0000 0011 1111 1100
Clear ff0f 04fb 1111 1111 0000 1111 0000 0100 1111 1011
Color + Clear ff0f 05fa 1111 1111 0000 1111 0000 0101 1111 1010
Brighter ff0f 01fe 1111 1111 0000 1111 0000 0001 1111 1110
Dimmer ff0f 00ff 1111 1111 0000 1111 0000 0000 1111 1111
Off ff0f 02fd 1111 1111 0000 1111 0000 0010 1111 1101

There’s a clear pattern here, with an ff0f0 preamble, a control byte, f, and another control byte. The values decoded here also elide a final pulse followed by a long gap – this could be interpreted as either 0 or 1, but it doesn’t appear to matter and the gap is always the same.

ESPHome config

From the above decoded values, using nominal 1100us / 3100us / 11450us / 5275us timings, here’s an ESPHome config for reproducing the functionality of the remote.

This is using the latest esphome as of the time of writing, which has yet to incorporate the changes in esphome/esphome#7770. The RMT module will show "The legacy RMT driver is deprecated, please use driver/rmt_tx.h and/or driver/rmt_rx.h" warnings until it’s updated.

esphome:
  name: christmas-tree

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf
  variant: esp32s3

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

remote_transmitter:
  id: tx
  pin:
    number: 2
    mode:
      output: true
  carrier_duty_percent: 100% # No carrier frequency for ASK
  rmt_channel: 1

button:
  - platform: template
    name: "Christmas Tree Color and Clear"
    id: color_clear
    on_press:
      then:
        - remote_transmitter.transmit_raw:
            code: [
              11450, -5275, # sync
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -1100, 1100, -3100, 1100, -1100, 1100, -3100, # 0101
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -1100, 1100, -3100, 1100, -1100, # 1010
              1100, -11450 # final pulse plus gap
            ]
            repeat:
              times: 6
              wait_time: 0s
  - platform: template
    name: "Christmas Tree Clear"
    id: clear
    on_press:
      then:
        - remote_transmitter.transmit_raw:
            code: [
              11450, -5275, # sync
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -1100, 1100, -3100, 1100, -1100, 1100, -1100, # 0100
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -1100, 1100, -3100, 1100, -3100, # 1011
              1100, -11450 # final pulse plus gap
            ]
            repeat:
              times: 6
              wait_time: 0s
  - platform: template
    name: "Christmas Tree Color"
    id: color
    on_press:
      then:
        - remote_transmitter.transmit_raw:
            code: [
              11450, -5275, # sync
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -1100, 1100, -1100, 1100, -3100, 1100, -3100, # 0011
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -3100, 1100, -1100, 1100, -1100, # 1100
              1100, -11450 # final pulse plus gap
            ]
            repeat:
              times: 6
              wait_time: 0s
  - platform: template
    name: "Christmas Tree Off"
    id: "off"
    on_press:
      then:
        - remote_transmitter.transmit_raw:
            code: [
              11450, -5275, # sync
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -1100, 1100, -1100, 1100, -3100, 1100, -1100, # 0010
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -3100, 1100, -1100, 1100, -3100, # 1101
              1100, -11450 # final pulse plus gap
            ]
            repeat:
              times: 6
              wait_time: 0s
  - platform: template
    name: "Christmas Tree Brighter"
    id: brighter
    on_press:
      then:
        - remote_transmitter.transmit_raw:
            code: [
              11450, -5275, # sync
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -3100, # 0001
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -1100, # 1110
              1100, -11450 # final pulse plus gap
            ]
            repeat:
              times: 6
              wait_time: 0s
  - platform: template
    name: "Christmas Tree Dimmer"
    id: dimmer
    on_press:
      then:
        - remote_transmitter.transmit_raw:
            code: [
              11450, -5275, # sync
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -1100, 1100, -1100, 1100, -1100, 1100, -1100, # 0000
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -3100, 1100, -3100, 1100, -3100, 1100, -3100, # 1111
              1100, -11450 # final pulse plus gap
            ]
            repeat:
              times: 6
              wait_time: 0s

In a case of simultaneous invention, I learned that Dave Corder had also captured the RF remote with rtl_433 and set up ESPHome for his Christmas tree this December. Looks like we covered much the same ground, and his resulting ESPHome config is substantially equivalent to mine.

Resources

Specific details of the Balsam Hill tree remote:

  • I couldn’t identify the microcontroller, but the transmitter chip is a Synoxo F-113
  • The antenna pin is connected in parallel with a SI2305 MOSFET. I’m guessing this is where the low-frequency signal modulation comes from, but haven’t measured it.

Reading my own writes

As mentioned above, I couldn’t figure out how to reliably receive even a simple test broadcast with the RX module. I spent a lot of time trying to solve this with code before moving to hardware as the potential culprit. I haven’t yet been able to solve the issue, but I have some ideas. I’ll update here or make a new post if I figure it out.