How a bare ESP32-S3 learned to hear a 2.4 GHz remote: raw I/Q, a GFSK demodulator in C, the ADC's ring mode and a gate that keeps Wi-Fi out.

Reverse-engineering the ScreenBar Halo remote with a bare ESP32-S3

How a bare ESP32-S3 learned to hear a 2.4 GHz remote: raw I/Q, a GFSK demodulator in C, the ADC's ring mode and a gate that keeps Wi-Fi out.

Projects & Demos

I wanted my first-gen ScreenBar Halo in Home Assistant, running on one cheap ESP32-S3 and nothing else. So I taught a bare S3 to sniff the remote. It finds the lamp's address by itself, with no CC2500 or second radio, and it listens 99.7% of the time on a crowded 2.4 GHz band, so Home Assistant follows every turn of the dial. That listener is the radio in my Home Assistant bridge for the Halo. That post is the build guide; this one is how it works, from raw I/Q samples (the radio signal as pairs of numbers) to a listener that never stops.

I didn't start from zero. Through the BenQ ScreenBar thread on the Home Assistant forum I found Djoko-cli, who had decoded the radio protocol, and Junyi-99, whose firmware already had a bare ESP32-S3 sending packets to the lamp. That firmware switched the receiver on for about a millisecond after each command, just to check the lamp's ACK, and finding your lamp's 4-byte address still took a second radio. Keeping that receiver on, and making it hear the remote, is the part I built.

Capture 1: the remote sends a dial step+160 kHz0-160 kHz01282563845126407688961024address hiddenhiddenCapture 2: the lamp answers with an ACK+160 kHz0-160 kHz01282563845126407688961024address hiddenhiddentime in each capture, us
Swipe the chart sideways to see the whole capture.
Hover or tap a field. This is what the S3 sees after demodulation: frequency against time, with the bits as a wave swinging +/-160 kHz around the carrier. The faint scribble outside the frames is noise.
Two real 1 ms captures from my desk, decoded by the bridge's own demodulator and detector: a dial step (payload C5 87, +71 kHz off carrier) and an ACK from the lamp (+56 kHz). They come from separate exchanges: a command and its ACK never fit in one 1 ms capture, as the timeline in section 2 shows. Address and CRC are masked, because together they would give away my lamp's address.

This drives the S3's radio through undocumented parts of the chip. The S3 radio code this builds on says it is not meant or qualified for real products, and that its signals may not meet your local radio rules. It also says it may permanently change the radio's analog behaviour, which rules the board out of any certified product later. Use a board you can dedicate to it, keep the transmit power low, only talk to your own lamp, and don't build these to sell.

Not affiliated with or endorsed by BenQ. BenQ and ScreenBar are trademarks of BenQ Corporation, used here only to name the product.

What's new here

The protocol and the S3 radio came from others (credits at the end). This is what I added on top:

Sniffing on a bare S3Finds the lamp's address by listening to the remote. No CC2500 or other second radio.
A listener that never stopsThe ADC's ring mode with a cycle-timed bank flip on one core and Halo decoding on the other. 12% listening became 99.7%.
A gate for a busy bandWi-Fi is dropped for being wideband, Bluetooth for sitting too far off the carrier, before either reaches the demodulator.
Shared brightness, testedThe protocol notes listed it as untested. On my lamp, front and back share one brightness.
Colour on the S3A colour-temperature command in the S3 firmware, using the 00-64 range from earlier work. Tested cold, mid and warm.
Auto mode, decodedAuto re-triggers instead of toggling, and keeps the remote streaming FA/FB/FC frames while it runs.

1Why the usual tools don't work

The Halo link is slow and narrow, which sounds easy, but it falls into a gap between the cheap tools.

RTL-SDR dongle~EUR 30

Common tuners stop around 1.7 GHz. 2405 MHz needs a downconverter in front.

Can't reach it
nRF24L01+ sniffing~EUR 2

Same ESB-style framing, and the promiscuous-mode trick is famous. But its slowest rate is 250 kbit/s, and this link runs at 125.

