Skip to content

Latest commit

 

History

History
341 lines (258 loc) · 16.3 KB

File metadata and controls

341 lines (258 loc) · 16.3 KB

Reverse-engineering notes: eMotion Air (Telink TLSR8258)

Everything here is for stock firmware v1.1.9 (215,860 bytes). The image loads at 0x0, so every file offset equals its runtime address, which makes patching easy.

The device

  • SoC: Telink TLSR8258 (module marked LL8253-P). Custom TC32 core, thumb-like, little-endian, with its own instruction encoding. Telink Zigbee SDK v2.4.1.0.
  • Radar: an RC2410 24 GHz presence module on an internal UART at 115200 8N1, driven with AT+ commands. Its own firmware reports SW=22.31.18.
  • Dual-mode: the device runs Zigbee or BLE, never both. Selected by config byte +0x14 bit 0 (0 = Zigbee, 1 = BLE).
  • Button: hold about 10 s for BLE pairing, about 15 s for Zigbee pairing. These are independent of stored configuration, which is what makes every experiment reversible.

Toolchain

Compiler: native Linux tc32-elf-gcc 4.5.1 from flyskywhy/tc32, branch linux.

  • Flags: -Os -std=gnu99, because gcc 4.5 defaults to C89. No -mthumb, which is not a valid option for this target.
  • Disassemble a raw image with tc32-elf-objdump -D -b binary -m tc32 --start-address=... --stop-address=.... The -m tc32 is required, or it errors with "architecture UNKNOWN".
  • The Windows build of that toolchain does not work: gcc, cc1 and binutils hard-import libgcj-13.dll from an ancient MinGW GCJ runtime that is not bundled. The repo's posix/ tree is only wine wrappers around those same .exe files. Use WSL.

Disassembler: Ghidra with the community Telink_TC32 processor module (2019 vintage, still builds against current Ghidra).

Ghidra gotcha: auto-analysis gap-fill misclassifies the AES S-box and Rcon tables as code and crashes the decompiler. Force a code/data boundary at 0x2C400.

Image format

Field Where Meaning
format marker img[6:8] 5D 02
boot flag byte @0x08 0x4B ('K'), held back until the OTA completes
magic @0x08 KNLT
total length u32 @0x18 must equal the file length
trailing CRC last 4 bytes CRC-32/JAMCRC, (~zlib.crc32(image[:-4])) & 0xFFFFFFFF

The payload, meaning everything but the CRC, must be a multiple of 16, so length & 0xF == 4.

Dual-bank ping-pong at 0x0 and 0x40000: a new image goes to the inactive bank, the boot flag is written only after a CRC pass, then the old bank is invalidated. A bad image never boots. Maximum image size is 0x40000.

Config block

Base 0x844210 in SRAM, 32 bytes, returned by the getter at 0x5BB4.

Offset Type Meaning Stock default
+0x14 bits b0 mode (0 Zigbee, 1 BLE), b1 encryption, b2 Zigbee-to-BLE flag
+0x15 packed u8 b0-2 frequency, b3-6 sensitivity, b7 awake
+0x16 u16 presence timeout, seconds 180
+0x18 u16 light (lux) sample interval 30
+0x1A u16 temp/humidity sample interval 60
+0x1C u16 lux threshold low ("Normal") 10
+0x1E u16 lux threshold high ("Bright") 100

Defaults are written by the init routine at 0x5C74, which also clamps a lux interval below 2 and a temp/humidity interval below 10 back to defaults. Those are the real minimums. The persist helper is at 0x5BD4, and NV lives in flash around 0x80000 and 0x81000.

Addresses of interest

Address What
0x4B7C command dispatcher, the only safe entry point (see landmine 1)
0x2CC88 opcode jump table, 0x87 entries of 4 bytes
0x88B8 radar AT-command UART sender
0xA310 global ZCL message callback
0x942C flash word holding &globalZclCallback, the hook point
0x33566 occupancy cluster registration (18-byte zcl_specClusterInfo_t)
0x3356A, 0x3356C its attribute count (u16) and table pointer (u32); stock 2 and 0x334FC
0x34788, 0x34790, 0x34792 OTA file version u32, image type u16, manufacturer u16
0x2DC80 DIS "Firmware Revision" string, 5 chars
0x9F5C, 0xA018 OTA query delays: 12,000 ms after join, then 300,000 ms

