Embedded HandbookApps
All sections › XI. Sensors: connection, circuits, drivers
STM32F103, STM32F407 · level intermediate · 78 min read
Open in the interactive handbook

100а. Human presence with a 24 GHz radar: HLK-LD2420 — range by gates, the UART protocol and threshold tuning

In section 100.6 the HC-SR501 pyroelectric sensor honestly admitted that it sees motion, not presence. A person sat down to read — and soon the light went out. A millimeter-wave radar works differently: it transmits by itself and catches the reflection, so it notices a motionless person too — by breathing, by small movements of the head and hands. HLK-LD2420 is a small module of such a radar at 24 GHz: 20 × 20 mm, 3.3 V supply, a logic level "a person is present" at the output and a UART port for configuration.

Here: how a radar differs from a pyroelectric sensor and how to connect it; how it divides space into range gates of 0.7 m and why every gate has two thresholds of its own; the command frame and the response frame — byte for byte per the protocol description, together with the places where the description contradicts itself; a driver in C — a receive ring, a parsing state machine that survives garbage and fragments, commands with a timeout and a retry, threshold tuning with read-back. And a small device: a lamp that goes out only when the person has left.

Prerequisites: the user manual and the protocol description from the manufacturer (Hi-Link) — all numbers about the module are taken from there. The driver was run on a computer against our own model of the module, which responds according to the same description: on all sixteen protocol examples, on a stream with garbage, lost and corrupted bytes, and on the scenario "a person came in, sat down, left". The same run was repeated on a Cortex-M4 in QEMU, and the lamp firmware was built for F103 and F407. The driver was not tested with a real module — more on that in the note at the end. UART — sections 16 and 16б, connecting modules — section 95.

Terms used in the documentation

Term Meaning
millimeter wave radar a radar working at millimeter wavelengths
FMCW (frequency modulated continuous wave) continuous transmission with frequency modulation: range comes from the frequency difference
presence, micro-motion presence; small movements (breathing, gestures)
distance gate a range gate: a slice of distance the radar evaluates separately
trigger threshold the threshold for "nobody" → "a person is present"
maintain (hold) threshold the threshold that keeps "the person is still here"
target disappearance delay how long the radar waits before reporting the target gone
bottom noise scanning measuring the noise of the empty room
command mode the mode in which the module accepts commands
packet header, end of packet the frame header, the frame tail
upper computer tool the configuration program on a PC

100а.1 A radar instead of a pyroelectric sensor: what it sees and how it connects

HLK-LD2420 next to an STM32: on the left the module pins 3V3, GND, OT1, RX, OT2 and where they go — OT1 to the PA0 input, OT2 is the radar's transmitter output to the microcontroller RX, the port runs at 256 000 baud; on the right 16 range gates of 0.7 m, the zone 0…8 up to 6.3 m, a person at 2 m falls into gate 2, a fan at 7.5 m into gate 10 outside the zone; at the bottom the command frame for reading a threshold and the response frame with the header FD FC FB FA, the length, the code, the status, the value 60 000 and the tail 04 03 02 01

A pyroelectric sensor is passive: it catches a change in infrared radiation when a warm body moves from one lens zone to another. A radar is active: it transmits at 24 GHz and compares the reflection with what it sent. The LD2420 contains a frequency-modulated radar: the transmit frequency sweeps over a band of 0.25 GHz, and the frequency difference between the transmitted and the reflected signal tells the distance to the reflector. The change in the reflection from one sweep to the next tells about motion. The chest of a breathing person moves by millimeters, and with a wavelength of 12.5 mm that is enough.

HC-SR501 (section 100) HLK-LD2420
principle passive, infrared active, radio waves 24–24.25 GHz
what it sees motion of a warm body motion and also a motionless person by small movements
distance no yes, for a moving target; accuracy ±0.35 m
range (wall mounted) — a moving person 8 m, small movements 6 m
range (ceiling mounted) — 5 m and 4 m
angle — ±60°
supply 5 V 3.0–3.6 V, typically 3.3 V, 50 mA on average
output 3.3 V level OT1 — 3.3 V level, plus UART
configuration a trimmer resistor and a jumper gates, thresholds, delay — by UART commands

All numbers about the LD2420 here and below are from the manufacturer's manual. It also gives: the data are refreshed 10 times per second, the near limit is 0.2 m, the operating temperature is from −40 to 85 °C, the equivalent radiated power is 11 dBm.

The module has two rows of holes with a 2.54 mm pitch, no pins soldered in. J1 is the SWD port of the module itself, for flashing it; the microcontroller does not need it. J2 is the working connector:

J2 Name What it is Where to
1 3V3 supply 3.0–3.6 V the board's 3.3 V
2 GND common common
3 OT1 "a person is present": one — present, zero — not GPIO input (PA0)
4 RX the module's UART receiver microcontroller TX (PA9 on F103)
5 OT2 the module's UART transmitter microcontroller RX (PA10 on F103)

The name OT2 is misleading: it is not a second logic output but the radar's TX. In the table for connecting to a USB–UART adapter the manual says just that: O_T2 goes to the adapter's RXD. The port runs at 256 000 baud — not 115 200 and not 230 400, and the divider has to be calculated (100а.8, section 16.2).

The supply is a separate story. The module draws 50 mA on average, and its receiver listens to a weak reflection. The manual requires that the supply ripple has no noticeable spectral peaks up to 100 kHz. A switching converter nearby without a filter is the first suspect for false triggering; decoupling and ground are covered in section 95.8.


100а.2 Range gates, two thresholds and the disappearance delay

The radar does not say "a person at 2.34 m". It divides the distance into gates — slices of 0.7 m — and in every gate it computes the energy of the reflection that changes. The gates are numbered from 0 to 15: sixteen gates of 0.7 m make 11.2 m, although the promised range is 8 m. The far gates exist in the protocol, but the radar can no longer tell a person there.

Two thresholds are set for every gate, and this is the heart of the configuration:

Parameter Name in the protocol What it does Manual's advice
near gate 0x0000 from which gate to look, 0…15 —
far gate 0x0001 up to which gate to look, 0…15, not less than the near gate —
delay 0x0004 how long to wait before saying "nobody" —
trigger threshold of gate g 0x0010 + g energy above it — "nobody" changes to "a person is present" more than 5 times the gate noise
hold threshold of gate g 0x0020 + g energy above it — the person is still here 2–5 times the gate noise

Two thresholds are hysteresis. To switch on, motion is needed: a person came in, walks, waves a hand — the energy is high. To stay on, breathing is enough — the energy is lower, but above the noise. If the hold threshold is set equal to the trigger threshold, a seated person "disappears" — this can be seen on the model in 100а.8.

Noise is the energy of a gate in an empty room. It is measured by the manufacturer's program on a PC: the scan runs with a given interval and duration, nobody must be in the room during it, and for every gate you get a plot of noise over time. The protocol description has no command of its own for reading gate energies, so the noise table gets into the firmware as numbers — from this scan.

The disappearance delay works like this: when the radar has stopped seeing the person, it does not say "nobody" at once but starts a timer. It sees the person again — the timer starts over. Only when nobody was there for the whole delay does OT1 drop to zero. The unit of the delay is not named in the documents; in the read example from the protocol description it is 30.

The zone is the gates from the near one to the far one. The radar ignores everything beyond it, and this is the best remedy against interference: a fan at 7.5 m falls into gate 10 (7.5 / 0.7 = 10.7), and if the far gate is 8 (up to 6.3 m), nobody will see its swinging. The manual lists the main troublemakers: animals, curtains in a draught, plants under an air conditioner, air conditioners and ceiling fans. And it reminds that the antenna has a back lobe: whatever swings behind the radar, it sees too; a metal shield at the back helps.

The module is mounted on the ceiling (2.7–3 m; the zone is a cone with a base radius of 5 m) or on a wall (1.5–2 m; a sector with a radius of 8 m and ±45° horizontally and vertically). The enclosure must be made only of a material transparent at 24 GHz, without metal.


100а.3 The command frame and the response frame

Commands and responses travel in frames of the same shape:

Field Bytes Command Response
header 4 FD FC FB FA FD FC FB FA
data length 2 code + parameters code + status + values
code 2 command code command code | 0x0100
status 2 — 00 00 — success, otherwise refusal
parameters, values N names (2 bytes each) or name + value pairs (2 + 4) values of 4 bytes each
tail 4 04 03 02 01 04 03 02 01

All multi-byte fields go least significant byte first: the code 0x0068 goes as 68 00, the length 4 as 04 00, the threshold 60 000 (0xEA60) as 60 EA 00 00. The length counts only the data — without the header, the length itself and the tail, so a frame is always 10 bytes longer than it. A frame without parameters ("close mode", "version") is 12 bytes.

The commands per the protocol description:

Command Code Parameters Response in the example
open command mode 0x00FF 01 00 02 00 20 00 after the status
close command mode 0x00FE — status only
version 0x0000 — string length 06 00 and v1.5.4
reboot 0x0068 — not in the description
read parameters 0x0008 names of 2 bytes each values of 4 bytes each, in the same order
write parameters 0x0007 N pairs: name (2) + value (4) status only

Reading and writing happen in command mode: open, do your work, close. Building a frame in the driver:

uint16_t ld2420_build(uint8_t *out, uint16_t cmd, const uint8_t *par, uint16_t n)
{
    uint16_t len = (uint16_t)(2u + n);
    if (len > LD2420_MAX_DATA)
        return 0;
    memcpy(out, HDR, 4);
    out[4] = (uint8_t)len;
    out[5] = (uint8_t)(len >> 8);
    out[6] = (uint8_t)cmd;
    out[7] = (uint8_t)(cmd >> 8);
    if (n)
        memcpy(&out[8], par, n);
    memcpy(&out[8 + n], TAIL, 4);
    return (uint16_t)(len + 10u);
}

An example from the description — reading the trigger threshold of gate 0 and the response:

→ FD FC FB FA 04 00 08 00 10 00 04 03 02 01
← FD FC FB FA 08 00 08 01 00 00 40 9C 00 00 04 03 02 01

The length 04 00: the code 08 00 and the name 10 00. In the response the length is 08 00: the code 08 01, the status 00 00 and the value 40 9C 00 00. What this value is is discussed in the next subsection — the description gets it wrong here.


100а.4 Where the description disagrees with itself

All 16 examples from the protocol description were written into the run byte for byte. For each one the driver built its own frame from the code and parameters, and the module model built its own response. The result:

doc_examples=16 doc_send_same=15 model_resp_same=15

15 of 16 frames matched, and 15 of 16 model responses. Both discrepancies are in the description itself, and here are all the places where the document is ambiguous:

  1. Writing thresholds 00…05 in one frame. In the example the length is 24 00 (36), and the last value is 90 01 without the upper 00 00. Six pairs of 6 bytes and the code make 38 bytes, 26 00. The example frame is 2 bytes shorter than the correct one:

doc_send_differs=set_trig00_05 doc_len=46 our_len=48

A module receiving the example frame will either read the last value wrongly or reject the frame — which one, there is no way to know without a real module. The driver builds the full frame.

  1. Reading trigger threshold 00. The text of the example says: least significant byte first, 9C 40, so 60 000. But 0x9C40 is 40 000; 60 000 is 0xEA60. And in the example of reading thresholds 00…05 the first value is exactly 60 EA 00 00. The same bytes 40 9C 00 00 stand in the example of reading hold threshold 00 — it looks as if the response was copied from there. The model answers 60 000, hence the second discrepancy:

model_differs=read_trig00 doc_resp=FD FC FB FA 08 00 08 01 00 00 40 9C 00 00 04 03 02 01 model_resp=FD FC FB FA 08 00 08 01 00 00 60 EA 00 00 04 03 02 01

  1. The delay range. The parameter table of the protocol says 0x00…0x0F, while the examples read 30 (1E) and write 26 (1A); the manual says 0…65 535. The model and the driver accept 0…65 535. The unit is not named anywhere.
  2. The threshold range. The protocol says "0–65536", the manual 0…65 535. The field is 4 bytes, but the driver does not write 65 536 or more: ld2420_thr_from_noise limits a threshold to 65 535.
  3. The order of the response fields. In words the description puts the status before the response code, while in all examples the code comes first (08 01 00 00). The driver follows the examples.
  4. The response to opening the mode is 02 00 20 00 after the status. What 2 and 32 mean, the description does not say; the driver passes them out as they are.
  5. Reboot — the description gives no response for it. The driver does not wait for one.
  6. The report in normal mode. The manual suggests opening the port and watching the "current result" in a terminal, but the format of this output is not described in either of the two documents. So the driver does not parse reports: everything that did not form a response frame it hands to the on_other callback as is, and the lamp looks at OT1.

The responses from the description, passed through the driver's parser, give these fields:

resp open frames=1 len=8 code=0x01FF status=0 a=2 b=32
resp version frames=1 len=12 code=0x0100 status=0 n=6 s=v1.5.4
resp read_min frames=1 len=8 code=0x0108 status=0 0
resp read_max frames=1 len=8 code=0x0108 status=0 12
resp read_delay frames=1 len=8 code=0x0108 status=0 30
resp read_hold00 frames=1 len=8 code=0x0108 status=0 40000
resp read_trig00 frames=1 len=8 code=0x0108 status=0 40000
resp read_trig00_05 frames=1 len=28 code=0x0108 status=0 60000 30000 3000 2000 500 400

100а.5 Reception: the ring and the parsing state machine

At 256 000 baud a byte takes 39 µs. It can be received with an interrupt on every byte — as here — or by DMA into a ring (section 16б). The interrupt puts the byte into the ring, the main loop takes it out:

void ld2420_ring_put(ld2420_ring_t *r, uint8_t b)
{
    uint16_t h = r->head;
    if ((uint16_t)(h - r->tail) >= LD2420_RING) {   /* полно: байт теряется, счётчик это помнит */
        r->lost++;
        return;
    }
    r->buf[h & (LD2420_RING - 1u)] = b;
    r->head = (uint16_t)(h + 1u);                     /* сначала байт, потом индекс */
}
int ld2420_ring_get(ld2420_ring_t *r, uint8_t *b)
{
    uint16_t t = r->tail;
    if (t == r->head)
        return 0;
    *b = r->buf[t & (LD2420_RING - 1u)];
    r->tail = (uint16_t)(t + 1u);
    return 1;
}

