The hidden serial port in a cheap Zigbee ceiling radar

A cheap Zigbee ceiling radar hides a radar module with a plain-text serial port, so you can tune each room's sensitivity and hold time by hand.

Difficulty
Advanced
Parts cost
A 3.3 V USB-serial adapter and a 1 kΩ resistor, plus the radars
Build time
An evening, then a few nights of watching
Skills
USB-serial adapters, Python, Zigbee2MQTT

The problem

I put six cheap battery Zigbee radars on the ceilings around the house, mostly in place of home-made radars. They’re 24 GHz mmWave presence sensors, the sort that can tell someone is there even when they’re sitting still. Mine are sold as Z3-24G under a few brand names.

Out of the box they got it wrong both ways at once. The one in the hallway by the kids’ rooms fired about 25 times in three hours with nobody there. The one in the dining area kept dropping a person sitting still right underneath it: 34 times in a day, compared with a home-made radar in the same room.

And there was nothing to adjust. Zigbee2MQTT (the bridge between the Zigbee radio and Home Assistant) sees them as model MS01, vendor zbeacon, and Home Assistant shows them as “Motion sensor (SNZB-03) by SONOFF”. All you get is occupied or clear, plus battery. No sensitivity, no range, no hold time.

So I opened one up. The radar is a separate little module, and it has a serial port that prints its whole setup in plain text and takes commands. I tuned every room by hand through that port, then closed them up again. Same Zigbee pairing, nothing reflashed.

How it works

Where the ceiling radar's settings live Inside the sensor, a separate radar module does all the sensing and holds all the settings. The Zigbee chip only reads the module's presence output pin and passes occupied or clear to Home Assistant, along with the battery level. The module also has a plain-text serial port. While tuning, a USB-serial adapter and a laptop connect to it, read its settings and change them with AT commands. Then the wires come off and the sensor goes back on the ceiling with its Zigbee pairing untouched. tuning only: T, R and G wires 115200 baud, plain text O pin: present or clear (the only thing it reads) Zigbee Laptop + USB-serial adapter skyrelay.py show / set / monitor Radar module SKYRELAY BC2412, 24 GHz • all the settings live here • 3 zones: 0–2 m, 2–5 m, 5–10 m • motion + still threshold per zone • hold time, scan speed • kept through a battery change Zigbee chip never writes to the radar Home Assistant occupied / clear + battery, no settings Tune it once, unplug, close it up. The Zigbee pairing doesn't change.

Inside there are two boards. The main board has the Zigbee chip (a Telink-type one, with an SWS programming pad). The radar is a white daughterboard: a SKYRELAY BC2412FR, 24 GHz, with two patch antennas. Its pins are labelled V G O R T: supply, ground, presence output, serial in, serial out.

I hooked a cheap logic analyser (a gadget that records what’s happening on several wires at once) to the module’s pins and powered the sensor up. Three things fell out of that capture:

  • T (the radar’s serial out) talks plain text at 115200 baud. It prints its settings at power-up, then a short report about every half a second.
  • O goes high when someone is there. That’s the only pin the Zigbee chip reads.
  • R (the radar’s serial in) is silent. The Zigbee chip never sends the radar anything, so whatever you set on the radar stays set. Settings also survive taking the batteries out.

The radar splits its view into three distance zones: 0–2 m, 2–5 m and 5–10 m. Each zone has its own motion threshold and still threshold (for someone not moving). A higher number means less sensitive. There’s also a hold time, a trigger count and a scan speed.

Here’s why the factory settings suit a ceiling badly. The seller says the beam is about 110° wide, motion is picked up out to 6 m, and still people out to 4 m. From a 2.4 m ceiling, the farthest point still inside that beam is about 4.2 m away. So zone 3 (5–10 m) is entirely outside the beam: anything it reports out there is a reflection. Meanwhile the people under a ceiling sensor are in zone 1, and zone 1 has the least sensitive still threshold. The far zones have the most sensitive one. That’s a wall profile. On a wall, the first couple of metres is the wall itself and the room is 2–10 m away, so it makes sense there. On a ceiling it’s backwards.