zclAttrInfo_t is {u16 id; u8 type; u8 access; u32 data}, 8 bytes. Types: 0x18 map8, 0x20 u8, 0x21 u16, 0x29 i16, 0x23 u32. Access bits: 1 read, 2 write, 4 report.

Command protocol

GATT: ...2b11 command write, ...2b10 notify, ...2b12 OTA. Base UUID is 00010203-0405-0607-0809-0a0b0c0d....

The frame is [opcode][arg_len][args...].

Opcode Action Args
0x01 radar restart, sets the awake bit (+0x15 OR 0x80), then AT+RESET none
0x02 frequency 0-4, AT+FREQ=n u8
0x03 sensitivity 1-10, AT+TRITH=n u8
0x04 presence timeout, AT+HOLD=<secs/2> u16
0x05 calibrate, AT+CALI=<passes>, passes 1-20 u8
0x06 radar sleep, clears the awake bit (+0x15 AND 0x7F), then AT+SLEEP none
0x07 calibration reset, AT+INITTH none
0x10, 0x11, 0x12 encryption, key regeneration, factory reset. Destructive
0x13, 0x14 lux and temp/humidity sample interval u16
0x15 lux thresholds, writing +0x1C and +0x1E [low u16][high u16]
0x80, 0x81, 0x84, 0x85, 0x86 read sensor, radar params, key, intervals, thresholds none

Opcode 0x05 takes one byte even though the vendor app never sends a value other than its own default. The handler guards on arg_len == 1 and returns silently without it, which is worth knowing if you drive these opcodes yourself.

Opcode 0x81 is the most useful read, since it exposes the packed config byte:

02 06 <sw0> <sw1> <sw2> <cfg15> <timeout_lo> <timeout_hi>

sw is the radar firmware as hex digits, so 22 31 18 reads as "22.31.18". A live example, 02 06 22 31 18 c8 2c 01, decodes to sensitivity 9, frequency 0, awake 1, timeout 300.

Each handler writes the config byte, sends the radar command, and persists to flash. That is why routing a ZCL write into one produces a real, durable change rather than a cosmetic one.

Four landmines

Each of these produced a real failure that took hours to find.

1. The opcode handlers are not callable functions

They are continuations of the dispatcher. The dispatcher allocates a 56-byte frame and jumps to the handler (tmov pc, r3); the handler builds its AT string inside that frame at sp+0x24, then returns by executing the dispatcher's epilogue (add sp,#56; pop {r2,r3}; ...; pop {r4,r5,r6,r7,pc}).

Proof: the sensitivity handler at 0x4C60 contains tjne 0x4BD8, a branch into the middle of the dispatcher's exit sequence.

Call one directly from your own code and it unwinds your stack by 56 bytes plus seven registers, then branches to whatever it pops as pc. That crashes, and the watchdog reboots. The symptom is a device that reboots every 30 to 60 seconds and never answers, which looks nothing like "my function is wrong".

Always call the dispatcher and let it set up the frame and its informal register convention: r6 = &[opcode, arg_len, args...], r7 = arg_len, r8 = config base.

2. Write records hold a pointer, not a value

typedef struct { u16 attrID; u8 dataType; u8 *attrData; } zclWriteRec_t;  /* stride 7 */

Reached via *(u8**)(pInMsg + 0x0C): a u8 count, then records at +1. The last four bytes are a pointer to the value. The tell is that a fixed 7-byte record physically cannot hold an inline string or array, so it must be indirect.

Comparing those pointer bytes against an expected value silently matches nothing.

3. Read the record fields byte-wise

Records sit at list + 1 + i*7, so attrID lands on an odd address. Unaligned 16 and 32-bit loads on this core return rotated garbage rather than faulting, so the bug is silent. Assemble every field from bytes.