The indices are 16-bit counters that grow forever; the difference head − tail gives the fill level even after wrapping through zero, and the cell number is the mask & 255. head is written only by the interrupt, tail only by the loop, so there is no need to disable interrupts. A ring of 256 bytes holds more than three of the longest responses (68 bytes of data — 78 bytes of frame) and fills up in 10 ms of continuous stream. If the loop did not keep up, the byte is lost, and the counter lost remembers it:

ring size=256 put=300 got=256 lost=44

Parsing is a state machine fed one byte at a time. Its state is how many bytes of the frame have already been collected (rawn): 0–3 — the header, 4–5 — the length, then the data, then the tail. The step itself:

/* Один байт. 0 — принят (в кадр или мимо), 1 — кадр не сложился: в raw лежит всё от его первого FD
 * до этого байта включительно, и это надо разобрать заново со второго байта. */
static int step(ld2420_parser_t *p, uint8_t c)
{
    uint16_t i = p->rawn;
    if (i == 0 && c != HDR[0]) {
        other(p, c);
        return 0;
    }
    p->raw[p->rawn++] = c;
    if (i < 4)
        return c != HDR[i];
    if (i == 4)
        return 0;
    if (i == 5) {
        p->len = (uint16_t)(p->raw[4] | p->raw[5] << 8);
        if (p->len < 2u || p->len > LD2420_MAX_DATA) {
            p->bad_len++;
            return 1;
        }
        return 0;
    }
    if (i < 6u + p->len)
        return 0;
    uint16_t k = (uint16_t)(i - 6u - p->len);
    if (c != TAIL[k]) {
        p->bad_tail++;
        return 1;
    }
    if (k < 3u)
        return 0;
    if (p->on_frame && !p->on_frame(p->ctx, &p->raw[6], p->len)) {
        p->rejected++;                  /* хвост совпал случайно: «кадр» начался с ложного заголовка */
        return 1;
    }
    p->frames++;
    p->rawn = 0;
    return 0;
}

The subtle part is what to do on a failure. The simplest is to drop what was collected and wait for the next FD. But that loses real frames. Suppose the garbage happens to contain a header with length 10, and right after it a real response arrives: the parser takes the start of the response for the data of the false frame, at the place of the tail it finds no tail — and if everything is dropped, the response is lost. So on a failure the first FD is declared garbage, and all the other bytes are parsed again:

/* Сбой: первый FD — мусор, остальное — в начало очереди. В заголовке нет повторяющихся байтов,
 * но внутри «кадра» может начинаться настоящий — его и находит повторный разбор. */
void ld2420_parse(ld2420_parser_t *p, uint8_t b)
{
    uint8_t q[LD2420_RAW_MAX];          /* непрочитанное + байты брошенного кадра: всего не больше RAW_MAX */
    uint16_t qi = 0, qn = 1;
    q[0] = b;
    while (qi < qn) {
        if (!step(p, q[qi++]))
            continue;
        uint16_t n = (uint16_t)(p->rawn - 1u), rest = (uint16_t)(qn - qi);
        memmove(&q[n], &q[qi], rest);
        memcpy(q, &p->raw[1], n);
        qi = 0;
        qn = (uint16_t)(n + rest);
        other(p, p->raw[0]);
        p->rawn = 0;
    }
}

All header bytes are different, so a shift by one byte skips nothing. The queue does not grow: each retry is one byte shorter than the previous one (the first FD goes to garbage), and RAW_MAX bytes are always enough.

The third case: the frame broke off, and nothing more comes: a false header with length 100, and then silence. The state machine would wait forever. For this ld2420_poll watches the pause: more than 10 ms without bytes in the middle of a frame — the frame is dropped and its tail is parsed again (ld2420_parser_flush). At 256 000 baud the longest frame the driver will accept — 138 bytes — takes 5.4 ms, so a real module does not make a 10 ms pause in the middle of a frame. This is an assumption: the documents are silent about pauses inside a frame.

The last and the most treacherous case: the length of a false header happened to lead exactly onto the tail of a real response. The tail 04 03 02 01 matched — the frame "came together", but its code is random bytes, and the real response is eaten. In the garbage experiment (100а.7) the driver without this check got this way 2 timeouts per 2000 operations (junk_lax). The cure is a check of meaning: the callback can say "this is not a frame", and then the parser does what it does on any failure — parses again from the second byte. The driver accepts only responses to known commands:

/* Ответ — это код известной команды с 0x01 в старшем байте и не меньше 4 байт. Всё прочее — не кадр:
 * ложный заголовок из мусора, длина которого случайно привела на хвост настоящего ответа. */
static int is_ack(uint16_t code, uint16_t len)
{
    uint16_t c = (uint16_t)(code & 0x00FFu);
    return (code & 0xFF00u) == LD2420_ACK && len >= 4u &&
           (c == LD2420_CMD_VERSION || c == LD2420_CMD_WRITE || c == LD2420_CMD_READ || c == LD2420_CMD_REBOOT ||
            c == LD2420_CMD_CLOSE || c == LD2420_CMD_OPEN);
}

The parser tests are short streams after which exactly one correct response must remain (the value 12, "far gate"):

unit whole frames=1 value=12 other=0 bad_len=0 bad_tail=0 rejected=0
unit garbage_before frames=1 value=12 other=7 bad_len=0 bad_tail=0 rejected=0
unit double_fd frames=1 value=12 other=1 bad_len=0 bad_tail=0 rejected=0
unit bad_len frames=1 value=12 other=6 bad_len=1 bad_tail=0 rejected=0
unit short_len frames=1 value=12 other=6 bad_len=1 bad_tail=0 rejected=0
unit fake_header frames=1 value=12 other=7 bad_len=0 bad_tail=1 rejected=0
unit cut_frame frames=1 value=12 other=9 bad_len=0 bad_tail=1 rejected=0
unit bad_tail frames=0 value=0 other=18 bad_len=0 bad_tail=1 rejected=0
unit long_fake_then_quiet frames=1 before_flush=0 value=12 other=6 bad_len=0 bad_tail=0 rejected=0
unit tail_alias_any frames=1 value=12 other=0 bad_len=0 bad_tail=0 rejected=0
unit tail_alias_strict frames=1 value=12 other=10 bad_len=0 bad_tail=0 rejected=1
Stream What is in it What came out
whole one frame the frame
garbage_before 7 bytes of garbage with header fragments, then a frame the frame, 7 bytes of garbage
double_fd an extra FD before the frame the frame, 1 byte of garbage
bad_len, short_len a header with length 32 767 or 1, then a frame the frame
fake_header a false header with length 10, then the frame right away the frame — found by re-parsing
cut_frame the start of a frame without its end, then a whole frame the frame
bad_tail a frame with a corrupted tail nothing, 18 bytes of garbage
long_fake_then_quiet a header with length 100, a frame and silence before the pause — nothing, after — the frame
tail_alias_any the length of a false header leads onto the frame's tail; accept everything a "frame" made of garbage, the real one lost
tail_alias_strict the same; accept only responses the real frame, the false one rejected

100а.6 Commands: timeout, retry and read-back

A command is "build a frame, send it, wait for a response with the command code | 0x0100". The response is found by the parser callback:

static int on_frame(void *ctx, const uint8_t *data, uint16_t len)
{
    ld2420_t *d = ctx;
    uint16_t code = (uint16_t)(data[0] | data[1] << 8);
    if (!is_ack(code, len))
        return 0;
    if (d->got || code != (d->want | LD2420_ACK)) {
        d->stray++;                     /* опоздавший ответ на брошенную команду */
        return 1;
    }
    memcpy(d->ack, data, len);
    d->ack_len = len;
    d->got = 1;
    return 1;
}

A response that arrives after the timeout is a late one, for the previous attempt: it is counted in stray and is not confused with the new one. The command itself is three attempts of 100 ms:

int ld2420_cmd(ld2420_t *d, uint16_t cmd, const uint8_t *par, uint16_t n, uint32_t tmo_ms)
{
    uint8_t f[LD2420_RAW_MAX];
    uint16_t fl = ld2420_build(f, cmd, par, n);
    if (!fl)
        return LD2420_EARG;
    for (unsigned t = 0; t < LD2420_TRIES; t++) {
        if (t)
            d->retries++;
        ld2420_poll(d);                 /* старое — до отправки, чтобы не принять его за ответ */
        d->want = cmd;
        d->got = 0;
        d->tx(f, fl);
        uint32_t t0 = d->now_ms();
        for (;;) {
            ld2420_poll(d);
            if (d->got || d->now_ms() - t0 >= tmo_ms)
                break;
            d->idle();
        }
        if (d->got) {
            d->status = (uint16_t)(d->ack[2] | d->ack[3] << 8);
            return d->status ? LD2420_ESTATUS : LD2420_OK;
        }
        d->timeouts++;
    }
    return LD2420_ETIMEOUT;
}

The response time of the module is not named in the documents; 100 ms is a choice with margin, not a measurement. Waiting does not spin the processor idle: idle on the board is __WFI(), on the computer it is a step of the model. If the module is silent, the command gives up after 300 ms:

silent rc=-1 timeouts=3 retries=2 ms=300

Reading and writing on top of ld2420_cmd are laying out names and values in bytes and checking the response length: for n names the response must be exactly 4 + 4·n bytes. A session with the model on a clean line:

session open=0 a=2 b=32
session version=0 s=v1.5.4
session read_general=0 min=0 max=12 delay=30
session read_trig16=0 ack_len=68 t0=60000 t5=400 t15=400
session close=0
session read_closed=-2 status=1

Outside command mode the model refuses (status 1). How a real module behaves, the description does not say; the driver opens the mode before reading and writing in any case.

There is no checksum in the frame. The header and the tail catch fragments, but not a flipped bit in a value: the module will accept a corrupted threshold and answer "success". So the configuration reads back everything it wrote, and on a mismatch or a refusal writes again — up to three times:

/* Контрольной суммы в кадре нет: испорченное значение модуль примет и ответит «успех».
 * Поэтому всё записанное читается обратно; не сошлось или модуль отказал (в запросе мог испортиться
 * байт имени) — записать всё ещё раз, до трёх проходов. Молчание модуля повтором не лечится. */
int ld2420_configure(ld2420_t *d, const ld2420_cfg_t *c)
{
    uint16_t names[NPAR];
    uint32_t vals[NPAR];
    uint16_t a, b;
    if (c->min_gate > c->max_gate || c->max_gate >= LD2420_GATES)
        return LD2420_EARG;
    cfg_list(c, names, vals);
    int r = ld2420_open(d, &a, &b);
    if (r)
        return r;
    for (unsigned pass = 0; pass < 3u; pass++) {
        r = write_all(d, names, vals);
        if (r == LD2420_OK)
            r = verify_all(d, names, vals);
        if (r == LD2420_OK || r == LD2420_ETIMEOUT)
            break;
    }
    int rc = ld2420_close(d);
    return r ? r : rc;
}

There are 35 configuration parameters: the near and far gates, the delay and 16 thresholds of each of the two kinds. They are written 6 per frame — as in the longest example of the description (what limit the module has is unknown), and read 16 at a time — the response to 16 names takes 68 bytes. That is 11 commands: open, 6 writes, 3 reads, close. The thresholds come from the noise table:

/* Руководство: порог срабатывания — больше 5 шумов ворот, порог удержания — от 2 до 5 шумов. */
void ld2420_thr_from_noise(const uint32_t *noise, uint32_t *trig, uint32_t *hold, uint32_t kt, uint32_t kh)
{
    for (uint16_t g = 0; g < LD2420_GATES; g++) {
        uint32_t t = noise[g] * kt, h = noise[g] * kh;
        trig[g] = t > 65535u ? 65535u : t;
        hold[g] = h > 65535u ? 65535u : h;
    }
}
configure=0 cmds=11 ms=18 trig0=2400 trig2=900 hold2=450 model_max=8 model_delay=5 cmd_mode=0
configure_bad_gate=-5

The noise of gate 0 is 400, of gate 2 — 150 (arbitrary units of the model): trigger 2400 and 900, hold — 450. The whole configuration took 18 ms on the model; on a real module the time depends on how fast it responds.


100а.7 A stream with garbage, losses and errors

How the driver copes with a bad line was tested on the model. The line carries 25 bytes per 1 ms (256 000 baud); every operation is a read of 1–16 random parameters or a write of 1–6, 2000 operations per experiment in total. The model knows what is really written in it, and every value read is compared with it. Garbage is random bytes, header fragments, "frames" without a tail and with a length above the allowed one; with a probability of 30 % it precedes a response.

fuzz clean ops=2000 ok=2000 timeout=0 status=0 short=0 wrong_read=0 wrong_write=0
fuzz clean retries=0 timeouts=0 stray=0 frames=2001 bad_len=0 bad_tail=0 rejected=0 flushed=0 other=0 junk=0 drops=0 flips=0 ms=3403 lost=0
fuzz junk ops=2000 ok=2000 timeout=0 status=0 short=0 wrong_read=0 wrong_write=0
fuzz junk retries=0 timeouts=0 stray=0 frames=2001 bad_len=157 bad_tail=46 rejected=4 flushed=114 other=11315 junk=11315 drops=0 flips=0 ms=5010 lost=0
fuzz junk_lax ops=2000 ok=2000 timeout=0 status=0 short=0 wrong_read=0 wrong_write=0
fuzz junk_lax retries=2 timeouts=2 stray=0 frames=2003 bad_len=158 bad_tail=44 rejected=0 flushed=118 other=11303 junk=11333 drops=0 flips=0 ms=5252 lost=0
fuzz drops ops=2000 ok=1985 timeout=15 status=0 short=0 wrong_read=0 wrong_write=0
fuzz drops retries=367 timeouts=382 stray=0 frames=1986 bad_len=179 bad_tail=156 rejected=0 flushed=122 other=18466 junk=12025 drops=325 flips=0 ms=43159 lost=0
fuzz flips ops=2000 ok=1969 timeout=1 status=30 short=0 wrong_read=41 wrong_write=8
fuzz flips retries=40 timeouts=41 stray=0 frames=2000 bad_len=1 bad_tail=6 rejected=6 flushed=1 other=548 junk=0 drops=0 flips=117 ms=7517 lost=0
Experiment Succeeded Refused Timed out Wrong values taken for correct ones
clean line 2000 0 0 0
garbage before 30 % of responses 2000 0 0 0
garbage + 0.2 % of bytes lost 1985 0 15 0
0.1 % of bytes with a flipped bit 1969 30 1 41 on reading, 8 on writing

