RMT API

The ESP32 RMT peripheral, short for Remote Control, is a timing-oriented signal engine that can both transmit and receive precisely timed digital pulse sequences. It was originally introduced for infrared remote-control protocols, but it is far more general than the name suggests.

In practice, the RMT peripheral is a very useful tool whenever a protocol is defined in terms of pulse widths rather than bytes on a conventional bus. This makes it a strong fit for:

  • infrared protocols,

  • 1-wire devices,

  • addressable LED strips such as WS2812B,

  • custom pulse-encoded signaling,

  • pulse measurement and decoding tasks, and

  • other applications where sub-microsecond timing matters.

Because the peripheral handles timing in hardware, it is often a better choice than trying to generate or measure pulse trains directly from Lua.

Application Examples

Two especially common uses for RMT in Xedge32 projects are:

  • 1-wire communication Timing is critical in 1-wire protocols, and RMT provides the precision needed for reliable reads and writes.

  • Addressable LED strips LED strips such as WS2812B require very specific pulse timing. RMT lets you generate those waveforms in hardware instead of bit-banging them from Lua.

RMT Symbol Layout

The RMT hardware represents a waveform as a sequence of RMT symbols. Each symbol contains timing and logic-level information describing two consecutive pulse segments.

RMT-symbol layout

Bit fields in an RMT symbol as defined by the hardware.

Each RMT symbol contains:

  • a duration value for the first segment,

  • a logic level for the first segment,

  • a duration value for the second segment, and

  • a logic level for the second segment.

Duration values are expressed in RMT clock ticks, not in seconds directly. The tick duration depends on the configured channel resolution.

Understanding Tick Resolution

The configured resolution determines how to interpret durations:

  • at 1,000,000 Hz, one tick is 1 us,

  • at 10,000,000 Hz, one tick is 0.1 us,

  • at 80,000,000 Hz, one tick is 12.5 ns.

This is one of the most important ideas when working with RMT. The symbol data does not store human-friendly time units. It stores counts that are meaningful only when combined with the selected resolution.

Lua RMT Symbol Representation

In Lua, a single RMT symbol is represented as a four-element table:

{ level0, duration0, level1, duration1 }

Example:

{
   1, 350, 0, 800
}

If the channel resolution is 1 MHz, the symbol above means:

  • drive the line high for 350 us,

  • then drive it low for 800 us.

This table format is compact, but it is helpful to think of it as a small waveform fragment rather than as generic numeric data.

Lua RMT Byte Encoding

Many pulse protocols define how each data bit is encoded as a pair of pulses. For example:

  • a binary 0 might be sent as a short-high then long-low pulse pair,

  • a binary 1 might be sent as a long-high then short-low pulse pair.

Xedge32 supports a convenient Lua structure for describing this pattern at the byte level:

{
   booleanMsbFirst,
   symbolForBit0,
   symbolForBit1,
   { byteArray }
}

Fields:

  • booleanMsbFirst: true to transmit the most-significant bit first, false for least-significant-bit first.

  • symbolForBit0: RMT symbol that represents a transmitted zero bit.

  • symbolForBit1: RMT symbol that represents a transmitted one bit.

  • { byteArray }: Array of bytes to encode and transmit.

Example at 10 MHz resolution:

{
   true,
   {1, 4, 0, 8},
   {1, 8, 0, 4},
   {0x00, 0x55, 0xFF}
}

With a resolution of 10,000,000 Hz, the values 4 and 8 correspond to 0.4 us and 0.8 us respectively.

RMT TX API

Creating a TX Channel

Function signature:

rmttx, err = esp32.rmttx(cfg [, rx])

This function creates a transmit channel object. The channel starts out disabled, so you must call rmttx:enable() before transmitting.

The optional rx argument lets you pair a TX and RX channel on the same GPIO so they can cooperate on a bidirectional bus such as 1-wire.

TX Configuration Options

Required fields:

  • gpio: Output GPIO pin.

  • resolution: Tick resolution in Hertz.