What you need

  • One or more of these radars. Check in Zigbee2MQTT: model MS01, vendor zbeacon, firmware 0122052017 on mine. The listing for mine said 70 × 25 × 20 mm, two AAA batteries, 24 GHz, Zigbee 3.0, “ceiling or wall”.
  • A 3.3 V USB-serial adapter. CP2102, CH340 or FTDI. I used a CP2102.
  • A 1 kΩ resistor, to go in the line from the adapter into the radar.
  • Three short wires to reach the module’s G, R and T pins.
  • A computer with Python 3 and the pyserial library.
  • Zigbee2MQTT and Home Assistant, with the sensors already paired.

You don’t need a logic analyser. That’s only how I found the port in the first place.

Wiring

Take the batteries out before you wire anything. Then connect the adapter to the radar module’s pins:

USB-serial adapter Radar module pin
TXD R, through the 1 kΩ resistor
RXD T
GND G
3V3 / 5V Not connected

The sensor runs on its own batteries the whole time. Don’t power it from the adapter. The 1 kΩ resistor limits the current into the radar’s R pin, in case the batteries sit a little below the adapter’s 3.3 V.

Leave V and O alone.

Firmware and config

Nothing gets flashed, and nothing changes in Home Assistant or Zigbee2MQTT. All the work is a few text commands over the serial port.

What the radar says at power-up

This is the banner from the first unit I opened, straight off the T pin:

ROM12_OK
SW=22.51.14
BaudRate=115200
GPIO=0
TAGOUT=2
HoldFrame=40
FastTime=500
SlowTime=500
TRITH=3
HOLDONTH=3
Range1=2.0m
Range2=5.0m
Range3=10.0m
MR1TH=14
MR2TH=10
MR3TH=10
R1TH=10
R2TH=6
R3TH=6
CFAR=15
NMF=3

After that, with TAGOUT=2, it sends a line about every 0.54 seconds like 1,92cm,17: a first field, then the distance to the target, then its energy (how strong the reflection is). 0cm,0 means nothing this scan. The first field was always 1 in my tests, so don’t read it as “present”. The units that had been paired shipped with TAGOUT=0 (no reports) and HoldFrame=60.

What the settings mean

The names are the firmware’s. The meanings are my reading of them. The ones marked “verified” I tested.

Setting Factory What it does
Range1 / 2 / 3 2 / 5 / 10 m the three distance zones
MR1TH / MR2TH / MR3TH 14 / 10 / 10 motion threshold per zone. Higher = less sensitive (verified)
R1TH / R2TH / R3TH 10 / 6 / 6 still-presence threshold per zone. Higher = less sensitive (verified)
HoldFrame (set with HOLD) 40 or 60 how many scans to stay “present” after the last detection
TRITH 3 how many scans it needs to trigger
FastTime / SlowTime (set with FTIME / STIME) 500 scan interval in ms. I measured 551 ms per scan at 500
TAGOUT 2 or 0 2 = live reports on, 0 = quiet
CFAR / NMF 15 / 3 noise filtering, I think. I left these alone

The protocol

  • Commands are AT+NAME=value followed by a carriage return and line feed. It answers AT+OK or AT+ERR. A plain AT gets OK.
  • The first line after a quiet spell is thrown away. The radar answers STOP, which means it has paused measuring and is in command mode. Anything you sent before STOP showed up was ignored. About 5 seconds after your last command it says RUN and goes back to measuring. So always send AT first, wait for STOP or OK, then send the real settings.
  • Settings it accepts: MR1TH MR2TH MR3TH R1TH R2TH R3TH HOLD TRITH CFAR NMF TAGOUT FTIME STIME.
  • AT+RESET restarts the radar and it prints the banner again with the live values. That’s the easy way to read your settings back.
  • I never found a command for the range zones. Every AT+RANGE3=… I tried got AT+ERR. The thresholds do the job anyway: set a zone’s thresholds to 99 and it’s effectively off.

The tool: skyrelay.py

I worked the protocol out with a couple of rough scripts, then wrote one tool that does it properly. It finds the adapter by itself, wakes the radar, sends each setting and checks the reply, then restarts the radar and prints the live values back.