The driver filters out garbage completely: 11 315 bytes of garbage — exactly 11 315 bytes in other, not a single timeout. Re-parsing found four frames whose tail matched by chance (rejected=4); without the is_ack check (junk_lax) the same 2000 operations gave 2 timeouts. Lost bytes are cured by retrying: 367 retries, and only 15 operations did not fit into three attempts. Losing a byte in a command costs more than in a response: the model waits for the missing byte and eats the start of the next attempt with it.

Bit errors show the price of a protocol without a checksum. A corrupted parameter name is rejected by the module (30 refusals), but a corrupted value gets through: 41 wrong values on reading and 8 writes after which the module holds something other than what was asked — and all of that with the status "success". The same line and the whole configuration, with read-back, 300 times:

cfg_flips runs=300 ok=280 verify_fail=6 status_fail=12 timeout=2 bad_after_ok=0 flips=282 retries=103 cmds=4136

280 configurations went through, 20 ended with an honest error, and not a single "successful" configuration after which something in the module is wrong. Read-back does not make the line reliable — it makes the error visible.


100а.8 The lamp: model, firmware and sizes

The lamp logic could not be simpler: OT1 at one — the light is on and the time is remembered; OT1 at zero for longer than its own hold time — the light goes out.

int lamp_step(lamp_t *l, uint32_t now_ms, int occupied)
{
    if (occupied) {
        l->last_seen = now_ms;
        if (!l->on) {
            l->on = 1;
            l->switches++;
        }
    } else if (l->on && now_ms - l->last_seen >= l->hold_ms) {
        l->on = 0;              /* разность беззнаковая: переход счётчика через ноль не страшен */
        l->switches++;
    }
    return l->on;
}

All the subtlety is in configuring the radar. The presence model works like this: 10 times per second every gate has noise of ±50 % around its mean; a target adds energy in its gate (distance / 0.7 m) and a quarter of it in the neighbouring ones. OT1 rises when the energy in at least one gate of the zone is above the trigger threshold, and drops when in none of them it is above the hold threshold for the whole delay in a row:

/* 100 мс работы радара. Модель: шум ворот ±50 %, цель — в воротах cm/70 и по четверти в соседних.
 * Нет человека → есть: энергия выше порога срабатывания хоть в одних воротах от min до max.
 * Есть → нет: ни в одних не выше порога удержания delay секунд подряд. */
int model_tick(model_t *m, const m_target_t *t, int n)
{
    for (int g = 0; g < 16; g++)
        m->energy[g] = m->noise[g] * (50u + model_rand(m) % 101u) / 100u;
    for (int i = 0; i < n; i++) {
        int g = t[i].cm / 70;
        if (g > 15)
            continue;
        m->energy[g] += t[i].amp * (80u + model_rand(m) % 41u) / 100u;
        if (g > 0)
            m->energy[g - 1] += t[i].amp / 4u;
        if (g < 15)
            m->energy[g + 1] += t[i].amp / 4u;
    }
    int trig = 0, hold = 0;
    for (uint32_t g = m->min_gate; g <= m->max_gate && g < 16; g++) {
        trig |= m->energy[g] > m->trig[g];
        hold |= m->energy[g] > m->hold[g];
    }
    if (!m->ot1) {
        if (trig)
            m->ot1 = 1, m->quiet = 0;
    } else if (hold) {
        m->quiet = 0;
    } else if (++m->quiet >= m->delay * (1000u / M_TICK_MS)) {
        m->ot1 = 0;
    }
    return m->ot1;
}

The energies in the model are arbitrary: a walking person is 6000, a breathing one is 500, a fan is 2000, the noise is 150 (for gates 0 and 1 — 400 and 300). This is not a measurement of a real module but a scenario on which the difference between settings is visible. The room lasts 300 s: a fan at 7.5 m swings all the time; a person comes in at second 30 and walks from 6 m to 2 m, from second 40 to 160 sits still, then leaves and disappears at second 165. The radar delay is 5 (in the model — seconds), the lamp hold time is 10 s.

lamp gates0-8 configure=0 max_gate=8 trig=900 hold=450
lamp gates0-8 t=30000 on=1
lamp gates0-8 t=179800 on=0
lamp gates0-8 switches=2 on_present_s=135 on_absent_s=14 ot1_s=139 present_s=135
lamp gates0-12 configure=0 max_gate=12 trig=900 hold=450
lamp gates0-12 t=0 on=1
lamp gates0-12 switches=1 on_present_s=135 on_absent_s=165 ot1_s=300 present_s=135
lamp no_hysteresis configure=0 max_gate=8 trig=900 hold=900
lamp no_hysteresis t=30000 on=1
lamp no_hysteresis t=54800 on=0
lamp no_hysteresis t=160000 on=1
lamp no_hysteresis t=179800 on=0
lamp no_hysteresis switches=4 on_present_s=29 on_absent_s=14 ot1_s=24 present_s=135
Setting Light on Light off On while the person was there On without the person
gates 0…8, trigger 6 × noise, hold 3 × 30.0 s 179.8 s 135 of 135 s 14 s
gates 0…12 (factory far gate) 0 s never off 135 of 135 s 165 s
gates 0…8, hold = trigger 30.0 s 54.8 s and again 179.8 s 29 of 135 s 14 s

The first row is as intended: the light came on as soon as the person came in, stayed on the whole time they sat, and went out 5 + 10 s after they left. In the second the zone caught the fan, and the light is on in the empty room all the time. The third has no hysteresis: breathing does not reach the trigger threshold, OT1 drops 5 s after the person sat down, and the light 10 s later; the person sits in the dark until they get up.

The firmware is the same logic on the board. The USART receives into the ring by interrupt:

void UART_IRQHandler(void)
{
    uint32_t sr = UART->SR;
    if (sr & (USART_SR_RXNE | USART_SR_ORE)) {      /* чтение SR, затем DR снимает и RXNE, и ORE */
        uint8_t b = (uint8_t)UART->DR;
        if (sr & USART_SR_ORE)
            uart_ore++;
        ld2420_ring_put(&rx, b);
    }
}

The baud rate divider is the bus frequency over 256 000, rounded. On F103 USART1 is on the 72 MHz bus: 281, which is 256 228 baud (+0.09 %). On F407 USART2 is on the 42 MHz bus: 164, which is 256 098 baud (+0.04 %). Both numbers are checked by _Static_assert at build time. The main loop:

int main(void)
{
    clock_init();
    SysTick->LOAD = HCLK / 1000u - 1u;
    SysTick->VAL = 0;
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk;
    pins_init();
    uart_init();
    ld2420_init(&radar, &rx, uart_tx, now_ms, idle);
    radar.on_other = count_other;

    while (ms < 1000u)                  /* сколько модуль стартует, в документах нет: ждём секунду */
        __WFI();
    static ld2420_cfg_t c;              /* static: 132 байта не на стеке; поля — здесь, а не в .data */
    c.min_gate = 0;
    c.max_gate = 8;                     /* ворота 0…8: до 6,3 м */
    c.delay = 5;
    ld2420_thr_from_noise(NOISE, c.trig, c.hold, 6, 3);     /* срабатывание — 6 шумов, удержание — 3 */
    cfg_rc = ld2420_configure(&radar, &c);  /* не вышло — модуль работает со своими настройками */

    lamp_init(&lamp, 10000u);
    uint32_t next = ms;
    for (;;) {
        ld2420_poll(&radar);            /* отчёты обычного режима уходят в count_other */
        if ((int32_t)(ms - next) >= 0) {
            next += 100u;
            light(lamp_step(&lamp, ms, ot1()));
        }
        __WFI();
    }
}

The one-second wait before configuring is a choice, not a number from the document: how long the module takes to start is not stated there. If the configuration failed, the module works with whatever is written in it, and the light still follows OT1.

Build with arm-none-eabi-gcc 12.2.1, -Os:

   text    data     bss     dec     hex filename
   1820       0       0    1820     71c ld2420_F103.o
     52       0       0      52      34 lamp_F103.o
    882       4     780    1666     682 fw100a_F103.o
   3368       4     780    4152    1038 fw_F103.elf
F103 F407
driver ld2420.o 1820 bytes 1812 bytes
lamp lamp.o 52 bytes 52 bytes
whole firmware: code and constants 3368 bytes 3456 bytes
RAM: .data + .bss 4 + 780 bytes 4 + 780 bytes
of which driver / ring / lamp 356 / 264 / 16 bytes 356 / 264 / 16 bytes
stack of the deepest chain 960 bytes 960 bytes

The stack goes deepest during configuration: ld2420_configure (320 bytes by -fstack-usage) → write (72) → command (176) → ld2420_poll (24) → dropping a broken frame (168) → parsing (184) → callback (16). Most of it is taken by frame copies on the stack — the price of re-parsing.

The same run — test100a.c with the model — was built for Cortex-M4 and run in QEMU (the netduinoplus2 machine, output via semihosting): all 74 lines of output matched the run on the computer. The lamp firmware was not run in QEMU: there is no module at the other end of its UART.


100а.9 Pitfalls

The module is silent. The lines are swapped: OT2 is the radar's TX, it goes to the microcontroller's RX. Or the baud rate is not 256 000.

Reading and writing do not work, but the version reads. Command mode is not open. The model refuses outside the mode; what a real module does, the description does not say, so the driver always opens the mode before configuring and closes it afterwards.

The write example from the description does not go through. The frame for writing thresholds 00…05 in the description is 2 bytes shorter than the correct one (length 24 00 instead of 26 00). Build frames with a function, do not copy them from the document.

The threshold reads as 40 000, while the text says 60 000. An error in the example: 40 9C 00 00 is 40 000. Least significant byte first.

The light is on in an empty room. Something extraneous swings in the zone: a fan, a curtain, a plant under an air conditioner. Narrow the far gate; in the model with gates 0…12 the fan at 7.5 m kept the light on for 165 s out of 165.

The light goes out while the person is sitting. The hold threshold is too high — breathing cannot get over it. Hold — 2–5 times the noise, trigger — more than 5; without this gap the light in the model was on for 29 s out of 135.

False triggering from what is behind the radar. The back lobe of the antenna: the radar sees behind itself too. A metal shield behind the module.

Noise from the supply. Peaks in the supply spectrum up to 100 kHz interfere with the receiver. A filter, a separate regulator, a short ground wire.

A value was written "successfully", but the module holds another one. There is no checksum in the frame: a flipped bit in a value gets through. Read back; in the experiment with errors, without read-back 8 writes out of 2000 left the module with something other than what was asked.

The response "got lost" in the garbage. A false header ate the start of the real frame. Do not drop what was collected on a failure but parse again from the second byte; accept only responses to known commands.

The parser hung in the middle of a frame. A byte was lost, and the state machine waits for the missing ones. A pause longer than 10 ms in the middle of a frame — drop the frame and parse its tail.

The module's report in normal mode is not parsed. Its format is not described in these documents. Presence comes from OT1; the driver hands the report bytes to on_other as they are.

The radar does not measure the distance to a motionless person. The manual says it plainly: distance is output only for a moving target within 8 m.


100а.10 Summary


Full sources

The driver, the lamp logic, the module model, the run, the firmware for F103 and F407 and the QEMU harness — everything needed to repeat the experiments of the section.

ld2420.h — the driver: protocol constants, the ring, the parser, commands, configuration.

/* Раздел 100а: драйвер радара HLK-LD2420 — кадры команд по описанию протокола Hi-Link.
 * Кадр: FD FC FB FA | длина (2 байта, младший первым) | данные | 04 03 02 01.
 * Данные команды: код (2 байта) + параметры; данные ответа: код | 0x0100, состояние (0 — успех), значения.
 * Контрольной суммы в кадре нет. Формат отчётов в обычном режиме в документах не описан: всё, что не
 * сложилось в кадр, драйвер отдаёт как есть обратному вызову on_other. */
#ifndef LD2420_H
#define LD2420_H
#include <stdint.h>

#define LD2420_GATES      16u           /* ворота 0…15, шаг 0,7 м (руководство) */
#define LD2420_GATE_CM    70u
#define LD2420_MAX_DATA   128u          /* своё ограничение: самый длинный ответ здесь — 68 байт */
#define LD2420_RAW_MAX    (4u + 2u + LD2420_MAX_DATA + 4u)
#define LD2420_WRITE_CHUNK 6u           /* параметров в одной записи — как в примере производителя */
#define LD2420_GAP_MS     10u           /* пауза посреди кадра дольше этой — кадр брошен */
#define LD2420_TMO_MS     100u          /* ждать ответ; время ответа модуля в документах не названо */
#define LD2420_TRIES      3u

/* коды команд */
#define LD2420_CMD_VERSION 0x0000u
#define LD2420_CMD_WRITE   0x0007u
#define LD2420_CMD_READ    0x0008u
#define LD2420_CMD_REBOOT  0x0068u
#define LD2420_CMD_CLOSE   0x00FEu
#define LD2420_CMD_OPEN    0x00FFu
#define LD2420_ACK         0x0100u      /* ответ = код команды | 0x0100: 08 00 → 08 01 */

/* имена параметров */
#define LD2420_P_MIN_GATE  0x0000u
#define LD2420_P_MAX_GATE  0x0001u
#define LD2420_P_DELAY     0x0004u
#define LD2420_P_TRIG(g)   (0x0010u + (g))
#define LD2420_P_HOLD(g)   (0x0020u + (g))

enum { LD2420_OK = 0, LD2420_ETIMEOUT = -1, LD2420_ESTATUS = -2, LD2420_ESHORT = -3, LD2420_EVERIFY = -4, LD2420_EARG = -5 };

/* ── кольцо приёма: пишет прерывание UART, читает основной цикл ── */
#define LD2420_RING 256u                /* степень двойки: индексы по маске */
typedef struct {
    uint8_t buf[LD2420_RING];
    volatile uint16_t head, tail;       /* head пишет только прерывание, tail — только цикл */
    volatile uint32_t lost;             /* байты, которым не нашлось места */
} ld2420_ring_t;

void ld2420_ring_put(ld2420_ring_t *r, uint8_t b);
int ld2420_ring_get(ld2420_ring_t *r, uint8_t *b);

/* ── разбор потока ── */
typedef struct {
    uint8_t raw[LD2420_RAW_MAX];        /* байты текущего кадра от первого FD: при сбое их разбирают заново */
    uint16_t rawn, len;
    int (*on_frame)(void *ctx, const uint8_t *data, uint16_t len);   /* 0 — «не кадр»: разобрать заново */
    void (*on_other)(void *ctx, uint8_t b);
    void *ctx;
    uint32_t frames, bad_len, bad_tail, rejected, other, flushed;
} ld2420_parser_t;