Optional fields:

  • mem: Memory or DMA buffer sizing. Default is 64.

  • queue: Transaction queue depth. Default is 4.

  • invert: Invert the outgoing logic level. Default is false.

  • DMA: Enable DMA backend. Default is false.

  • opendrain: Configure the GPIO in open-drain mode. Default is false.

  • callback: Function called when transmission completes.

Optional carrier modulation fields:

  • dutycycle: Carrier duty cycle.

  • frequency: Carrier frequency in Hertz. Maximum is 80000000.

  • polaritylow: Select the logic level on which the carrier is applied.

TX Object Methods

rmttx:enable()

Enables the RMT TX channel and prepares it for transmission.

rmttx:disable()

Disables the TX channel and stops further activity. This is especially useful for transmissions that were started in loop mode.

rmttx:transmit(cfg, symbols)

Queues or starts transmission of one or more RMT symbols.

Parameters:

  • cfg: Transmission options.

  • symbols: Either an array of direct RMT symbols or one or more byte-encode structures as described earlier.

Supported cfg fields:

  • loop: Number of repeat loops. Use -1 for infinite looping.

  • eot: Output level to hold at end of transmission.

rmttx:close()

Closes the TX channel and releases its resources.

TX Example 1: Musical Score

The following example plays a simple melody, Beethoven’s Ode to Joy, by transmitting a square wave for each note frequency. The score table is adapted from Espressif’s musical buzzer example.

local score = {
   {740, 400}, {740, 600}, {784, 400}, {880, 400},
   {880, 400}, {784, 400}, {740, 400}, {659, 400},
   {587, 400}, {587, 400}, {659, 400}, {740, 400},
   {740, 400}, {740, 200}, {659, 200}, {659, 800},

   {740, 400}, {740, 600}, {784, 400}, {880, 400},
   {880, 400}, {784, 400}, {740, 400}, {659, 400},
   {587, 400}, {587, 400}, {659, 400}, {740, 400},
   {659, 400}, {659, 200}, {587, 200}, {587, 800},

   {659, 400}, {659, 400}, {740, 400}, {587, 400},
   {659, 400}, {740, 200}, {784, 200}, {740, 400}, {587, 400},
   {659, 400}, {740, 200}, {784, 200}, {740, 400}, {659, 400},
   {587, 400}, {659, 400}, {440, 400}, {440, 400},

   {740, 400}, {740, 600}, {784, 400}, {880, 400},
   {880, 400}, {784, 400}, {740, 400}, {659, 400},
   {587, 400}, {587, 400}, {659, 400}, {740, 400},
   {659, 400}, {659, 200}, {587, 200}, {587, 800},
}

local resolution = 1000000

local function play(rmt)
   for _, note in ipairs(score) do
      local freq, duration = note[1], note[2]
      local symbolDuration = resolution / freq / 2
      rmt:transmit({loop = duration * freq / 1000}, {
         {0, symbolDuration, 1, symbolDuration}
      })
      coroutine.yield()
   end
   rmt:close()
end

local coro = coroutine.create(play)

local rmt, err = esp32.rmttx{
   gpio = 0,
   resolution = resolution,
   callback = function()
      coroutine.resume(coro)
   end
}

if rmt then
   rmt:enable()
   coroutine.resume(coro, rmt)
else
   trace(err)
end

function onunload()
   rmt:close()
end

How the Musical Example Works

The play() function runs as a Lua coroutine. That lets the code look sequential even though the actual note timing is event-driven.

For each note:

  1. The frequency is converted into a half-period duration.

  2. A single RMT symbol is transmitted in a loop to generate a square wave.

  3. The coroutine yields.

  4. The TX callback resumes the coroutine after the note duration finishes.

This is a useful general RMT pattern: let hardware perform the timing while Lua coordinates higher-level sequencing.

TX Example 2: WS2812B LED Strip

The following example drives a WS2812B-compatible LED strip by defining the timing for bit 0 and bit 1 and then repeatedly retransmitting updated color data.