pip install pyserial
#!/usr/bin/env python3
"""
skyrelay.py - read and tune the SKYRELAY BC2412 24 GHz radar module found inside cheap
battery Zigbee "ceiling presence" sensors (Zigbee2MQTT: model MS01, vendor "zbeacon").

Wire a 3.3 V USB-serial adapter to the radar module's pins:
    adapter TXD --1k--> R      adapter RXD <-- T      adapter GND --- G
Leave the sensor on its own batteries; do NOT connect the adapter's 3V3/5V.

usage:
    skyrelay.py [--port /dev/ttyUSB0] show                 restart the radar and print its settings
    skyrelay.py [--port ...] set R1TH=6 HOLD=110 ...       change settings (saved in the radar), then show
    skyrelay.py [--port ...] monitor                       print live reports (state,distance,energy)

Protocol (115200 8N1, ASCII, CRLF):
  - The first line after an idle spell only wakes it: it replies "STOP" (measuring paused,
    command mode). Lines sent before "STOP" are ignored. It replies "RUN" ~5 s after the last command.
  - "AT" -> "OK";  "AT+NAME=value" -> "AT+OK" or "AT+ERR";  "AT+RESET" restarts and reprints the config.
  - Settable: MR1TH MR2TH MR3TH (motion), R1TH R2TH R3TH (still presence), HOLD (= HoldFrame),
    TRITH, CFAR, NMF, TAGOUT, FTIME/STIME (= FastTime/SlowTime, scan interval in ms).
    Higher threshold = LESS sensitive. Values persist across power loss.
  - HOLD and TRITH count SCANS, not seconds: at the factory 500 ms (measured 551 ms) HOLD=20 is ~11 s,
    at 200 ms it is ~4 s.
"""
import argparse, sys, threading, time, queue
import serial
from serial.tools import list_ports

class Radar:
    def __init__(self, port):
        self.s = serial.Serial(port, 115200, timeout=0.05)
        self.lines = queue.Queue()
        threading.Thread(target=self._reader, daemon=True).start()

    def _reader(self):
        buf = b""
        while True:
            buf += self.s.read(512)
            while b"\n" in buf:
                line, buf = buf.split(b"\n", 1)
                self.lines.put(line.decode(errors="replace").strip())

    def _send(self, msg):
        self.s.write(msg.encode() + b"\r\n"); self.s.flush()

    def _expect(self, words, timeout):
        end = time.time() + timeout
        while time.time() < end:
            try:
                line = self.lines.get(timeout=0.05)
            except queue.Empty:
                continue
            if line in words or any(line.startswith(w) for w in words):
                return line
        return None

    def _drain(self):
        while not self.lines.empty():
            self.lines.get_nowait()

    def wake(self):
        for _ in range(8):                      # it can be busy for a few seconds after a restart
            self._drain(); self._send("AT")
            if self._expect(("STOP", "OK"), 1.5):
                return
        # Last resort: catch it at power-up (also proves the T wire works - it prints its config on boot).
        print("No reply. Check the wiring; then remove the batteries for 5 s and put them back -"
              " waiting up to 90 s for its start-up banner ...", flush=True)
        if not self._expect(("ROM12_OK",), 90):
            sys.exit("still nothing - check wiring (adapter RXD must go to the module's T pin, GND to G)")
        time.sleep(0.3)
        for _ in range(5):
            self._drain(); self._send("AT")
            if self._expect(("STOP", "OK"), 1.5):
                return
        sys.exit("it started up but won't take commands")

    def command(self, msg):
        self._drain(); self._send(msg)
        return self._expect(("AT+OK", "AT+ERR", "OK"), 1.5)

    def show(self):
        self._drain(); self._send("AT+RESET")
        if not self._expect(("ROM12_OK",), 4):
            sys.exit("radar did not restart")
        cfg, end = [], time.time() + 2
        while time.time() < end:
            try:
                line = self.lines.get(timeout=0.1)
            except queue.Empty:
                continue
            if "=" in line:
                cfg.append(line)
            if line.startswith("NMF="):
                break
        return cfg

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--port", help="serial port; default = the only USB-serial adapter plugged in")
    ap.add_argument("action", choices=["show", "set", "monitor"])
    ap.add_argument("settings", nargs="*", help="NAME=value, e.g. R1TH=6 HOLD=110")
    a = ap.parse_args()
    if not a.port:
        ports = [p for p in list_ports.comports() if p.vid]
        if len(ports) != 1:
            sys.exit("found %d USB-serial adapters - pick one with --port:\n%s" % (len(ports),
                     "\n".join(f"  {p.device}  {p.description}" for p in ports) or "  (none)"))
        a.port = ports[0].device
        print(f"using {a.port} ({ports[0].description})")
    r = Radar(a.port)
    if a.action == "monitor":
        print("Ctrl-C to stop")
        while True:
            print(r.lines.get())
    r.wake()
    ok = True
    for kv in a.settings:
        name, _, value = kv.partition("=")
        reply = r.command(f"AT+{name.upper()}={value}")
        print(f"{name.upper()}={value}: {reply}")
        ok &= reply in ("AT+OK", "OK")
    for line in r.show():
        print(line)
    sys.exit(0 if ok else 1)