void ld2420_parser_init(ld2420_parser_t *p, int (*on_frame)(void *, const uint8_t *, uint16_t),
                        void (*on_other)(void *, uint8_t), void *ctx);
void ld2420_parse(ld2420_parser_t *p, uint8_t b);
void ld2420_parser_flush(ld2420_parser_t *p);   /* долгая пауза посреди кадра: бросить его и разобрать хвост */

uint16_t ld2420_build(uint8_t *out, uint16_t cmd, const uint8_t *par, uint16_t n);

/* ── команды ── */
typedef struct {
    ld2420_ring_t *rx;
    ld2420_parser_t p;
    void (*tx)(const uint8_t *b, uint16_t n);
    uint32_t (*now_ms)(void);
    void (*idle)(void);                 /* ждём байты: __WFI() на плате, шаг модели на компьютере */
    void (*on_other)(void *ctx, uint8_t b);
    void *other_ctx;
    uint32_t last_ms;                   /* когда пришёл последний байт */
    uint16_t want;                      /* какой ответ ждём */
    uint8_t got;
    uint16_t ack_len;
    uint8_t ack[LD2420_MAX_DATA];
    uint16_t status;                    /* состояние из последнего ответа */
    uint32_t timeouts, retries, stray;  /* чужие ответы — от прошлой команды, которую не дождались */
} ld2420_t;

void ld2420_init(ld2420_t *d, ld2420_ring_t *rx, void (*tx)(const uint8_t *, uint16_t), uint32_t (*now_ms)(void),
                 void (*idle)(void));
void ld2420_poll(ld2420_t *d);          /* разобрать всё, что накопилось в кольце */
int ld2420_cmd(ld2420_t *d, uint16_t cmd, const uint8_t *par, uint16_t n, uint32_t tmo_ms);

int ld2420_open(ld2420_t *d, uint16_t *a, uint16_t *b);
int ld2420_close(ld2420_t *d);
int ld2420_version(ld2420_t *d, char *s, uint16_t size);
int ld2420_reboot(ld2420_t *d);
int ld2420_read(ld2420_t *d, const uint16_t *names, uint16_t n, uint32_t *vals);
int ld2420_write(ld2420_t *d, const uint16_t *names, const uint32_t *vals, uint16_t n);

typedef struct {
    uint8_t min_gate, max_gate;
    uint16_t delay;                     /* единица в документах не названа */
    uint32_t trig[LD2420_GATES], hold[LD2420_GATES];
} ld2420_cfg_t;

int ld2420_configure(ld2420_t *d, const ld2420_cfg_t *c);   /* открыть, записать, прочитать обратно, закрыть */
void ld2420_thr_from_noise(const uint32_t *noise, uint32_t *trig, uint32_t *hold, uint32_t kt, uint32_t kh);
#endif

ld2420.c — the driver: the receive ring, the parsing state machine with re-parsing, commands with timeout and retry, configuration with read-back.

/* Раздел 100а: драйвер HLK-LD2420 — кольцо приёма, разбор кадров с повторным разбором при сбое,
 * команды с тайм-аутом и повтором, настройка порогов с проверкой обратным чтением. */
#include <string.h>
#include "ld2420.h"

static const uint8_t HDR[4] = {0xFD, 0xFC, 0xFB, 0xFA};
static const uint8_t TAIL[4] = {0x04, 0x03, 0x02, 0x01};

/* ── кольцо ── */
void ld2420_ring_put(ld2420_ring_t *r, uint8_t b)
{
    uint16_t h = r->head;
    if ((uint16_t)(h - r->tail) >= LD2420_RING) {   /* полно: байт теряется, счётчик это помнит */
        r->lost++;
        return;
    }
    r->buf[h & (LD2420_RING - 1u)] = b;
    r->head = (uint16_t)(h + 1u);                     /* сначала байт, потом индекс */
}

int ld2420_ring_get(ld2420_ring_t *r, uint8_t *b)
{
    uint16_t t = r->tail;
    if (t == r->head)
        return 0;
    *b = r->buf[t & (LD2420_RING - 1u)];
    r->tail = (uint16_t)(t + 1u);
    return 1;
}

/* ── разбор ── */
void ld2420_parser_init(ld2420_parser_t *p, int (*on_frame)(void *, const uint8_t *, uint16_t),
                        void (*on_other)(void *, uint8_t), void *ctx)
{
    memset(p, 0, sizeof *p);
    p->on_frame = on_frame;
    p->on_other = on_other;
    p->ctx = ctx;
}

static void other(ld2420_parser_t *p, uint8_t b)
{
    p->other++;
    if (p->on_other)
        p->on_other(p->ctx, b);
}

/* Один байт. 0 — принят (в кадр или мимо), 1 — кадр не сложился: в raw лежит всё от его первого FD
 * до этого байта включительно, и это надо разобрать заново со второго байта. */
static int step(ld2420_parser_t *p, uint8_t c)
{
    uint16_t i = p->rawn;
    if (i == 0 && c != HDR[0]) {
        other(p, c);
        return 0;
    }
    p->raw[p->rawn++] = c;
    if (i < 4)
        return c != HDR[i];
    if (i == 4)
        return 0;
    if (i == 5) {
        p->len = (uint16_t)(p->raw[4] | p->raw[5] << 8);
        if (p->len < 2u || p->len > LD2420_MAX_DATA) {
            p->bad_len++;
            return 1;
        }
        return 0;
    }
    if (i < 6u + p->len)
        return 0;
    uint16_t k = (uint16_t)(i - 6u - p->len);
    if (c != TAIL[k]) {
        p->bad_tail++;
        return 1;
    }
    if (k < 3u)
        return 0;
    if (p->on_frame && !p->on_frame(p->ctx, &p->raw[6], p->len)) {
        p->rejected++;                  /* хвост совпал случайно: «кадр» начался с ложного заголовка */
        return 1;
    }
    p->frames++;
    p->rawn = 0;
    return 0;
}

/* Сбой: первый FD — мусор, остальное — в начало очереди. В заголовке нет повторяющихся байтов,
 * но внутри «кадра» может начинаться настоящий — его и находит повторный разбор. */
void ld2420_parse(ld2420_parser_t *p, uint8_t b)
{
    uint8_t q[LD2420_RAW_MAX];          /* непрочитанное + байты брошенного кадра: всего не больше RAW_MAX */
    uint16_t qi = 0, qn = 1;
    q[0] = b;
    while (qi < qn) {
        if (!step(p, q[qi++]))
            continue;
        uint16_t n = (uint16_t)(p->rawn - 1u), rest = (uint16_t)(qn - qi);
        memmove(&q[n], &q[qi], rest);
        memcpy(q, &p->raw[1], n);
        qi = 0;
        qn = (uint16_t)(n + rest);
        other(p, p->raw[0]);
        p->rawn = 0;
    }
}

void ld2420_parser_flush(ld2420_parser_t *p)
{
    uint8_t t[LD2420_RAW_MAX];
    uint16_t n = p->rawn;
    if (!n)
        return;
    memcpy(t, p->raw, n);
    p->rawn = 0;
    p->flushed++;
    other(p, t[0]);
    for (uint16_t i = 1; i < n; i++)
        ld2420_parse(p, t[i]);
}

uint16_t ld2420_build(uint8_t *out, uint16_t cmd, const uint8_t *par, uint16_t n)
{
    uint16_t len = (uint16_t)(2u + n);
    if (len > LD2420_MAX_DATA)
        return 0;
    memcpy(out, HDR, 4);
    out[4] = (uint8_t)len;
    out[5] = (uint8_t)(len >> 8);
    out[6] = (uint8_t)cmd;
    out[7] = (uint8_t)(cmd >> 8);
    if (n)
        memcpy(&out[8], par, n);
    memcpy(&out[8 + n], TAIL, 4);
    return (uint16_t)(len + 10u);
}

/* ── команды ── */
/* Ответ — это код известной команды с 0x01 в старшем байте и не меньше 4 байт. Всё прочее — не кадр:
 * ложный заголовок из мусора, длина которого случайно привела на хвост настоящего ответа. */
static int is_ack(uint16_t code, uint16_t len)
{
    uint16_t c = (uint16_t)(code & 0x00FFu);
    return (code & 0xFF00u) == LD2420_ACK && len >= 4u &&
           (c == LD2420_CMD_VERSION || c == LD2420_CMD_WRITE || c == LD2420_CMD_READ || c == LD2420_CMD_REBOOT ||
            c == LD2420_CMD_CLOSE || c == LD2420_CMD_OPEN);
}

static int on_frame(void *ctx, const uint8_t *data, uint16_t len)
{
    ld2420_t *d = ctx;
    uint16_t code = (uint16_t)(data[0] | data[1] << 8);
    if (!is_ack(code, len))
        return 0;
    if (d->got || code != (d->want | LD2420_ACK)) {
        d->stray++;                     /* опоздавший ответ на брошенную команду */
        return 1;
    }
    memcpy(d->ack, data, len);
    d->ack_len = len;
    d->got = 1;
    return 1;
}

static void on_other(void *ctx, uint8_t b)
{
    ld2420_t *d = ctx;
    if (d->on_other)
        d->on_other(d->other_ctx, b);
}

void ld2420_init(ld2420_t *d, ld2420_ring_t *rx, void (*tx)(const uint8_t *, uint16_t), uint32_t (*now_ms)(void),
                 void (*idle)(void))
{
    memset(d, 0, sizeof *d);
    d->rx = rx;
    d->tx = tx;
    d->now_ms = now_ms;
    d->idle = idle;
    ld2420_parser_init(&d->p, on_frame, on_other, d);
}

void ld2420_poll(ld2420_t *d)
{
    uint8_t b;
    uint32_t now = d->now_ms();
    while (ld2420_ring_get(d->rx, &b)) {
        d->last_ms = now;
        ld2420_parse(&d->p, b);
    }
    if (d->p.rawn && now - d->last_ms >= LD2420_GAP_MS)
        ld2420_parser_flush(&d->p);     /* кадр оборвался: байты потеряны, ждать его нечего */
}

int ld2420_cmd(ld2420_t *d, uint16_t cmd, const uint8_t *par, uint16_t n, uint32_t tmo_ms)
{
    uint8_t f[LD2420_RAW_MAX];
    uint16_t fl = ld2420_build(f, cmd, par, n);
    if (!fl)
        return LD2420_EARG;
    for (unsigned t = 0; t < LD2420_TRIES; t++) {
        if (t)
            d->retries++;
        ld2420_poll(d);                 /* старое — до отправки, чтобы не принять его за ответ */
        d->want = cmd;
        d->got = 0;
        d->tx(f, fl);
        uint32_t t0 = d->now_ms();
        for (;;) {
            ld2420_poll(d);
            if (d->got || d->now_ms() - t0 >= tmo_ms)
                break;
            d->idle();
        }
        if (d->got) {
            d->status = (uint16_t)(d->ack[2] | d->ack[3] << 8);
            return d->status ? LD2420_ESTATUS : LD2420_OK;
        }
        d->timeouts++;
    }
    return LD2420_ETIMEOUT;
}