4. Ghidra misrenders a negate trick

The radar-restart handler really does:

mov r3, #128      ; 0x80
neg r3, r3        ; 0xFFFFFF80
or  r3, cfg15
strb r3, [r1, #21]    ; stores the low byte -> cfg15 | 0x80

Ghidra decompiles this as | 0x7f. Trusting it would have inverted the meaning of the awake bit. Disassemble when the semantics matter.

The radar module (RC2410)

The Telink drives the radar over an internal UART at 115200 8N1 using AT+ commands. No public datasheet or AT reference for this module could be found, so the following is reconstructed from the firmware's string table. It may be useful to anyone working with the same radar in another product.

Full AT vocabulary

58 distinct AT+ strings appear in the image, in two separate clusters:

  • 0x2CFC4 to 0x2E214, the subset the Telink actually sends, sitting near the command handlers that use them.
  • 0x30E68 to 0x3125C, the complete vocabulary, apparently a reference or parser table. The firmware knows far more commands than it ever issues.

Grouped by apparent function:

Group Commands
Link and IO AT+BAUD AT+GPIO AT+TAGOUT AT+OUTTRI AT+LED
Session and status AT+START AT+RESET AT+SLEEP AT+OK AT+ERR AT+ATERR
Detection timing AT+HOLD AT+FTIME AT+STIME
Detection thresholds AT+TRITH AT+ONTH AT+R1 to AT+R10 AT+R1TH to AT+R10TH AT+MR1TH to AT+MR10TH
Signal processing AT+CFAR AT+NMF AT+SIGN
RF and hardware AT+GAIN AT+FREQ AT+HW
Calibration AT+CALI AT+INIT AT+INITTH
Debug and factory AT+LAB AT+DEBUG AT+OLDIDX

The stock firmware only ever issues RESET, TRITH, HOLD, CALI, SLEEP, INITTH, FREQ, INIT, and the TAGOUT, FTIME and STIME trio at bring-up.

What the radar reports back

On reset it emits a status block, which the Telink parses. The field-name strings are in the image too:

ROM12_OK / SW=22.31.18 / BaudRate=115200 / GPIO=0 / TAGOUT=0 / HoldFrame=90
FastTime=2000 / SlowTime=500 / TRITH=3 / HOLDONTH=1
Range1..Range10 = 0.7m .. 7.0m   (0.7 m per range gate)
MR1TH..MR10TH, R1TH..R10TH, CFAR=15, NMF=3

Ten range gates at 0.7 m each, with separate per-gate thresholds (R*TH) and what look like moving-target thresholds (MR*TH). The stock firmware's single sensitivity control only drives AT+TRITH and never touches the per-gate thresholds.

Note what is absent from that dump: FREQ, GAIN and HW are settable but never reported. The thresholds appear only in this reset dump, so to see a calibration's effect you have to be watching the radar's output line, not its command line.

Reaching the rest of the vocabulary

Firmware v1.10 adds a raw passthrough on occupancy attribute 0xF0FF (a ZCL character string). The firmware appends CRLF and forwards the string to the radar unchanged, which puts the other roughly 48 commands within reach without another firmware build. Verified on hardware: AT+HW sent that way appeared verbatim on the UART.

The per-gate thresholds are the interesting target. Raising the far gates amounts to limiting detection distance, which neither the vendor app nor the stock firmware exposes.

AT+BAUD is refused by the passthrough. The MCU always talks 115200 to the radar, so a successful baud change would cut the link permanently with no recovery short of replacing the radar. Everything else is recoverable with AT+RESET.

What AT+FREQ probably does

This one is still unknown. The evidence:

  • The firmware accepts 0 to 4, stores it in config +0x15 bits 0-2, sends AT+FREQ=<digit>, and defaults to 0.
  • It sits with GAIN and HW in the command table, the RF and hardware group, rather than with the detection thresholds.
  • The radar never reports it back in its status dump.
  • The vendor app does not expose it at all.

The most plausible reading is a frequency point within the 24.00 to 24.25 GHz ISM band, five selectable channels, so that multiple radars in proximity do not interfere. That is a common feature on 24 GHz modules, but it is inference from naming and grouping rather than anything confirmed against documentation or measurement.

Where it might matter: if you run several of these sensors within range of one another and see phantom presence, one tripping while its room is empty or presence that will not clear, mutual interference is a classic cause and separating them across frequency values is exactly the remedy such a setting exists for.

If you experiment, remember that the read path reports the Telink's stored copy of the value rather than the radar's, because the radar never reports it. The entity will show whatever you set even if the radar ignored it. Judge it by behaviour, not by the number.

What the calibration commands actually do

Measured on 2026-08-29 by pressing each and diffing the radar's reset dumps.

AT+CALI rewrote 19 thresholds, turning the factory ramp (R1TH=8,7,6,4,4...) into an environment-shaped profile (10,10,6,6...). This is the real calibration scan, and it is what the vendor app calls "Start detection". A pass takes roughly 22 seconds, and the radar prints an RCALI_Times countdown while it runs.

AT+INITTH is acknowledged with AT+OK and restarts the radar, but left every threshold unchanged across two restarts. Pressing "Restore" in the vendor app produced an identical UART signature and identical non-results, which identifies AT+INITTH as that button and shows the vendor's own restore does not work. It is described as clearing the per-area calibration and restoring defaults; measurably it does neither.

The practical consequence is that a calibration has no working undo short of a factory reset, so run one only with the sensor mounted where it will live and the room empty.

Working out the app's settings

Mapping the vendor app's Configuration screen onto the firmware:

App control Mechanism
Sensitivity opcode 0x03, presented as Low/Med/High over the 1-10 range
Default no-motion duration opcode 0x04
Light collection interval opcode 0x13
Temperature and humidity collection interval opcode 0x14
Bright and Normal Lux opcode 0x15, both in one command
Start detection opcode 0x05, AT+CALI
Presence monitoring no single toggle: on is opcode 0x01, off is opcode 0x06, i.e. the awake bit
Environment self-learning (Restore) opcode 0x07, AT+INITTH, which measurably changes nothing

A note on the odd-looking branch at 0x5046: sensitivity value 10 does not take the normal path. It is not a hidden mode. The normal path renders the digit with value + 0x30, which only works for 1 to 9, so value 10 branches to a hardcoded "AT+TRITH=10" literal. cmd_05 has the same shape for calibration counts above 9. Pure string-formatting plumbing.

Registering attributes changes write behaviour

Worth knowing before you extend the attribute table. With the stock 2-entry occupancy table, ZCL writes to unregistered attributes were answered SUCCESS. The write was effectively ignored by the stack, but our global-callback shim still saw it and acted, so everything worked.

Publishing a larger table, which is what the v1.9 read path does, makes the stack validate writes against it. Anything not in the table now returns UNSUPPORTED_ATTRIBUTE, after the callback has already run. The side effect still happens; only the response changes. zigpy treats that as a failed write and Home Assistant reverts the entity.

So if you register attributes for reading, you must also register everything you intend to write, with write access. Watch out for packed fields: a raw ZCL write to a byte holding several bit-fields, like +0x15, will clobber its neighbours, which is precisely why the read mirrors are read-only.

Dead ends

  • Hardware (SWS) flashing is not viable on this module. No reset pad is exposed on the LL8253-P, back-powering does not help, and Telink's floader expects UART on PA0/PB1 rather than the accessible test point. OTA is strictly better and needs no soldering.
  • bleak on Windows cannot reliably reach this sleepy device. Use native WinRT with GattSession.maintain_connection and drive an uncached service discovery in a loop.
  • Never trust a silent instrument. A serial monitor died for 15 minutes, and a quiet line is indistinguishable from a dead one, so a genuine success was recorded as a failure and sent the investigation down a blind alley. Emit a liveness heartbeat.
  • A client-side timeout is not a failed write. Test tooling that gives up after a few seconds reports "never delivered" while ZHA still has the write queued, and it lands minutes later. Confirm from device state, not from your own return value.