if __name__ == "__main__":
    main()

The three ways to use it:

python3 skyrelay.py show            # restart the radar and print its current settings
python3 skyrelay.py set R1TH=7 HOLD=200 ...   # change settings, then print them back
python3 skyrelay.py monitor         # live distance and energy (needs TAGOUT=2)

The profiles I run

All my ceilings are about 2.4 m. Use these as a starting point and calibrate your own rooms (step 5 below).

Room What it’s for skyrelay.py set …
Open-plan living area (three sensors, lit as one space) see as much as possible, hold people sitting still R1TH=7 MR1TH=10 R2TH=8 MR2TH=10 R3TH=15 MR3TH=15 TRITH=2 FTIME=300 STIME=300 HOLD=200 TAGOUT=0
Entry by several doors, and the hallway (lights at night) movement only, no ghosts, quick MR1TH=10 R1TH=15 MR2TH=18 R2TH=99 MR3TH=99 R3TH=99 TRITH=3 FTIME=200 STIME=200 HOLD=60 TAGOUT=0
A bedroom (someone reading or lying on the bed) hold a still person lying down R1TH=15 R2TH=13 R3TH=99 MR1TH=15 MR2TH=12 MR3TH=99 TRITH=3 FTIME=300 STIME=300 HOLD=200 TAGOUT=0

Roughly what they do:

  • Living area: zones 1 and 2 are sensitive because the area is one big open room and I want each sensor to see as much of it as it can. Zone 3 is milder (15) because it’s outside the beam. At 300 ms scans, HOLD=200 is about a minute.
  • Entry and hallway: still detection is off past 2 m (99), zone 3 is off, and zone 2 needs real movement (18). The 200 ms scans with TRITH=3 make it about three times quicker than factory, and HOLD=60 is about 12 seconds.
  • Bedroom: the person is lying on the bed about 2.2 m from the sensor, which is zone 2, so zone 2 still detection is on at 13: above the empty room (9 at most) and below the quietest readings of someone lying there (17).

Faster detection

The factory needs 3 scans to trigger, at about 551 ms a scan. That’s around 1.6 seconds before it notices you, and you end up waving your arms in the doorway. FTIME=200 STIME=200 took it to 201 ms a scan. That’s about 2.7 times as many scans, so expect it to cost battery. I haven’t measured how much yet.

Step by step

  1. Get a baseline first. Look at the sensor’s history in Home Assistant and count the triggers when you know nobody was there, or the drop-outs while someone sat still. That’s the number to beat.
  2. Open the sensor and take the batteries out.
  3. Wire the adapter as in the table: TXD through 1 kΩ to R, RXD to T, GND to G, no power wire.
  4. Put the batteries back and read it: python3 skyrelay.py show. You should get the banner. If you have more than one USB-serial adapter plugged in, it refuses to guess and lists them. Pick one with --port.
  5. Calibrate where people actually are. Turn the reports on with set TAGOUT=2, then run monitor while someone sits (or lies) still exactly where they will in real life. Note the energy values and the distance. Then do the same with the room empty. Set that zone’s still threshold to about half the person’s quietest energy, and above what the empty room reads.
  6. Load the profile with set …. Every setting should come back AT+OK, and the banner printed after it should show your new values. Finish with TAGOUT=0 so it isn’t chattering away.
  7. Unwire, close it up, put it back. The Zigbee pairing doesn’t change.
  8. Watch it for a few nights before you trust it with anything that turns lights on while people sleep.