static uint16_t rd16(const uint8_t *p) { return (uint16_t)(p[0] | p[1] << 8); }
static uint32_t rd32(const uint8_t *p) { return p[0] | (uint32_t)p[1] << 8 | (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24; }

int ld2420_open(ld2420_t *d, uint16_t *a, uint16_t *b)
{
    static const uint8_t par[2] = {0x01, 0x00};    /* «2-byte upper version, default 01» */
    int r = ld2420_cmd(d, LD2420_CMD_OPEN, par, 2, LD2420_TMO_MS);
    if (r)
        return r;
    if (d->ack_len < 8u)
        return LD2420_ESHORT;
    *a = rd16(&d->ack[4]);              /* в примере 02 00 и 20 00; что это, документ не говорит */
    *b = rd16(&d->ack[6]);
    return LD2420_OK;
}

int ld2420_close(ld2420_t *d)
{
    return ld2420_cmd(d, LD2420_CMD_CLOSE, 0, 0, LD2420_TMO_MS);
}

int ld2420_version(ld2420_t *d, char *s, uint16_t size)
{
    int r = ld2420_cmd(d, LD2420_CMD_VERSION, 0, 0, LD2420_TMO_MS);
    if (r)
        return r;
    if (d->ack_len < 6u || d->ack_len < 6u + rd16(&d->ack[4]))
        return LD2420_ESHORT;
    uint16_t n = rd16(&d->ack[4]);
    if (n > size - 1u)
        n = (uint16_t)(size - 1u);
    memcpy(s, &d->ack[6], n);
    s[n] = 0;
    return LD2420_OK;
}

int ld2420_reboot(ld2420_t *d)
{
    uint8_t f[12];                      /* кадр без параметров — 12 байт */
    d->tx(f, ld2420_build(f, LD2420_CMD_REBOOT, 0, 0));   /* ответа в описании нет — не ждём */
    return LD2420_OK;
}

int ld2420_read(ld2420_t *d, const uint16_t *names, uint16_t n, uint32_t *vals)
{
    uint8_t par[2 * LD2420_GATES];
    if (n == 0 || n > LD2420_GATES)
        return LD2420_EARG;
    for (uint16_t i = 0; i < n; i++) {
        par[2 * i] = (uint8_t)names[i];
        par[2 * i + 1] = (uint8_t)(names[i] >> 8);
    }
    int r = ld2420_cmd(d, LD2420_CMD_READ, par, (uint16_t)(2u * n), LD2420_TMO_MS);
    if (r)
        return r;
    if (d->ack_len != 4u + 4u * n)
        return LD2420_ESHORT;
    for (uint16_t i = 0; i < n; i++)
        vals[i] = rd32(&d->ack[4 + 4 * i]);
    return LD2420_OK;
}

int ld2420_write(ld2420_t *d, const uint16_t *names, const uint32_t *vals, uint16_t n)
{
    uint8_t par[6 * LD2420_WRITE_CHUNK];
    if (n == 0 || n > LD2420_WRITE_CHUNK)
        return LD2420_EARG;
    for (uint16_t i = 0; i < n; i++) {
        uint8_t *q = &par[6 * i];
        q[0] = (uint8_t)names[i];
        q[1] = (uint8_t)(names[i] >> 8);
        q[2] = (uint8_t)vals[i];
        q[3] = (uint8_t)(vals[i] >> 8);
        q[4] = (uint8_t)(vals[i] >> 16);
        q[5] = (uint8_t)(vals[i] >> 24);
    }
    return ld2420_cmd(d, LD2420_CMD_WRITE, par, (uint16_t)(6u * n), LD2420_TMO_MS);
}

/* все 35 параметров настройки: 3 общих, 16 порогов срабатывания, 16 порогов удержания */
#define NPAR (3u + 2u * LD2420_GATES)

static void cfg_list(const ld2420_cfg_t *c, uint16_t *names, uint32_t *vals)
{
    names[0] = LD2420_P_MIN_GATE, vals[0] = c->min_gate;
    names[1] = LD2420_P_MAX_GATE, vals[1] = c->max_gate;
    names[2] = LD2420_P_DELAY, vals[2] = c->delay;
    for (uint16_t g = 0; g < LD2420_GATES; g++) {
        names[3 + g] = (uint16_t)LD2420_P_TRIG(g), vals[3 + g] = c->trig[g];
        names[3 + LD2420_GATES + g] = (uint16_t)LD2420_P_HOLD(g), vals[3 + LD2420_GATES + g] = c->hold[g];
    }
}

static int write_all(ld2420_t *d, const uint16_t *names, const uint32_t *vals)
{
    for (uint16_t i = 0; i < NPAR; i = (uint16_t)(i + LD2420_WRITE_CHUNK)) {
        uint16_t k = (uint16_t)(NPAR - i < LD2420_WRITE_CHUNK ? NPAR - i : LD2420_WRITE_CHUNK);
        int r = ld2420_write(d, &names[i], &vals[i], k);
        if (r)
            return r;
    }
    return LD2420_OK;
}

static int verify_all(ld2420_t *d, const uint16_t *names, const uint32_t *vals)
{
    uint32_t got[LD2420_GATES];
    for (uint16_t i = 0; i < NPAR; i = (uint16_t)(i + LD2420_GATES)) {
        uint16_t k = (uint16_t)(NPAR - i < LD2420_GATES ? NPAR - i : LD2420_GATES);
        int r = ld2420_read(d, &names[i], k, got);
        if (r)
            return r;
        if (memcmp(got, &vals[i], 4u * k))
            return LD2420_EVERIFY;
    }
    return LD2420_OK;
}

/* Контрольной суммы в кадре нет: испорченное значение модуль примет и ответит «успех».
 * Поэтому всё записанное читается обратно; не сошлось или модуль отказал (в запросе мог испортиться
 * байт имени) — записать всё ещё раз, до трёх проходов. Молчание модуля повтором не лечится. */
int ld2420_configure(ld2420_t *d, const ld2420_cfg_t *c)
{
    uint16_t names[NPAR];
    uint32_t vals[NPAR];
    uint16_t a, b;
    if (c->min_gate > c->max_gate || c->max_gate >= LD2420_GATES)
        return LD2420_EARG;
    cfg_list(c, names, vals);
    int r = ld2420_open(d, &a, &b);
    if (r)
        return r;
    for (unsigned pass = 0; pass < 3u; pass++) {
        r = write_all(d, names, vals);
        if (r == LD2420_OK)
            r = verify_all(d, names, vals);
        if (r == LD2420_OK || r == LD2420_ETIMEOUT)
            break;
    }
    int rc = ld2420_close(d);
    return r ? r : rc;
}

/* Руководство: порог срабатывания — больше 5 шумов ворот, порог удержания — от 2 до 5 шумов. */
void ld2420_thr_from_noise(const uint32_t *noise, uint32_t *trig, uint32_t *hold, uint32_t kt, uint32_t kh)
{
    for (uint16_t g = 0; g < LD2420_GATES; g++) {
        uint32_t t = noise[g] * kt, h = noise[g] * kh;
        trig[g] = t > 65535u ? 65535u : t;
        hold[g] = h > 65535u ? 65535u : h;
    }
}

lamp.h — the lamp driven by OT1: the structure and the calls.

/* Раздел 100а: светильник по присутствию. Вход — уровень OT1 радара (единица — «есть человек»),
 * выход — свет. Включить сразу, выключить, когда OT1 в нуле дольше своей выдержки hold_ms. */
#ifndef LAMP_H
#define LAMP_H
#include <stdint.h>

typedef struct {
    uint32_t hold_ms;       /* своя выдержка поверх задержки радара */
    uint32_t last_seen;     /* когда OT1 последний раз был в единице */
    uint8_t on;
    uint32_t switches;      /* сколько раз переключался */
} lamp_t;

void lamp_init(lamp_t *l, uint32_t hold_ms);
int lamp_step(lamp_t *l, uint32_t now_ms, int occupied);
#endif

lamp.c — the lamp: switch on at once, switch off after the hold time.

/* Раздел 100а: светильник по присутствию — логика без железа, её гоняет модель. */
#include "lamp.h"

void lamp_init(lamp_t *l, uint32_t hold_ms)
{
    l->hold_ms = hold_ms;
    l->last_seen = 0;
    l->on = 0;
    l->switches = 0;
}

int lamp_step(lamp_t *l, uint32_t now_ms, int occupied)
{
    if (occupied) {
        l->last_seen = now_ms;
        if (!l->on) {
            l->on = 1;
            l->switches++;
        }
    } else if (l->on && now_ms - l->last_seen >= l->hold_ms) {
        l->on = 0;              /* разность беззнаковая: переход счётчика через ноль не страшен */
        l->switches++;
    }
    return l->on;
}

fw100a.c — the lamp firmware for F103 and F407: clocks, pins, USART at 256 000 baud receiving into the ring, radar configuration, the main loop, the vector table.

/* Раздел 100а: светильник с радаром HLK-LD2420 — прошивка для STM32F103 (-DCHIP_F103) и STM32F407 (-DCHIP_F407).
 *   F103: USART1, TX PA9 → RX радара, RX PA10 ← OT2 (TX радара); OT1 → PA0; свет — PC13 (светодиод BluePill, ноль — горит).
 *   F407: USART2, TX PA2, RX PA3 (AF7); OT1 → PA0; свет — PD12.
 * 256 000 бод, 8N1. После старта прошивка задаёт ворота и пороги по таблице шума и дальше
 * смотрит только на OT1: свет включается сразу, гаснет через 10 с после того, как OT1 упал. */
#if defined(CHIP_F103)
#include "stm32f1xx.h"
#define UART USART1
#define UART_IRQn USART1_IRQn
#define UART_IRQHandler USART1_IRQHandler
#define HCLK 72000000u
#define UART_CLK 72000000u              /* USART1 — на APB2 */
#elif defined(CHIP_F407)
#include "stm32f4xx.h"
#define UART USART2
#define UART_IRQn USART2_IRQn
#define UART_IRQHandler USART2_IRQHandler
#define HCLK 168000000u
#define UART_CLK 42000000u              /* USART2 — на APB1 = HCLK/4 */
#else
#error "CHIP_F103 или CHIP_F407"
#endif
#include "ld2420.h"
#include "lamp.h"

#define BAUD 256000u                    /* руководство: скорость порта 256000 */
#define BRR_VAL ((UART_CLK + BAUD / 2u) / BAUD)
_Static_assert(USART_SR_RXNE == 0x20u && USART_SR_ORE == 0x08u && USART_SR_TXE == 0x80u, "биты SR");
_Static_assert(BRR_VAL == (UART_CLK == 72000000u ? 281u : 164u), "делитель: 72 МГц/281, 42 МГц/164");

/* Шум ворот — из сканирования шума программой производителя в пустой комнате (здесь — для примера). */
static const uint32_t NOISE[LD2420_GATES] = {400, 300, 150, 150, 150, 150, 150, 150,
                                             150, 150, 150, 150, 150, 150, 150, 150};

static volatile uint32_t ms;
static ld2420_ring_t rx;
static ld2420_t radar;
static lamp_t lamp;
volatile uint32_t uart_ore, other_bytes;
volatile int cfg_rc = 1;

void SysTick_Handler(void) { ms++; }

void UART_IRQHandler(void)
{
    uint32_t sr = UART->SR;
    if (sr & (USART_SR_RXNE | USART_SR_ORE)) {      /* чтение SR, затем DR снимает и RXNE, и ORE */
        uint8_t b = (uint8_t)UART->DR;
        if (sr & USART_SR_ORE)
            uart_ore++;
        ld2420_ring_put(&rx, b);
    }
}

static void uart_tx(const uint8_t *p, uint16_t n)
{
    for (uint16_t i = 0; i < n; i++) {
        while (!(UART->SR & USART_SR_TXE)) {
        }
        UART->DR = p[i];
    }
}

static uint32_t now_ms(void) { return ms; }
static void idle(void) { __WFI(); }
static void count_other(void *ctx, uint8_t b) { (void)ctx; (void)b; other_bytes++; }

#if defined(CHIP_F103)
static void clock_init(void)                    /* HSE 8 МГц × 9 = 72 МГц, APB1 36 МГц */
{
    RCC->CR |= RCC_CR_HSEON;
    while (!(RCC->CR & RCC_CR_HSERDY)) {
    }
    FLASH->ACR = FLASH_ACR_PRFTBE | (2u << FLASH_ACR_LATENCY_Pos);
    RCC->CFGR = RCC_CFGR_PLLSRC | RCC_CFGR_PLLMULL9 | RCC_CFGR_PPRE1_DIV2;
    RCC->CR |= RCC_CR_PLLON;
    while (!(RCC->CR & RCC_CR_PLLRDY)) {
    }
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL) {
    }
}

static void pins_init(void)
{
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPCEN | RCC_APB2ENR_USART1EN;
    GPIOA->CRL = (GPIOA->CRL & ~0xFu) | 0x8u;                      /* PA0: вход с подтяжкой… */
    GPIOA->BRR = 1u << 0;                                          /* …вниз: радара нет — «никого» */
    GPIOA->CRH = (GPIOA->CRH & ~(0xFFu << 4)) | (0xBu << 4) | (0x8u << 8);   /* PA9 — AF, PA10 — вход */
    GPIOA->BSRR = 1u << 10;                                        /* подтяжка RX вверх: линия в покое — 1 */
    GPIOC->CRH = (GPIOC->CRH & ~(0xFu << 20)) | (0x2u << 20);      /* PC13 — выход */
    GPIOC->BSRR = 1u << 13;                                        /* свет выключен */
}

static int ot1(void) { return (int)(GPIOA->IDR & 1u); }
static void light(int on) { GPIOC->BSRR = on ? 1u << (13 + 16) : 1u << 13; }
#else
static void clock_init(void)                    /* HSE 8 МГц: /8 × 336 / 2 = 168 МГц, APB1 42, APB2 84 */
{
    RCC->CR |= RCC_CR_HSEON;
    while (!(RCC->CR & RCC_CR_HSERDY)) {
    }
    FLASH->ACR = FLASH_ACR_ICEN | FLASH_ACR_DCEN | FLASH_ACR_PRFTEN | FLASH_ACR_LATENCY_5WS;
    RCC->PLLCFGR = 8u | (336u << RCC_PLLCFGR_PLLN_Pos) | (0u << RCC_PLLCFGR_PLLP_Pos) | RCC_PLLCFGR_PLLSRC_HSE |
                   (7u << RCC_PLLCFGR_PLLQ_Pos);
    RCC->CFGR = RCC_CFGR_PPRE1_DIV4 | RCC_CFGR_PPRE2_DIV2;
    RCC->CR |= RCC_CR_PLLON;
    while (!(RCC->CR & RCC_CR_PLLRDY)) {
    }
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL) {
    }
}

static void pins_init(void)
{
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_GPIODEN;
    RCC->APB1ENR |= RCC_APB1ENR_USART2EN;
    GPIOA->MODER = (GPIOA->MODER & ~(3u << 0 | 3u << 4 | 3u << 6)) | (2u << 4) | (2u << 6);   /* PA0 вход, PA2/PA3 AF */
    GPIOA->PUPDR = (GPIOA->PUPDR & ~(3u << 0 | 3u << 6)) | (2u << 0) | (1u << 6);            /* PA0 вниз, PA3 вверх */
    GPIOA->AFR[0] = (GPIOA->AFR[0] & ~(0xFFu << 8)) | (7u << 8) | (7u << 12);               /* AF7 — USART2 */
    GPIOD->MODER = (GPIOD->MODER & ~(3u << 24)) | (1u << 24);                                /* PD12 — выход */
}

static int ot1(void) { return (int)(GPIOA->IDR & 1u); }
static void light(int on) { GPIOD->BSRR = on ? 1u << 12 : 1u << (12 + 16); }
#endif

static void uart_init(void)
{
    UART->BRR = BRR_VAL;
    UART->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE;
    NVIC_SetPriority(UART_IRQn, 5);
    NVIC_EnableIRQ(UART_IRQn);
}

int main(void)
{
    clock_init();
    SysTick->LOAD = HCLK / 1000u - 1u;
    SysTick->VAL = 0;
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk;
    pins_init();
    uart_init();
    ld2420_init(&radar, &rx, uart_tx, now_ms, idle);
    radar.on_other = count_other;

    while (ms < 1000u)                  /* сколько модуль стартует, в документах нет: ждём секунду */
        __WFI();
    static ld2420_cfg_t c;              /* static: 132 байта не на стеке; поля — здесь, а не в .data */
    c.min_gate = 0;
    c.max_gate = 8;                     /* ворота 0…8: до 6,3 м */
    c.delay = 5;
    ld2420_thr_from_noise(NOISE, c.trig, c.hold, 6, 3);     /* срабатывание — 6 шумов, удержание — 3 */
    cfg_rc = ld2420_configure(&radar, &c);  /* не вышло — модуль работает со своими настройками */

    lamp_init(&lamp, 10000u);
    uint32_t next = ms;
    for (;;) {
        ld2420_poll(&radar);            /* отчёты обычного режима уходят в count_other */
        if ((int32_t)(ms - next) >= 0) {
            next += 100u;
            light(lamp_step(&lamp, ms, ot1()));
        }
        __WFI();
    }
}

/* ── запуск и таблица векторов ── */
extern uint32_t _estack, _sidata, _sdata, _edata, _sbss, _ebss;

void Reset_Handler(void)
{
    uint32_t *s = &_sidata, *d = &_sdata;
    while (d < &_edata)
        *d++ = *s++;
    for (d = &_sbss; d < &_ebss;)
        *d++ = 0;
    main();
    for (;;) {
    }
}

static void Default_Handler(void)
{
    for (;;) {
    }
}

__attribute__((section(".isr_vector"), used)) void (*const vectors[16 + UART_IRQn + 1])(void) = {
    [0] = (void (*)(void))&_estack, [1] = Reset_Handler, [2] = Default_Handler, [3] = Default_Handler,
    [15] = SysTick_Handler, [16 + UART_IRQn] = UART_IRQHandler,
};