Wrong bit rate
CC2500 module~EUR 5

Can be set to 125 kbit/s GFSK. That's how the address was first found, from a raw capture.

Works, extra module
BM5602 module~EUR 4, niche

Holtek's module with the lamp's own BC5602 chip. Talks to the lamp natively once you know the address, but can't discover it.

Not for finding it
HackRF, ADALM-PlutoEUR 200+

Real SDRs that cover 2.4 GHz. Overkill for one address.

Works, costs more
ESP32-S3~EUR 5

Already has a 2.4 GHz receiver with an ADC behind it. The catch: it takes undocumented chip internals, and Wi-Fi is off while it listens.

This post

2What the remote sends

The remote and lamp use Enhanced ShockBurst-style frames, the format Nordic's nRF24 chips made common, on 2405 MHz: GFSK at 125 kbit/s with +/-160 kHz deviation. I checked every field below against my own captures.

Command: 81 bits = 648 usgap 184 usACK: 65 bits = 520 us
To scale, 8 us per bit. Preamble 8 bits (0x55, or 0xAA when the address starts with a 1), address 32, control 9 (payload length 6, packet ID 2, no-ACK 1), payload 0 or 2 bytes, then CRC16-CCITT (poly 0x1021, start 0xFFFF) over everything after the preamble.

A few rules matter for listening. Each remote/lamp pair has its own address, set at pairing. The ACK is empty, so the lamp never reports its state; you only learn it by hearing what the remote says. Buttons send each state 3 times about 100 ms apart, and the dial sends a frame every ~112 ms while it turns. Every state frame is absolute, so a lost one never leaves the lamp half-updated.

The payload is two bytes: flags, then a value. These six cover most of what the remote sends:

C5 A5flags set: Power, Front, Bright, Back.On, both lights, brightness 50% A5 on the remote's 4C-FE scale
C3 35flags set: Power, Front, Colour, Back.On, both lights, colour 35 00 is coldest, 64 warmest
45 4Cflags set: Front, Bright, Back.Off The light bits stay set, so power brings the same lights back
C9 00flags set: Power, Front, Heart, Back.Heart (favourite) Afterwards the bridge marks brightness and colour as unknown
E1 01flags set: Power, Front, Auto, Back.Auto button Starts auto mode. I caught one press, and it carried 01
FF 00flags set: Power, Front, Auto, Stream?, Heart, Bright, Colour, Back.Wake-up The remote waking up; it carries no state
01010101 00011010001010110011110001001101 000010010 1100010110100101 0000011011110001
The first row on air: preamble 55, address 1A2B3C4D (made up), control length 2, ID 1, ACK wanted, payload C5 A5, CRC 06F1.
Six payloads I captured or sent, with the flag bits set in each. Bit 4 only shows up in the stream the remote sends while Auto runs.

3Turning a Wi-Fi radio into an SDR

The raw sample access isn't mine (see Credits). An undocumented debug path lets the CPU read I/Q samples straight from the radio, and a port for the S3 adds playback through the transmitter. The firmware I started from builds each Halo packet on the S3 itself, plays it, then captures the lamp's ACK.

What the S3 gives you

Receive
10-bit I/Q at 16 MS/s
One capture
16,380 samples in a 64 KB bank, just over 1 ms
Transmit
The DAC plays 40 MS/s from banks you fill
Tuning
Locked to Wi-Fi channel 1 (2412 MHz), then shifted -7 MHz to 2405
The cost
Wi-Fi is off while it runs, and it needs one pinned ESP-IDF build

Everything between the samples and the bits is plain C. For the bridge I rewrote that part so it also runs on a PC, where the tests feed it simulated frames. Here is a real dial frame going through it:

Raw I/Q16 MS/s, 10 bits. Blue is Q. It comes out inverted, so the spectrum is mirrored.
Sum blocks of 82 MS/s, 16 samples per bit. The same pass gives the DC offset.
Phase stepatan2 between samples = frequency. Dashed: the carrier offset.
Slice and check3-tap smooth, slice against the preamble's own average, then CRC16.
The first 6 bits of the real command above, at each stage.
+/-200 kHzIn the PC tests, every frame was found from -200 to +200 kHz of crystal error at 4 dB SNR, because each frame is sliced against its own preamble's average. A fixed 0 Hz threshold falls apart past +/-50 kHz.
22% to 100%In the PC tests, detection at 4 dB without and with the 3-tap smoothing. It looks optional, but without it most weak frames are lost.

4Listening without sending

The firmware already captured about 1 ms after each command to look for the ACK. My first change was a SNIFF command: the same capture with the transmitter off, decoding every frame from any address and keeping the ones whose CRC checks out. The first real run, turning the dial and pressing buttons for two minutes:

SNIFF 120, first run on a real remote
Listening for 120 s. Turn the remote dial and press its buttons NOW.
  CMD 1A2B3C4D  pid 1  payload FF00  offset -15 kHz  t=66.1s
  CMD 1A2B3C4D  pid 2  payload C5DF  offset -26 kHz  t=68.5s
  CMD 1A2B3C4D  pid 1  payload C5FB  offset -11 kHz  t=74.0s
  ACK 1A2B3C4D  pid 1  payload 0000  offset -23 kHz  t=100.7s
  CMD 1A2B3C4D  pid 2  payload C596  offset -22 kHz  t=102.3s
  CMD 1A2B3C4D  pid 1  payload C336  offset -16 kHz  t=109.9s
6 frame(s) in 14881 capture(s), 0 RX error(s).
Your lamp address is almost certainly 1A2B3C4D.
A few lines trimmed, and my lamp's address replaced with a made-up one.
6 framesin 120 s of dial turning and button presses
1 addressand the lamp ACKed the first command I sent to it
12%of the time listening: 14,881 captures in 120 s, one every 8 ms for a 1 ms capture

That found the address. But the board spent most of its time decoding, and a faster decoder alone wouldn't have been enough. Even with no gap between captures, a 648 us command only fits inside a 1,024 us window if it starts in the first 376 us:

Three back-to-back windows, to scale. A 648 us command fits only if it starts in the green part of a window, the first 376 us: 37% of start times. The red one starts 600 us in, runs over the edge, and fails the CRC in both windows.

With the remote's 3 repeats, a press gets through about 75% of the time at best, even with no gaps between captures. The S3 had to listen continuously.

5Listening without gaps

The firmware I started from used one-shot captures. Its transmit side already switches DAC banks mid-playback, so I probed whether the receive side could do the same: fill the banks with 0xFFFFFFFF (the ADC writes only 20 bits per sample), capture, and look at what got overwritten.

  1. A normal capture is one-shot. DONE fires at 1,024 us for 16,380 samples, and nothing is written after, even if you hand the ADC another bank.
  2. The length field is 14 bits. Asking for 49,140 samples behaves like 16,372.
  3. Bit 14 of ADC_CTRL (0x60033d5c) makes it a ring. DONE never comes and the ADC keeps writing until stopped. It wraps after the full 64 KB bank, 16,384 words, so exactly every 1,024 us.
  4. Swapping the bank owner mid-capture works. Writes continue in the other bank at the same offset.

I found that the hard way, then learned it was already written down: esp-sdr calls it circular mode and uses it for continuous capture on the S3, and esp-sdr-zephyr describes the same ring and the bank switching in its hardware notes. What follows is how the bridge uses it.

Two banks make a double buffer. Core 1 flips the bank owner by cycle count every 1,024 us, so every flip lands at the same offset. It also sums each finished bank into blocks, 620-632 us of every lap. Core 0 runs the gate and the demodulator, carrying the previous bank's tail across, so a frame that straddles the flip is still read in one piece. The flip itself is a busy-wait on the cycle counter:

phy_s3_ring.c (excerpt)
#define ADC_MODE_RING   (1u << 14)   /* never DONE; the write address wraps every 16384 words */