Testing it

  • Read it back, every time. show restarts the radar and prints what it’s really running. Don’t trust that a set worked until the banner says so.
  • Use the live reports to see what it sees. With TAGOUT=2, monitor shows the distance and energy of whatever it’s looking at. My calibration numbers, for scale:
    • Me, sitting still about 1.2 m from a living-area sensor: energy minimum 15, typically 33. That’s why the living area has R1TH=7.
    • Me, lying still on the bed in a bedroom, about 2.2 m away: energy typically 23, and 95% of readings at 17 or more.
    • The same bedroom empty: never above 12 in zone 1 or 9 in zone 2. On factory settings it still said “present” for the whole empty minute. No wonder it kept firing all night.
  • Trust Home Assistant for the final answer, not the report. The first field in the live report stays at 1. Watch the occupancy in Home Assistant instead.
  • Test the hold both ways. In the tuned bedroom, lying still was seen in every one of 138 reports. When I left, the energy dropped to 0 within 2 seconds, Home Assistant went to clear about 65 seconds later, and it stayed clear.
  • Compare it with a second sensor if you can. I have home-made radars beside some of these, so I can count how many of the Zigbee triggers the other sensor agrees with. That’s how I caught the problems in the lessons below.
  • Watch the battery voltage, not the percentage. The percentage sits at 100% for a long time. Fresh batteries read about 3000 mV.

How it went:

  • The hallway, on its first night: no triggers at all from 2:30 to 6 am, where it had been firing about 8 times an hour with nobody there. It also reacts in about 0.2 seconds now instead of about 1.6. “200% better, almost instant” was my verdict walking under it.
  • The dining area held a person sitting still for 6 minutes 19 seconds, over a stretch where the home-made radar in the same room dropped them for 2 minutes 50 seconds.
  • The entry, on its second profile, agreed with the home-made radar next to it on 103 of 104 triggers.

Lessons learnt

The factory settings are a wall profile, and these are sold for ceilings. The least sensitive zone is where the people are, and the most sensitive zones are floor reflections, open doorways and the next room. That one mismatch caused both the false triggers and the drop-outs. Tune every sensor for where it’s mounted.

One-scan triggering at 200 ms ghosted the entry all night. My first entry profile had TRITH=1 with 200 ms scans, which gives it something like eight times as many chances to trigger on a blip as the factory setting. Zone 2 motion was also quite sensitive (10) out to 5 m, so it could see movement through an open door. Between about 4 and 6 am it ghost-triggered around 120 times, and the living-area lights came on about 40 times in the middle of the night. The home-made radar beside it saw nothing. TRITH=3 and zone 2 motion at 18 fixed it, and it’s still about three times quicker than factory. Anything that turns lights on at night gets TRITH=3 now.

The hallway’s first profile was too twitchy as well. I started the hallway on a “walk-through” profile: TRITH=1, 200 ms scans, still detection off. Its first night was perfect. Then one night it fired 47 times in 45 minutes. Someone was up, but that was far busier than any other room. It runs the entry profile now.

HOLD counts scans, not seconds. Speed up the scans and the hold time quietly gets shorter. HOLD=20 is about 11 seconds at the factory speed and about 4 seconds at 200 ms. Work it out again every time you change FTIME and STIME.

The first command after a quiet spell is thrown away. The first command I sent each time just vanished. The radar answers the first line with STOP and drops it. Send AT, wait for STOP, then send the real thing. skyrelay.py does this for you.

I spent an hour talking to the wrong adapter. I had a second USB-serial adapter plugged in for another project, and my early script was hard-coded to the first port. It was talking to that one, so the radar looked dead. skyrelay.py now uses the only adapter it finds and refuses to guess when there are several.

Tuning doesn’t fix a bad unit. Even on the entry profile, the hallway sensor kept firing on its own. One night it fired 22 times and the home-made radar beside it agreed with none of them. The entry sensor, same model and same settings, agreed with its partner 97 times out of 99. So it’s that unit or where it’s mounted, not the settings. I’ve moved it onto the wall right next to the home-made one, so both see exactly the same thing and I can tell which it is. The kitchen one has started ghosting too, and I’ve taken it out of the lighting until I know why. If one sensor won’t settle, compare it with a second sensor before you chase the numbers any further.

The battery percentage doesn’t move. Zigbee2MQTT treats these as a SONOFF motion sensor, and the percentage sat at 100% on all six. Watch the voltage instead, especially if you’ve sped up the scans.