fw100a.ld — the linker script for the firmware and the QEMU run; memory sizes come from the command line.

/* Раздел 100а: прошивка светильника. Размеры памяти — из командной строки:
   F103C8: --defsym=FLASH_LEN=64K --defsym=RAM_LEN=20K; F407VG: 1024K и 128K. */
ENTRY(Reset_Handler)
MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = FLASH_LEN
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = RAM_LEN
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS
{
  .isr_vector : { KEEP(*(.isr_vector)) } > FLASH
  .text : { *(.text .text.*) *(.rodata .rodata.*) KEEP(*(.init)) KEEP(*(.fini)) . = ALIGN(4); } > FLASH
  .ARM.exidx : { *(.ARM.exidx*) } > FLASH
  .data : { _sdata = .; *(.data .data.*) . = ALIGN(4); _edata = .; } > RAM AT > FLASH
  _sidata = LOADADDR(.data);
  .bss (NOLOAD) : { _sbss = .; *(.bss .bss.*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM
  end = .;
}

model100a.h — the module model: protocol state, garbage, gate energies.

/* Раздел 100а: модель HLK-LD2420 для прогона драйвера на компьютере.
 * Протокол — по описанию производителя: ответы на открытие и закрытие режима команд, версию,
 * чтение и запись параметров — байт в байт как в примерах. Чего в описании нет, модель решает сама
 * (помечено «модель»): ответ на чтение и запись вне режима команд, значения по умолчанию для ворот,
 * которых нет в примерах, единица задержки (секунды), поведение OT1 и энергии в воротах. */
#ifndef MODEL100A_H
#define MODEL100A_H
#include <stdint.h>

#define M_TICK_MS 100u                  /* обновление 10 раз в секунду (руководство) */
#define M_OUT 8192u

typedef struct {
    uint16_t cm;                        /* расстояние до радара */
    uint32_t amp;                       /* энергия, которую цель даёт в своих воротах */
} m_target_t;

typedef struct {
    /* протокол */
    uint8_t in[200];
    uint16_t inn;
    uint8_t out[M_OUT];
    uint32_t oh, ot;
    int cmd_mode;
    uint32_t min_gate, max_gate, delay, trig[16], hold[16];
    uint32_t cmds, rejected, ignored;
    /* мусор между ответами: вероятность в тысячных */
    uint32_t junk_pm, junk_bytes;
    /* присутствие */
    uint32_t noise[16];                 /* средний шум ворот, условные единицы модели */
    uint32_t energy[16];
    int ot1;
    uint32_t quiet;                     /* тиков подряд без удержания */
    uint32_t rng;
} model_t;

void model_init(model_t *m, uint32_t seed);
void model_rx(model_t *m, uint8_t b);           /* байт от микроконтроллера */
int model_tx(model_t *m, uint8_t *b);           /* байт к микроконтроллеру, 0 — нечего */
uint32_t model_rand(model_t *m);
int model_tick(model_t *m, const m_target_t *t, int n);   /* 100 мс: энергии, OT1 */
#endif

model100a.c — the module model: responses per the protocol description, garbage between responses, OT1 by gate thresholds.

/* Раздел 100а: модель HLK-LD2420 — ответы по описанию протокола, OT1 по порогам ворот. */
#include <string.h>
#include "model100a.h"

static const uint8_t HDR[4] = {0xFD, 0xFC, 0xFB, 0xFA};
static const uint8_t TAIL[4] = {0x04, 0x03, 0x02, 0x01};

uint32_t model_rand(model_t *m)
{
    m->rng ^= m->rng << 13;             /* xorshift32: один и тот же ряд на компьютере и в QEMU */
    m->rng ^= m->rng >> 17;
    m->rng ^= m->rng << 5;
    return m->rng;
}

void model_init(model_t *m, uint32_t seed)
{
    memset(m, 0, sizeof *m);
    m->rng = seed ? seed : 1u;
    /* значения из примеров описания протокола */
    m->min_gate = 0;                    /* «Read module parameters (minimum distance)» → 00 00 00 00 */
    m->max_gate = 12;                   /* → 0C 00 00 00 */
    m->delay = 30;                      /* → 1E 00 00 00 */
    static const uint32_t t0[6] = {60000, 30000, 3000, 2000, 500, 400};   /* чтение порогов 00…05 */
    for (int g = 0; g < 16; g++) {
        m->trig[g] = g < 6 ? t0[g] : 400u;          /* модель: ворот 6…15 в примерах нет */
        m->hold[g] = g == 0 ? 40000u : 200u;        /* удержание 00 → 40 9C 00 00; остальное — модель */
        m->noise[g] = g == 0 ? 400u : g == 1 ? 300u : 150u;
    }
}

static void put(model_t *m, uint8_t b)
{
    if (m->oh - m->ot < M_OUT)
        m->out[m->oh++ % M_OUT] = b;
}

/* мусор между ответами: случайные байты, обрывки заголовка, «кадры» без хвоста и с огромной длиной */
static void junk(model_t *m)
{
    uint32_t kind = model_rand(m) % 4u, n = 1u + model_rand(m) % 40u;
    if (kind == 1) {
        put(m, 0xFD), put(m, 0xFC), m->junk_bytes += 2;
        if (model_rand(m) & 1u)
            put(m, 0xFB), m->junk_bytes++;
    } else if (kind >= 2) {
        uint32_t len = kind == 2 ? 2u + model_rand(m) % 127u : 129u + model_rand(m) % 60000u;
        for (int i = 0; i < 4; i++)
            put(m, HDR[i]);
        put(m, (uint8_t)len), put(m, (uint8_t)(len >> 8));
        m->junk_bytes += 6;
        n = model_rand(m) % 21u;
    }
    for (uint32_t i = 0; i < n; i++)
        put(m, (uint8_t)model_rand(m)), m->junk_bytes++;
}

static void reply(model_t *m, uint16_t code, uint16_t status, const uint8_t *p, uint16_t n)
{
    if (m->junk_pm && model_rand(m) % 1000u < m->junk_pm)
        junk(m);
    uint16_t len = (uint16_t)(4u + n);
    for (int i = 0; i < 4; i++)
        put(m, HDR[i]);
    put(m, (uint8_t)len), put(m, (uint8_t)(len >> 8));
    put(m, (uint8_t)code), put(m, (uint8_t)(code >> 8));
    put(m, (uint8_t)status), put(m, (uint8_t)(status >> 8));
    for (uint16_t i = 0; i < n; i++)
        put(m, p[i]);
    for (int i = 0; i < 4; i++)
        put(m, TAIL[i]);
}

static uint32_t *param(model_t *m, uint16_t name)
{
    if (name == 0x0000)
        return &m->min_gate;
    if (name == 0x0001)
        return &m->max_gate;
    if (name == 0x0004)
        return &m->delay;
    if (name >= 0x0010 && name <= 0x001F)
        return &m->trig[name - 0x10];
    if (name >= 0x0020 && name <= 0x002F)
        return &m->hold[name - 0x20];
    return 0;
}

static uint32_t limit(uint16_t name)
{
    return name <= 0x0001 ? 15u : 65535u;           /* ворота 0…15; задержка и пороги — 0…65535 */
}

static void command(model_t *m, const uint8_t *d, uint16_t len)
{
    uint16_t code = (uint16_t)(d[0] | d[1] << 8), ack = (uint16_t)(code | 0x0100u);
    uint8_t p[4 * 16];
    m->cmds++;
    if (code == 0x00FF) {                           /* открыть: ответ как в примере, 02 00 20 00 */
        static const uint8_t r[4] = {0x02, 0x00, 0x20, 0x00};
        m->cmd_mode = 1;
        reply(m, ack, 0, r, 4);
    } else if (code == 0x00FE) {
        m->cmd_mode = 0;
        reply(m, ack, 0, 0, 0);
    } else if (code == 0x0000) {
        static const uint8_t r[8] = {0x06, 0x00, 'v', '1', '.', '5', '.', '4'};
        reply(m, ack, 0, r, 8);
    } else if (code == 0x0068) {                    /* перезапуск: ответа в описании нет */
        m->cmd_mode = 0;
        m->ot1 = 0;
        m->quiet = 0;
    } else if (code == 0x0008) {
        uint16_t n = (uint16_t)((len - 2u) / 2u);
        int bad = !m->cmd_mode || (len - 2u) % 2u || n == 0 || n > 16;   /* модель: вне режима — отказ */
        for (uint16_t i = 0; !bad && i < n; i++) {
            uint32_t *v = param(m, (uint16_t)(d[2 + 2 * i] | d[3 + 2 * i] << 8));
            if (!v)
                bad = 1;
            else
                p[4 * i] = (uint8_t)*v, p[4 * i + 1] = (uint8_t)(*v >> 8), p[4 * i + 2] = (uint8_t)(*v >> 16),
                p[4 * i + 3] = (uint8_t)(*v >> 24);
        }
        if (bad)
            m->rejected++, reply(m, ack, 1, 0, 0);
        else
            reply(m, ack, 0, p, (uint16_t)(4u * n));
    } else if (code == 0x0007) {
        uint16_t n = (uint16_t)((len - 2u) / 6u);
        int bad = !m->cmd_mode || (len - 2u) % 6u || n == 0;
        for (uint16_t i = 0; !bad && i < n; i++) {   /* сначала проверить всё, потом записать */
            const uint8_t *q = &d[2 + 6 * i];
            uint16_t name = (uint16_t)(q[0] | q[1] << 8);
            uint32_t v = q[2] | (uint32_t)q[3] << 8 | (uint32_t)q[4] << 16 | (uint32_t)q[5] << 24;
            bad = !param(m, name) || v > limit(name);
        }
        for (uint16_t i = 0; !bad && i < n; i++) {
            const uint8_t *q = &d[2 + 6 * i];
            *param(m, (uint16_t)(q[0] | q[1] << 8)) = q[2] | (uint32_t)q[3] << 8 | (uint32_t)q[4] << 16 | (uint32_t)q[5] << 24;
        }
        if (bad)
            m->rejected++;
        reply(m, ack, bad ? 1u : 0u, 0, 0);
    } else {
        m->rejected++;
        reply(m, ack, 1, 0, 0);
    }
}

void model_rx(model_t *m, uint8_t b)
{
    if (m->inn < 4) {
        if (b != HDR[m->inn]) {
            m->inn = 0;
            if (b == HDR[0])
                m->in[m->inn++] = b;
            return;
        }
        m->in[m->inn++] = b;
        return;
    }
    m->in[m->inn++] = b;
    if (m->inn < 6)
        return;
    uint16_t len = (uint16_t)(m->in[4] | m->in[5] << 8);
    if (len < 2 || len + 10u > sizeof m->in) {
        m->ignored++;
        m->inn = 0;
        return;
    }
    if (m->inn < len + 10u)
        return;
    m->inn = 0;
    if (memcmp(&m->in[6 + len], TAIL, 4)) {
        m->ignored++;
        return;
    }
    command(m, &m->in[6], len);
}

int model_tx(model_t *m, uint8_t *b)
{
    if (m->ot == m->oh)
        return 0;
    *b = m->out[m->ot++ % M_OUT];
    return 1;
}

/* 100 мс работы радара. Модель: шум ворот ±50 %, цель — в воротах cm/70 и по четверти в соседних.
 * Нет человека → есть: энергия выше порога срабатывания хоть в одних воротах от min до max.
 * Есть → нет: ни в одних не выше порога удержания delay секунд подряд. */
int model_tick(model_t *m, const m_target_t *t, int n)
{
    for (int g = 0; g < 16; g++)
        m->energy[g] = m->noise[g] * (50u + model_rand(m) % 101u) / 100u;
    for (int i = 0; i < n; i++) {
        int g = t[i].cm / 70;
        if (g > 15)
            continue;
        m->energy[g] += t[i].amp * (80u + model_rand(m) % 41u) / 100u;
        if (g > 0)
            m->energy[g - 1] += t[i].amp / 4u;
        if (g < 15)
            m->energy[g + 1] += t[i].amp / 4u;
    }
    int trig = 0, hold = 0;
    for (uint32_t g = m->min_gate; g <= m->max_gate && g < 16; g++) {
        trig |= m->energy[g] > m->trig[g];
        hold |= m->energy[g] > m->hold[g];
    }
    if (!m->ot1) {
        if (trig)
            m->ot1 = 1, m->quiet = 0;
    } else if (hold) {
        m->quiet = 0;
    } else if (++m->quiet >= m->delay * (1000u / M_TICK_MS)) {
        m->ot1 = 0;
    }
    return m->ot1;
}

test100a.c — the run: 16 examples of the description, parser tests, a configuration session, experiments with garbage, loss and errors, lamp scenarios.

/* Раздел 100а: прогон драйвера HLK-LD2420 против модели. Печатает строки «ключ=значение».
 * Часы — свои: один вызов idle() = 1 мс, за 1 мс по линии 256 000 бод проходит 25 байт.
 * Тот же файл собирается gcc на компьютере и arm-none-eabi-gcc для QEMU — вывод должен совпасть. */
#include <stdio.h>
#include <string.h>
#include "ld2420.h"
#include "lamp.h"
#include "model100a.h"

#define BYTES_PER_MS 25u                /* 256 000 бод, 10 бит на байт: 25,6 байта за 1 мс */

static model_t M;
static ld2420_ring_t RX;
static ld2420_t D;
static uint32_t now;
static uint32_t drop_ppm, flip_ppm, wire_rng = 7u, wire_drops, wire_flips;

static uint32_t wrand(void)
{
    wire_rng ^= wire_rng << 13;
    wire_rng ^= wire_rng >> 17;
    wire_rng ^= wire_rng << 5;
    return wire_rng;
}

/* линия: байт может пропасть или прийти с одним перевёрнутым битом */
static int wire(uint8_t *b)
{
    if (drop_ppm && wrand() % 1000000u < drop_ppm)
        return wire_drops++, 0;
    if (flip_ppm && wrand() % 1000000u < flip_ppm)
        *b ^= (uint8_t)(1u << (wrand() % 8u)), wire_flips++;
    return 1;
}

static void tx(const uint8_t *p, uint16_t n)
{
    for (uint16_t i = 0; i < n; i++) {
        uint8_t b = p[i];
        if (wire(&b))
            model_rx(&M, b);
    }
}

static uint32_t now_ms(void) { return now; }

static void idle(void)
{
    uint8_t b;
    now++;
    for (unsigned i = 0; i < BYTES_PER_MS && model_tx(&M, &b); i++)
        if (wire(&b))
            ld2420_ring_put(&RX, b);
}

static void reset_all(uint32_t seed)
{
    model_init(&M, seed);
    memset(&RX, 0, sizeof RX);
    ld2420_init(&D, &RX, tx, now_ms, idle);
    drop_ppm = flip_ppm = wire_drops = wire_flips = 0;
    wire_rng = seed;                    /* у каждого опыта свой ряд случайных чисел линии */
    now = 0;
}

static int hex(const char *s, uint8_t *out)
{
    int n = 0;
    unsigned v;
    while (sscanf(s, "%2x", &v) == 1) {
        out[n++] = (uint8_t)v;
        s += 2;
        while (*s == ' ')
            s++;
    }
    return n;
}

static void put_hex(const char *key, const uint8_t *p, int n)
{
    printf("%s=", key);
    for (int i = 0; i < n; i++)
        printf("%s%02X", i ? " " : "", p[i]);
    printf("\n");
}

/* ══ 1. Кадры из описания протокола ══ */
struct ex { const char *name; uint16_t cmd; const char *par, *send, *resp; };
static const struct ex EX[] = {
    {"open", 0x00FF, "01 00", "FD FC FB FA 04 00 FF 00 01 00 04 03 02 01", "FD FC FB FA 08 00 FF 01 00 00 02 00 20 00 04 03 02 01"},
    {"close", 0x00FE, "", "FD FC FB FA 02 00 FE 00 04 03 02 01", "FD FC FB FA 04 00 FE 01 00 00 04 03 02 01"},
    {"version", 0x0000, "", "FD FC FB FA 02 00 00 00 04 03 02 01",
     "FD FC FB FA 0C 00 00 01 00 00 06 00 76 31 2E 35 2E 34 04 03 02 01"},
    {"reboot", 0x0068, "", "FD FC FB FA 02 00 68 00 04 03 02 01", ""},
    {"read_min", 0x0008, "00 00", "FD FC FB FA 04 00 08 00 00 00 04 03 02 01",
     "FD FC FB FA 08 00 08 01 00 00 00 00 00 00 04 03 02 01"},
    {"set_min_0", 0x0007, "00 00 00 00 00 00", "FD FC FB FA 08 00 07 00 00 00 00 00 00 00 04 03 02 01",
     "FD FC FB FA 04 00 07 01 00 00 04 03 02 01"},
    {"read_max", 0x0008, "01 00", "FD FC FB FA 04 00 08 00 01 00 04 03 02 01",
     "FD FC FB FA 08 00 08 01 00 00 0C 00 00 00 04 03 02 01"},
    {"set_max_12", 0x0007, "01 00 0C 00 00 00", "FD FC FB FA 08 00 07 00 01 00 0C 00 00 00 04 03 02 01",
     "FD FC FB FA 04 00 07 01 00 00 04 03 02 01"},
    {"read_delay", 0x0008, "04 00", "FD FC FB FA 04 00 08 00 04 00 04 03 02 01",
     "FD FC FB FA 08 00 08 01 00 00 1E 00 00 00 04 03 02 01"},
    {"set_delay", 0x0007, "04 00 1A 00 00 00", "FD FC FB FA 08 00 07 00 04 00 1A 00 00 00 04 03 02 01",
     "FD FC FB FA 04 00 07 01 00 00 04 03 02 01"},
    {"read_hold00", 0x0008, "20 00", "FD FC FB FA 04 00 08 00 20 00 04 03 02 01",
     "FD FC FB FA 08 00 08 01 00 00 40 9C 00 00 04 03 02 01"},
    {"set_hold00", 0x0007, "20 00 50 C3 00 00", "FD FC FB FA 08 00 07 00 20 00 50 C3 00 00 04 03 02 01",
     "FD FC FB FA 04 00 07 01 00 00 04 03 02 01"},
    {"read_trig00", 0x0008, "10 00", "FD FC FB FA 04 00 08 00 10 00 04 03 02 01",
     "FD FC FB FA 08 00 08 01 00 00 40 9C 00 00 04 03 02 01"},
    {"set_trig00", 0x0007, "10 00 50 9C 00 00", "FD FC FB FA 08 00 07 00 10 00 50 9C 00 00 04 03 02 01",
     "FD FC FB FA 04 00 07 01 00 00 04 03 02 01"},
    {"read_trig00_05", 0x0008, "10 00 11 00 12 00 13 00 14 00 15 00",
     "FD FC FB FA 0E 00 08 00 10 00 11 00 12 00 13 00 14 00 15 00 04 03 02 01",
     "FD FC FB FA 1C 00 08 01 00 00 60 EA 00 00 30 75 00 00 B8 0B 00 00 D0 07 00 00 F4 01 00 00 90 01 00 00 04 03 02 01"},
    {"set_trig00_05", 0x0007,
     "10 00 60 EA 00 00 11 00 30 75 00 00 12 00 B8 0B 00 00 13 00 D0 07 00 00 14 00 F4 01 00 00 15 00 90 01 00 00",
     "FD FC FB FA 24 00 07 00 10 00 60 EA 00 00 11 00 30 75 00 00 12 00 B8 0B 00 00 13 00 D0 07 00 00 14 00 F4 01 00 00 15 00 90 01 04 03 02 01",
     "FD FC FB FA 04 00 07 01 00 00 04 03 02 01"},
};
#define NEX (int)(sizeof EX / sizeof EX[0])

static uint8_t last[LD2420_MAX_DATA];
static uint16_t last_len;
static int nframes;
static int strict;                      /* 1 — как драйвер: только ответы с известным кодом */
static int grab(void *c, const uint8_t *d, uint16_t n)
{
    (void)c;
    if (strict && (d[1] != 0x01 || n < 4))
        return 0;
    memcpy(last, d, n);
    last_len = n;
    nframes++;
    return 1;
}

static void doc_frames(void)
{
    int same = 0, model_same = 0;
    uint8_t want[160], got[160], par[64], resp[160], mo[160];
    for (int i = 0; i < NEX; i++) {
        int np = hex(EX[i].par, par), nw = hex(EX[i].send, want);
        int ng = ld2420_build(got, EX[i].cmd, par, (uint16_t)np);
        int eq = ng == nw && !memcmp(got, want, (size_t)nw);
        same += eq;
        if (!eq) {
            printf("doc_send_differs=%s doc_len=%d our_len=%d\n", EX[i].name, nw, ng);
            put_hex("doc_send", want, nw);
            put_hex("our_send", got, ng);
        }
        /* модель в режиме команд: тот же ли ответ, что в описании */
        int nr = hex(EX[i].resp, resp), nm = 0;
        model_init(&M, 1);
        M.cmd_mode = 1;
        for (int k = 0; k < ng; k++)
            model_rx(&M, got[k]);
        while (model_tx(&M, &mo[nm]))
            nm++;
        if (nr == nm && !memcmp(resp, mo, (size_t)nr))
            model_same++;
        else {
            printf("model_differs=%s\n", EX[i].name);
            put_hex("doc_resp", resp, nr);
            put_hex("model_resp", mo, nm);
        }
    }
    printf("doc_examples=%d doc_send_same=%d model_resp_same=%d\n", NEX, same, model_same);

    /* ответы из описания — через разбор: что в них */
    ld2420_parser_t p;
    ld2420_parser_init(&p, grab, 0, 0);
    for (int i = 0; i < NEX; i++) {
        int nr = hex(EX[i].resp, resp);
        if (!nr)
            continue;
        nframes = 0;
        for (int k = 0; k < nr; k++)
            ld2420_parse(&p, resp[k]);
        uint16_t code = (uint16_t)(last[0] | last[1] << 8), st = (uint16_t)(last[2] | last[3] << 8);
        printf("resp %s frames=%d len=%u code=0x%04X status=%u", EX[i].name, nframes, last_len, code, st);
        if (EX[i].cmd == 0x0008)
            for (int k = 4; k + 3 < last_len; k += 4)
                printf(" %lu", (unsigned long)(last[k] | (uint32_t)last[k + 1] << 8 | (uint32_t)last[k + 2] << 16 |
                                              (uint32_t)last[k + 3] << 24));
        if (EX[i].cmd == 0x00FF)
            printf(" a=%u b=%u", last[4] | last[5] << 8, last[6] | last[7] << 8);
        if (EX[i].cmd == 0x0000)
            printf(" n=%u s=%.*s", last[4] | last[5] << 8, last[4], (const char *)&last[6]);
        printf("\n");
    }
    printf("doc_parse other=%lu bad_len=%lu bad_tail=%lu\n", (unsigned long)p.other, (unsigned long)p.bad_len,
           (unsigned long)p.bad_tail);
}

/* ══ 2. Разбор: обрывки, лишние байты, ложные заголовки ══ */
static void feed(ld2420_parser_t *p, const char *s)
{
    uint8_t b[300];
    int n = hex(s, b);
    for (int i = 0; i < n; i++)
        ld2420_parse(p, b[i]);
}

#define GOOD "FD FC FB FA 08 00 08 01 00 00 0C 00 00 00 04 03 02 01"

static void unit(const char *name, const char *a, const char *b, int flush)
{
    ld2420_parser_t p;
    ld2420_parser_init(&p, grab, 0, 0);
    nframes = 0;
    last_len = 0;
    feed(&p, a);
    if (b)
        feed(&p, b);
    int before = nframes;
    if (flush)
        ld2420_parser_flush(&p);
    printf("unit %s frames=%d", name, nframes);
    if (flush)
        printf(" before_flush=%d", before);
    printf(" value=%u other=%lu bad_len=%lu bad_tail=%lu rejected=%lu\n", nframes ? last[last_len - 4] : 0u,
           (unsigned long)p.other, (unsigned long)p.bad_len, (unsigned long)p.bad_tail, (unsigned long)p.rejected);
}

static void units(void)
{
    unit("whole", GOOD, 0, 0);
    unit("garbage_before", "00 11 FD 22 FD FC 33", GOOD, 0);
    unit("double_fd", "FD", GOOD, 0);
    unit("bad_len", "FD FC FB FA FF 7F", GOOD, 0);
    unit("short_len", "FD FC FB FA 01 00", GOOD, 0);
    unit("fake_header", "FD FC FB FA 0A 00 99", GOOD, 0);
    unit("cut_frame", "FD FC FB FA 08 00 08 01 00", GOOD, 0);
    unit("bad_tail", "FD FC FB FA 08 00 08 01 00 00 0C 00 00 00 04 03 02 00", 0, 0);
    unit("long_fake_then_quiet", "FD FC FB FA 64 00", GOOD, 1);
    /* ложный заголовок с длиной 18: его «хвост» — это хвост настоящего ответа */
    unit("tail_alias_any", "FD FC FB FA 12 00 B1 65 EC B2", GOOD, 0);
    strict = 1;
    unit("tail_alias_strict", "FD FC FB FA 12 00 B1 65 EC B2", GOOD, 0);
    strict = 0;

    ld2420_ring_t r;
    uint8_t b;
    int got = 0;
    memset(&r, 0, sizeof r);
    for (int i = 0; i < 300; i++)
        ld2420_ring_put(&r, (uint8_t)i);
    while (ld2420_ring_get(&r, &b))
        got++;
    printf("ring size=%u put=300 got=%d lost=%lu\n", LD2420_RING, got, (unsigned long)r.lost);
}

/* ══ 3. Сеанс настройки на чистой линии ══ */
static const uint32_t NOISE[16] = {400, 300, 150, 150, 150, 150, 150, 150, 150, 150, 150, 150, 150, 150, 150, 150};

static void make_cfg(ld2420_cfg_t *c, uint8_t max_gate, uint32_t kt, uint32_t kh)
{
    c->min_gate = 0;
    c->max_gate = max_gate;
    c->delay = 5;
    ld2420_thr_from_noise(NOISE, c->trig, c->hold, kt, kh);
}

static void session(void)
{
    uint16_t a = 0, b = 0;
    char ver[16];
    uint32_t v[16];
    uint16_t names[16];
    reset_all(1);
    int r = ld2420_open(&D, &a, &b);
    printf("session open=%d a=%u b=%u\n", r, a, b);
    r = ld2420_version(&D, ver, sizeof ver);
    printf("session version=%d s=%s\n", r, ver);
    names[0] = LD2420_P_MIN_GATE, names[1] = LD2420_P_MAX_GATE, names[2] = LD2420_P_DELAY;
    r = ld2420_read(&D, names, 3, v);
    printf("session read_general=%d min=%lu max=%lu delay=%lu\n", r, (unsigned long)v[0], (unsigned long)v[1],
           (unsigned long)v[2]);
    for (int g = 0; g < 16; g++)
        names[g] = (uint16_t)LD2420_P_TRIG(g);
    r = ld2420_read(&D, names, 16, v);
    printf("session read_trig16=%d ack_len=%u t0=%lu t5=%lu t15=%lu\n", r, D.ack_len, (unsigned long)v[0],
           (unsigned long)v[5], (unsigned long)v[15]);
    r = ld2420_close(&D);
    printf("session close=%d\n", r);
    names[0] = LD2420_P_MIN_GATE;
    r = ld2420_read(&D, names, 1, v);
    printf("session read_closed=%d status=%u\n", r, D.status);

    ld2420_cfg_t c;
    make_cfg(&c, 8, 6, 3);
    uint32_t t0 = now, cmds0 = M.cmds;
    r = ld2420_configure(&D, &c);
    printf("configure=%d cmds=%lu ms=%lu trig0=%lu trig2=%lu hold2=%lu model_max=%lu model_delay=%lu cmd_mode=%d\n", r,
           (unsigned long)(M.cmds - cmds0), (unsigned long)(now - t0), (unsigned long)M.trig[0],
           (unsigned long)M.trig[2], (unsigned long)M.hold[2], (unsigned long)M.max_gate, (unsigned long)M.delay,
           M.cmd_mode);
    c.max_gate = 16;
    r = ld2420_configure(&D, &c);
    printf("configure_bad_gate=%d\n", r);

    /* модуль молчит: три попытки по 100 мс */
    reset_all(1);
    M.cmd_mode = 1;
    drop_ppm = 1000000u;
    t0 = now;
    r = ld2420_close(&D);
    printf("silent rc=%d timeouts=%lu retries=%lu ms=%lu\n", r, (unsigned long)D.timeouts, (unsigned long)D.retries,
           (unsigned long)(now - t0));
}

/* ══ 4. Поток с мусором, потерями и сбоями битов ══ */
static uint32_t *mparam(uint16_t name)
{
    if (name == 0)
        return &M.min_gate;
    if (name == 1)
        return &M.max_gate;
    if (name == 4)
        return &M.delay;
    return name < 0x20 ? &M.trig[name - 0x10] : &M.hold[name - 0x20];
}

static uint16_t rand_name(void)
{
    uint32_t k = wrand() % 35u;
    return (uint16_t)(k == 0 ? 0 : k == 1 ? 1 : k == 2 ? 4 : k < 19 ? 0x10 + (k - 3) : 0x20 + (k - 19));
}

/* «Принимать всё»: обёртка над обратным вызовом драйвера, которая не даёт отвергнуть кадр с чужим кодом —
 * так разбор вёл себя без проверки is_ack */
static int (*drv_frame)(void *, const uint8_t *, uint16_t);
static int lax_frame(void *c, const uint8_t *d, uint16_t n)
{
    drv_frame(c, d, n);
    return 1;
}

static void fuzz(const char *name, uint32_t junk_pm, uint32_t drop, uint32_t flip, int ops, int lax)
{
    uint16_t a, b, names[16];
    uint32_t vals[16], got[16];
    int ok = 0, e_to = 0, e_st = 0, e_sh = 0, wrong_read = 0, wrong_write = 0;
    reset_all(12345);
    if (lax) {
        drv_frame = D.p.on_frame;
        D.p.on_frame = lax_frame;
    }
    ld2420_open(&D, &a, &b);
    M.junk_pm = junk_pm;
    drop_ppm = drop;
    flip_ppm = flip;
    for (int i = 0; i < ops; i++) {
        M.cmd_mode = 1;                 /* сбой мог «закрыть» режим чужой командой — тест не про это */
        int n = 1 + (int)(wrand() % 16u), r;
        for (int k = 0; k < n; k++)
            names[k] = rand_name();
        if (wrand() & 1u) {
            r = ld2420_read(&D, names, (uint16_t)n, got);
            if (r == 0)
                for (int k = 0; k < n; k++)
                    wrong_read += got[k] != *mparam(names[k]);
        } else {
            n = 1 + n % 6;
            for (int k = 0; k < n; k++) {
                for (int j = 0; j < k; j++)
                    if (names[j] == names[k])
                        names[k] = rand_name(), j = -1;      /* в одной записи имена без повторов */
                vals[k] = names[k] <= 1 ? wrand() % 16u : wrand() % 65536u;
            }
            r = ld2420_write(&D, names, vals, (uint16_t)n);
            if (r == 0)
                for (int k = 0; k < n; k++)
                    wrong_write += vals[k] != *mparam(names[k]);
        }
        ok += r == 0;
        e_to += r == LD2420_ETIMEOUT;
        e_st += r == LD2420_ESTATUS;
        e_sh += r == LD2420_ESHORT;
    }
    printf("fuzz %s ops=%d ok=%d timeout=%d status=%d short=%d wrong_read=%d wrong_write=%d\n", name, ops, ok, e_to,
           e_st, e_sh, wrong_read, wrong_write);
    printf("fuzz %s retries=%lu timeouts=%lu stray=%lu frames=%lu bad_len=%lu bad_tail=%lu rejected=%lu flushed=%lu "
           "other=%lu junk=%lu drops=%lu flips=%lu ms=%lu lost=%lu\n",
           name, (unsigned long)D.retries, (unsigned long)D.timeouts, (unsigned long)D.stray,
           (unsigned long)D.p.frames, (unsigned long)D.p.bad_len, (unsigned long)D.p.bad_tail, (unsigned long)D.p.rejected,
           (unsigned long)D.p.flushed, (unsigned long)D.p.other, (unsigned long)M.junk_bytes,
           (unsigned long)wire_drops, (unsigned long)wire_flips, (unsigned long)now, (unsigned long)RX.lost);
}

/* настройка целиком на линии со сбоями битов: обратное чтение ловит испорченное */
static void cfg_flips(int runs)
{
    int ok = 0, verify = 0, status = 0, tmo = 0, bad_after_ok = 0;
    reset_all(777);
    flip_ppm = 1000;
    for (int i = 0; i < runs; i++) {
        ld2420_cfg_t c;
        uint8_t gmax = (uint8_t)(4 + wrand() % 12u);    /* по одному вызову в строке: порядок вычисления */
        uint32_t kt = 5 + wrand() % 4u;                 /* аргументов у gcc для x86 и для ARM разный */
        uint32_t kh = 2 + wrand() % 4u;
        make_cfg(&c, gmax, kt, kh);
        int r = ld2420_configure(&D, &c);
        ok += r == 0;
        verify += r == LD2420_EVERIFY;
        status += r == LD2420_ESTATUS;
        tmo += r == LD2420_ETIMEOUT;
        if (r == 0) {
            int bad = M.min_gate != c.min_gate || M.max_gate != c.max_gate || M.delay != c.delay;
            for (int g = 0; g < 16; g++)
                bad |= M.trig[g] != c.trig[g] || M.hold[g] != c.hold[g];
            bad_after_ok += bad;
        }
    }
    printf("cfg_flips runs=%d ok=%d verify_fail=%d status_fail=%d timeout=%d bad_after_ok=%d flips=%lu retries=%lu "
           "cmds=%lu\n", runs, ok, verify, status, tmo, bad_after_ok, (unsigned long)wire_flips, (unsigned long)D.retries,
           (unsigned long)M.cmds);
}

/* ══ 5. Светильник ══
 * Комната 300 с. Вентилятор в 7,5 м качается всё время. Человек входит на 30-й секунде (идёт от 6 м
 * к 2 м 10 с), сидит неподвижно до 160-й, уходит к 6 м за 5 с и исчезает на 165-й. */
#define AMP_WALK 6000u
#define AMP_BREATH 500u
#define AMP_FAN 2000u

static int scene(uint32_t t, m_target_t *o)
{
    int n = 0;
    o[n].cm = 750, o[n++].amp = AMP_FAN;
    if (t >= 30000 && t < 40000)
        o[n].cm = (uint16_t)(600 - (t - 30000) * 400 / 10000), o[n++].amp = AMP_WALK;
    else if (t >= 40000 && t < 160000)
        o[n].cm = 200, o[n++].amp = AMP_BREATH;
    else if (t >= 160000 && t < 165000)
        o[n].cm = (uint16_t)(200 + (t - 160000) * 400 / 5000), o[n++].amp = AMP_WALK;
    return n;
}

static void lamp_run(const char *name, uint8_t max_gate, uint32_t kt, uint32_t kh)
{
    ld2420_cfg_t c;
    lamp_t l;
    m_target_t tg[4];
    reset_all(4242);
    make_cfg(&c, max_gate, kt, kh);
    int r = ld2420_configure(&D, &c);
    lamp_init(&l, 10000);
    uint32_t t0 = now, on_present = 0, on_absent = 0, ot1_on = 0, prev = 0;
    printf("lamp %s configure=%d max_gate=%u trig=%lu hold=%lu\n", name, r, max_gate, (unsigned long)c.trig[5],
           (unsigned long)c.hold[5]);
    for (uint32_t t = 0; t < 300000; t += M_TICK_MS) {
        int n = scene(t, tg);
        int ot1 = model_tick(&M, tg, n);
        int on = lamp_step(&l, t0 + t, ot1);
        int present = t >= 30000 && t < 165000;
        ot1_on += (uint32_t)ot1;
        if (on)
            present ? on_present++ : on_absent++;
        if ((uint32_t)on != prev)
            printf("lamp %s t=%lu on=%d\n", name, (unsigned long)t, on);
        prev = (uint32_t)on;
    }
    printf("lamp %s switches=%lu on_present_s=%lu on_absent_s=%lu ot1_s=%lu present_s=135\n", name,
           (unsigned long)l.switches, (unsigned long)(on_present / 10), (unsigned long)(on_absent / 10),
           (unsigned long)(ot1_on / 10));
}

int main(void)
{
    printf("== doc\n");
    doc_frames();
    printf("== unit\n");
    units();
    printf("== session\n");
    session();
    printf("== fuzz\n");
    fuzz("clean", 0, 0, 0, 2000, 0);
    fuzz("junk", 300, 0, 0, 2000, 0);
    fuzz("junk_lax", 300, 0, 0, 2000, 1);
    fuzz("drops", 300, 2000, 0, 2000, 0);
    fuzz("flips", 0, 0, 1000, 2000, 0);
    cfg_flips(300);
    printf("== lamp\n");
    lamp_run("gates0-8", 8, 6, 3);
    lamp_run("gates0-12", 12, 6, 3);
    lamp_run("no_hysteresis", 8, 6, 6);
    printf("done=1\n");
    return 0;
}

qemu100a.c — the run harness for QEMU: startup, output via semihosting, exit.

/* Раздел 100а: тот же прогон test100a.c на Cortex-M4 в QEMU netduinoplus2 (STM32F405).
   printf уходит через полухостинг (SYS_WRITE0), в конце — SYS_EXIT. */
#include <stdint.h>
#include <string.h>

extern uint32_t _estack, _sidata, _sdata, _edata, _sbss, _ebss;
int main(void);

static int semi(int op, const void *arg)
{
    register int r0 __asm__("r0") = op;
    register const void *r1 __asm__("r1") = arg;
    __asm__ volatile("bkpt 0xAB" : "+r"(r0) : "r"(r1) : "memory");
    return r0;
}

int _write(int fd, const char *p, int n)
{
    char buf[129];
    (void)fd;
    for (int done = 0; done < n;) {
        int k = n - done > 128 ? 128 : n - done;
        memcpy(buf, p + done, (size_t)k);
        buf[k] = 0;
        semi(0x04, buf);
        done += k;
    }
    return n;
}

void Reset_Handler(void)
{
    uint32_t *s = &_sidata, *d = &_sdata;
    while (d < &_edata)
        *d++ = *s++;
    for (d = &_sbss; d < &_ebss;)
        *d++ = 0;
    main();
    static const uint32_t ex[2] = {0x20026u, 0};      /* ADP_Stopped_ApplicationExit */
    semi(0x18, ex);
    for (;;) {
    }
}

static void Default_Handler(void)
{
    for (;;) {
    }
}

__attribute__((section(".isr_vector"), used)) void (*const vectors[16])(void) = {
    (void (*)(void))&_estack, Reset_Handler, Default_Handler, Default_Handler,
    Default_Handler, Default_Handler, Default_Handler,
};

run100a.sh — build and run in containers: host — gcc, arm — firmware for F103 and F407, sizes, stack and the run in QEMU.

#!/bin/sh
# Раздел 100а: прогон. /v — этот каталог (только чтение).
#   sh /v/run100a.sh host   в stm-sys:1 — драйвер против модели (gcc)
#   sh /v/run100a.sh arm    в stm-arm:1 — прошивки F103 и F407 (-Os, размеры) и тот же прогон в QEMU
#                           заголовки: /f1 — CMSIS F1 (stm32f103xb.h), /f4 — CMSIS F4, /ci — ядро CMSIS
set -u
export LC_ALL=C.UTF-8
B=$(mktemp -d)
cd $B
SRC="/v/ld2420.c /v/lamp.c"
case "$1" in
host)
    echo "== gcc $(gcc -dumpfullversion)"
    gcc -std=c11 -O2 -Wall -Wextra -Werror -o t100a /v/test100a.c /v/model100a.c $SRC 2>&1
    echo "build_host=$?"
    ./t100a
    echo "run_host=$?"
    ;;
arm)
    echo "== $(arm-none-eabi-gcc --version | head -1)"
    W="-mthumb -Os -std=c11 -Wall -Wextra -Werror -ffunction-sections -fdata-sections -I/v -I/ci"
    for chip in F103 F407; do
        if [ $chip = F103 ]; then CPU="-mcpu=cortex-m3"; DEF="-DSTM32F103xB -I/f1"; MEM="-Wl,--defsym=FLASH_LEN=64K -Wl,--defsym=RAM_LEN=20K"
        else CPU="-mcpu=cortex-m4"; DEF="-DSTM32F407xx -I/f4"; MEM="-Wl,--defsym=FLASH_LEN=1024K -Wl,--defsym=RAM_LEN=128K"; fi
        for f in ld2420 lamp fw100a; do
            arm-none-eabi-gcc $CPU $W -DCHIP_$chip $DEF -fstack-usage -c /v/$f.c -o ${f}_$chip.o 2>&1
            echo "build_${f}_$chip=$?"
        done
        sed "s/^/su $chip /" ld2420_$chip.su
        arm-none-eabi-gcc $CPU -mthumb -nostartfiles --specs=nano.specs --specs=nosys.specs -Wl,--gc-sections \
            -T /v/fw100a.ld $MEM -o fw_$chip.elf ld2420_$chip.o lamp_$chip.o fw100a_$chip.o 2>&1
        echo "build_fw_$chip=$?"
        echo "-- size $chip"
        arm-none-eabi-size ld2420_$chip.o lamp_$chip.o fw100a_$chip.o fw_$chip.elf
        arm-none-eabi-nm -S -t d ld2420_$chip.o | awk '$3 ~ /^[Tt]$/ {print "fn", $4, $2 + 0}' | sort
        arm-none-eabi-nm -S -t d fw_$chip.elf | awk '$4 == "rx" || $4 == "radar" || $4 == "lamp" {print "obj", $4, $2 + 0}' | sort
    done
    echo "== QEMU netduinoplus2"
    qemu-system-arm --version | head -1
    arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -std=c11 -Wall -Wextra -Werror -I/v -nostartfiles --specs=nosys.specs \
        -T /v/fw100a.ld -Wl,--defsym=FLASH_LEN=1024K -Wl,--defsym=RAM_LEN=128K -o q100a.elf \
        /v/test100a.c /v/model100a.c /v/qemu100a.c $SRC 2>&1
    echo "build_qemu=$?"
    timeout 300 qemu-system-arm -M netduinoplus2 -nographic -semihosting -kernel q100a.elf -monitor none -serial none 2>&1 \
        | tr -d '\r' > q.txt
    echo "qemu_exit=$?"
    sed 's/^/q| /' q.txt
    ;;
esac
cd /
rm -rf $B

Next: section 100б "VL53L0X laser rangefinder: time of flight over I2C, accuracy and speed modes, several sensors on one bus" — light instead of radio waves: millimetres over I2C, timing budget against noise and three sensors on one bus.

Open in the interactive handbook