bool phy_s3_ring_next(phy_s3_ring_t *ring, phy_s3_chunk_t *chunk)
{
uint32_t at = ring->next;
while ((int32_t)(at - esp_cpu_get_cycle_count()) > (int32_t)MASKED_CYCLES) {
}
portENTER_CRITICAL(&phy_s3_mux);
/* Even a few cycles late would move the flip off its offset, so give up instead. */
bool late = (int32_t)(at - esp_cpu_get_cycle_count()) <= 0;
while ((int32_t)(at - esp_cpu_get_cycle_count()) > 0) {
}
if (!late) {
REG_WRITE(SRAM_OWNER_REG, owner(ring->bank ^ 1u));
}
portEXIT_CRITICAL(&phy_s3_mux);
One remote command split across the flip. It still decodes, because the end of one bank and the start of the next are read as one stream.
Each bank holds one 1,024 us lap. While the ADC fills one bank, the CPU reads the other, so no lap is skipped.

The proof was frames that cross the seam. In 3 minutes of dial and power presses the S3 heard 375 frames, 165 of them the lamp's ACKs. 228 of them (61%) spanned a bank flip and still passed CRC, close to the ~58% their lengths predict. A lost sample or a slipped offset at the seam would have broken them. There were 0 late flips.

62405 MHz is crowded

With nonstop capture the next problem was everything else on the air. Wi-Fi channel 1 covers 2401-2423 MHz, right over the Halo's carrier. With a plain energy gate (2x the noise floor), 20-26% of 32 us segments on my desk were loud, including ~50 bursts a second longer than 1 ms. Demodulating all of it took 60-80% of core 0 and still dropped ~150 windows a second.

The fix was to measure how narrowband a segment is, not how loud. Take the lag-1 correlation of the block sums over their power, a 0-1 score for how steadily each sample turns from the one before. A Halo frame is a single tone wandering +/-160 kHz, so it scores high, while wideband traffic like Wi-Fi scores low. On my desk, with the remote untouched, 98% of loud segments scored under 0.3. Turning the dial added about 202 segments a second between 0.8 and 1.0, where the background puts 4. A segment only opens a demod window if all three hold:

> 2xpower over the noise floor
> 0.45lag-1 coherence
a run of 1232 us segments (an ACK fills 15)
Per second0-0.3wideband: Wi-Fi and the rest0.3-0.8in between0.8-1.0a steady tone, like the remote
Remote untouched13,1333234
Turning the dial14,055248206
Loud 32 us segments a second, sorted by their coherence score. Measured on my desk with the bench firmware, receive only: 25 s with the remote untouched, 34 s turning the dial. Turning the dial barely changes the first two columns but adds about 200 a second to the last. Nothing below 0.45 opens a demod window, so the busiest column never costs any decoding.

One more class got through: narrowband senders off our carrier. Logging the mean frequency of every window-opening run showed ~1,650 of ~1,720 frame-sized runs sitting at -950 to -850 kHz. My guess is Bluetooth advertising on 2402 MHz, folded to about -1 MHz by the 8x decimation. A Halo frame can't sit further out than the detector's 200 kHz offset range plus the 160 kHz deviation, 360 kHz, so with a little margin the gate drops anything centred more than 380 kHz away. Windows went from ~47 a second to 0.04, with 0 drops, and core 0 settled at about 15%.

First SNIFF build1 ms capture, then decode
12%
New modemsame loop, rewritten C
16%
Faster demodone integer pass, cheaper detect
25%
2 ms gap-free windowstwo banks, still processed after
26%
Nonstop ring + gateone core captures, one decodes
99.7%
Share of wall time the S3 is actually listening, by firmware step.

The ring isn't Halo-specific, and the gate's idea carries over to other slow, narrow signals near 2.4 GHz. Its thresholds, the demodulator and the detector are tuned for this one link, though.

7What the captures taught me about the lamp

Before the bridge existed, I ran a guided session on the first SNIFF build: press one thing, see what comes out. At 12% listening it missed some presses (the front/back button and power-off never showed up), but it caught these:

    A few more things I checked:

    1 brightnessFront and back share it. I set the front to minimum and the back to maximum, then turned both on with a colour frame: both came up at the same brightness. That settles a test the protocol notes listed as open, and it's why Home Assistant gets two lights with one shared brightness.
    00-64Colour temperature is flags 80 | 02 | lights with a value from 00 (cold) to 64 (warm), the range halo-nrf52840 and the protocol notes use. I added it to the S3 firmware as its own command and tested cold, mid and warm.
    -26 vs +112 kHzEvery crystal is a little off. One board read the remote at -11 to -26 kHz, the other at +46 to +76 kHz, later drifting out to +112. Per-frame slicing makes that a non-issue.
    2 in 5 minA CRC16 isn't enough on its own. SNIFF printed about 2 ACKs from random addresses in 5 minutes: 1 in 65,536 random frames passes a 16-bit CRC, and the early decoder tried every start position and bit phase in each capture. The bridge trusts only the paired address, and pairing needs 3 distinct changes from one address.

    8From a sniffer to a bridge

    The listener became the radio half of my Home Assistant bridge. The S3 handles the radio, an ESP32-C3 runs ESPHome, and they talk over two UART wires. To keep it small I soldered the two boards into a stack, antenna over antenna, which looked like a good way to deafen the radio. So I measured before trusting it.

    BeforeAfter
    Ring restartsEvery remote frame stopped the ring to be handled: 235 restarts in 90 s of dial use, 12 windows lost.Frames handled inside the ring loop, once per lap: 0 restarts, 0 drops.
    Retries5 tries within ~0.1 s, so one burst of interference could kill a whole command.Each wait is drawn at random from a range 3x wider than the last: 5 tries spread over 0.2-0.6 s.
    Stacked boardsWorried that stacking the boards antenna over antenna would deafen the S3.Measured first: 19/19 ACKs, listening 99.72% stacked vs 99.74% apart.
    No reset wireThe S3 Super Mini has no EN pin for the C3 to pull.Task watchdog plus a "heard nothing for 5 s" check, proven by fault injection: both recover in 5 s.
    TX powerAssumed more power meant fewer lost frames.First-try ACK rate stayed 84-94% across all ten gains with no trend, so the default went down.
    C3 Wi-FiSaw the router at -52 dBm, then failed authentication with the right password.output_power: 8.5dB fixed it. Only my first C3 had this trouble.
    PowerAlways-on listening keeps one core busy.Costs ~30-50 mA, about 0.2 W at 5 V. Kept on, so Home Assistant follows the dial.

    9Still unknown

    The auto-mode streamWhile Auto runs, the remote sends FA/FB/FC xx every few seconds. Probably its light sensor; unproven.
    Flag bit 4 aloneEarlier notes saw 91 00 around the favourite recall. My early sniffer never caught it, so its meaning is still open.
    Brightness in an "off" frameWhether the lamp stores it. The bridge never relies on it and always sends brightness on the next power-on.
    RangeEverything here was measured with the remote on the same desk. Range beyond desk distance is untested.
    My own devicesEverything was done on my own lamp and remote. Nothing was decompiled, and no encryption was bypassed; this link has none.
    Radio rules2405 MHz is licence-free, but the S3 module's certification may not cover raw sample playback. Check your local radio rules, keep the power low, and don't sell boards built this way.
    Other people's lampsThe listener hears every Halo in range. Pair with your own remote, and don't record or publish anyone else's frames.
    Open sourceGPL-3.0, like the radio code it builds on: ESPBoards/screenbar-halo-bridge.

    Credits

    This builds on other people's work. Djoko-cli decoded the Halo's radio protocol; their notes were the map for section 2, and the open questions in them were where I started testing. Junyi-99's halo-esp32s3 made the S3 send Halo packets and check the ACK, on top of esp-sdr (raw I/Q receive on the ESP32) and esp-sdr-zephyr (capture and playback on the S3), which also document the ADC ring mode in section 5. halo-nrf52840 gave the colour temperature range. What's new here is listed at the top.

    And thanks to everyone in the Home Assistant forum thread who kept poking at this lamp.

    Not affiliated with or endorsed by BenQ.

    Where to next