local leds = 1
local gpioPin = 48
local rmt, err

local data = {}
for i = 1, leds * 3 do
   table.insert(data, ba.rnd(0, 0xFF))
end

local function transmit()
   rmt:transmit({}, {
      {
         true,
         {1, 4, 0, 8},
         {1, 8, 0, 4},
         data
      },
      {0, 150, 0, 150}
   })
end

rmt, err = esp32.rmttx{
   gpio = gpioPin,
   resolution = 10000000,
   callback = function()
      for i = 1, #data do
         local c = data[i] + 1
         if c > 0xFF then
            c = 0
         end
         data[i] = c
      end
      transmit()
   end
}

if rmt then
   rmt:enable()
   transmit()
else
   trace(err)
end

function onunload()
   trace"Stopping wled"
   if rmt then
      for i = 1, #data do
         data[i] = 0
      end
      transmit()
      transmit = function()
         rmt:close()
      end
   end
end

Breaking Down the LED Example

Preparing color data:

  • data contains three bytes per LED.

  • Each group of three bytes represents one RGB color value.

Transmit function:

  • The first item in the transmit table describes byte encoding for the LED protocol.

  • true selects MSB-first transmission.

  • {1, 4, 0, 8} defines the waveform for a zero bit.

  • {1, 8, 0, 4} defines the waveform for a one bit.

  • {0, 150, 0, 150} adds the reset pulse after the color data.

Continuous animation:

  • The TX callback updates the color table after each completed transfer.

  • The callback then starts the next frame.

  • This creates a continuous animation loop without a separate timer.

Cleanup:

  • onunload() turns the LEDs off before closing the channel.

  • This matters when the code runs as an .xlua page and may be reloaded or stopped while the previous transmission is still active.

RMT RX API

Creating an RX Channel

Function signature:

rmtrx, err = esp32.rmtrx(cfg)

This creates a receive channel that captures incoming pulse timings as RMT symbols.

RX Configuration Options

Required fields:

  • gpio: Input GPIO pin.

  • resolution: Tick resolution in Hertz.

  • callback: Lua function called when a receive job completes.

Optional fields:

  • mem: Memory or DMA buffer sizing. Default is 64.

  • invert: Invert input levels before processing. Default is false.

  • DMA: Enable DMA backend. Default is false.

RX callback signature:

function callback(symbols, overflow)

Arguments:

  • symbols: Array of received RMT symbols.

  • overflow: Boolean indicating whether the receive buffer overflowed.

RX Object Methods

rmtrx:receive(cfg)

Starts a receive job and returns immediately.

Receive cfg fields:

  • min: Minimum valid pulse duration in nanoseconds. Shorter pulses are treated as glitches.

  • max: Maximum valid pulse duration in nanoseconds. Longer pulses are treated as a stop signal and end the receive operation.

  • len: Optional receive buffer length in RMT symbols. Default is 512.

  • defer: Optional boolean. Relevant when TX and RX are linked to the same GPIO.

When defer is used with a linked TX/RX pair:

  • false means reception starts immediately, so transmitted symbols may also appear in the receive result.

  • true delays reception until the linked TX channel finishes transmitting.

rmtrx:close()

Closes the RX channel and releases its resources.

RX Example: 1-Wire Temperature Read

The following example shows how RMT can be used to implement a 1-wire temperature transaction against a DS18B20-style sensor.

 1local tInsert = table.insert
 2local cResume, cYield = coroutine.resume, coroutine.yield
 3
 4local function decodeBytes(symbols)
 5   local mask, byte, t = 1, 0, {}
 6   for _, sym in ipairs(symbols) do
 7      if sym[2] <= 15 then
 8         byte = byte | mask
 9      end
10      mask = mask << 1
11      if 256 == mask then
12         tInsert(t, byte)
13         mask, byte = 1, 0
14      end
15   end
16   return t
17end
18
19local function readTemp(gpio, callback)
20   local coro
21
22   local function tempThread()
23      local txCfg = {eot = 1}
24
25      local rx <close> = esp32.rmtrx{
26         gpio = gpio,
27         resolution = 1000000,
28         callback = function(symbols)
29            cResume(coro, symbols)
30         end
31      }
32
33      local tx <close> = esp32.rmttx({
34         gpio = gpio,
35         opendrain = true,
36         resolution = 1000000,
37      }, rx)
38
39      local function busReset()
40         rx:receive{min = 2000, max = 480 * 2 * 1000}
41         tx:transmit(txCfg, {{0, 480, 1, 70}})
42         local symbols = cYield()
43         if #symbols < 2 then
44            callback(nil, "No sensors connected")
45         end
46         return #symbols < 2
47      end
48
49      local function sendCommand(cmd)
50         rx:receive{min = 900, max = 70 * 1000}
51         tx:transmit(txCfg, {
52            {
53               false,
54               {0, 60, 1, 2},
55               {0, 6, 1, 56},
56               cmd
57            }
58         })
59         return cYield()
60      end
61
62      local function readBytes(len)
63         local t = {}
64         for i = 1, len do
65            tInsert(t, 0xFF)
66         end
67         return sendCommand(t)
68      end
69
70      tx:enable()
71      tx:transmit(txCfg, {{1, 1, 1, 0}})
72      if busReset() then
73         return
74      end
75
76      sendCommand{0xCC, 0x44}
77      ba.timer(function()
78         cResume(coro)
79      end):set(1000, true)
80      cYield()
81
82      if busReset() then
83         return
84      end
85
86      sendCommand{0xCC, 0xBE}
87      local data = decodeBytes(readBytes(2))
88      local raw = (data[2] << 8) + data[1]
89      callback(raw * 0.0625)
90   end
91
92   coro = coroutine.create(tempThread)
93   cResume(coro)
94end
95
96readTemp(1, function(temp, err)
97   trace(temp, err)
98end)

Understanding the 1-Wire Example

This example is dense, so it helps to break it into pieces.

1-wire background:

  • A bus reset begins communication by pulling the line low for at least 480 us.

  • A device indicates presence by pulling the line low in response.

  • Bit timing determines whether the transmitted or received value is a zero or a one.

How the Lua code is organized:

  • tempThread() is written as a coroutine so the protocol can be expressed in sequential steps.

  • The RX callback resumes the coroutine whenever a receive operation completes.

  • TX and RX share the same GPIO pin, which is required for 1-wire signaling.

What decodeBytes() does:

  • It examines the low-duration part of each received symbol.

  • Short low pulses are interpreted as binary 1.

  • Longer low pulses are interpreted as binary 0.

  • Bits are shifted into bytes in least-significant-bit-first order.

Protocol flow:

  1. Reset the bus.

  2. Send 0xCC and 0x44 to skip ROM selection and start temperature conversion.

  3. Wait for the conversion to complete.

  4. Reset the bus again.

  5. Send 0xCC and 0xBE to read the scratchpad.

  6. Decode the returned bytes into a temperature value.

Why the example uses 0xFF during reads:

In 1-wire, the master must still generate timing slots while reading. Sending a series of one bits creates those read slots, and the sensor alters the observed pulse widths to encode its response.

Garbage-Collection Consideration

One subtle issue in coroutine-driven code like this is object lifetime. The example intentionally keeps the logic focused on protocol flow, but a production implementation should keep an explicit reference to the coroutine or wrap the entire behavior in a long-lived object.

Without a stable reference, Lua’s garbage collector could reclaim the coroutine while it is waiting for an event. If that happened, the callback would never resume the protocol.

Practical Guidance

  • Start by choosing a resolution that makes your protocol timing easy to reason about.

  • Use direct symbols when you are hand-crafting pulse sequences.

  • Use byte encoding when the protocol naturally describes timing for bit 0 and bit 1.

  • Pair TX and RX channels on the same GPIO for bidirectional single-wire buses.

  • Always think about cleanup and object lifetime when running long-lived RMT activity from LSP or hot-reloaded code.