SensorRig

HC-12 Packet Loss and Retry Protocol

18 August 2026 · 9 min read

Hardware used in this build

  • HC-12 433 MHz wireless module — Radio link shed <-> Pi 4
    HiLetgo HC-12 433 MHz SI4438 module with antenna. Reliable for 100–1 000 m links.
  • ESP32-C3 dev board — Shed node MCU
    The shed node MCU. Runs the AI_optimized_shed_TX.ino sketch. Seeed's own board.
  • Raspberry Pi 4 — 1-Wire master + villa collector
    Used in the running shed build; works reliably.

Full parts list & affiliate disclosure →

The problem

The HC-12 module is marketed as a wireless serial link. You wire it to a UART, set the same baud rate on both ends, and bytes flow. That framing works perfectly — until a byte is corrupted or a channel glitch drops a transmission entirely. There is no CRC at the module level, no retry, no acknowledgement, and no way to tell from the receiving UART alone whether the data that arrived is intact.

On my Pi 4ESP32-C3 link using HC-12 433 MHz serial radio through interior walls, the failure mode is not constant bit-flipping. It is whole-packet loss. A burst of UART data arrives or it does not. Sometimes the HC-12 module on the transmitter side pushes a frame out while the receiver side misses the preamble entirely because another nearby 433 MHz source was transmitting on an adjacent channel. The Si4463 radio inside the HC-12 supports hardware CRC-16 (CRC-CCITT or CRC-16/IBM) per its datasheet, but the module firmware does not expose that through the AT command set in a reliable way across all production firmware versions. I tested three different HC-12 boards and only one of them exposed CRC checking via AT+CRC; the other two ignored the command silently. You cannot trust the radio to validate frames for you.

The solution is to add a framing layer above the raw UART and implement retry logic at the application level.


Framing format

Every packet gets a 4-byte header, a CRC-16-CCITT appended to the payload, and a one-byte footer delimiter. This lets the receiver detect framing errors (lost or extra bytes) and payload corruption independently.

[0x7E] [LEN_H] [LEN_L] [CMD] [PAYLOAD...] [CRC_H] [CRC_L] [0x7E]
FieldBytesPurpose
0x7E1Start delimiter
LEN2Big-endian payload length (max 255 bytes)
CMD1Message type identifier
PAYLOADLENVariable payload
CRC-162CRC-16-CCITT over CMD + PAYLOAD (initial 0xFFFF)
0x7E1End delimiter

The double 0x7E delimiter is deliberate. If the receiver sees 0x7E anywhere inside the payload or CRC field, it is a corruption event. The length field lets us skip forward reliably if we land in the middle of a burst. This scheme does not escape 0x7E inside the payload; if your sensor data can contain that byte, you must implement byte stuffing (e.g., replace 0x7E with 0x7D 0x5E) before building the frame.

CRC-16-CCITT is the right choice here. It is the polynomial used by X.25 and many embedded protocols. In Python, crcmod with poly=0x1021 and initCrc=0xFFFF computes it.


Python implementation

On the Pi 5 side (collector), I use asyncio with a StreamReader / StreamWriter pair over the HC-12 UART. The ESP32-C3 node (transmitter) uses the same framing with a blocking write and timeout.

CRC and framing helpers

import crcmod.predefined
import struct

crc16 = crcmod.predefined.mkCrcFun('crc-ccitt-false')  # poly=0x1021, init=0xFFFF

DELIM = 0x7E
MIN_FRAME = 7  # delim + 2 len + 1 cmd + 2 crc + delim

def build_frame(cmd: int, payload: bytes) -> bytes:
    if not (0 <= cmd <= 0xFF):
        raise ValueError("cmd must fit in one byte")
    if len(payload) > 255:
        raise ValueError("payload too long for 1-byte length")
    header = bytes([DELIM, 0x00, len(payload), cmd])
    crc = crc16(bytes([cmd]) + payload)
    crc_bytes = struct.pack('>H', crc)
    return header + payload + crc_bytes + bytes([DELIM])

def parse_frame(data: bytes) -> tuple[int, bytes]:
    """Return (cmd, payload) from a single frame in data."""
    if len(data) < MIN_FRAME:
        raise ValueError("frame too short")
    if data[0] != DELIM or data[-1] != DELIM:
        raise ValueError("missing delimiters")
    length = struct.unpack('>H', data[1:3])[0]
    if length > 255:
        raise ValueError("invalid length")
    if len(data) < MIN_FRAME + length:
        raise ValueError("incomplete frame")
    cmd = data[3]
    payload = data[4:4 + length]
    expected_crc = struct.unpack('>H', data[4 + length:6 + length])[0]
    computed_crc = crc16(bytes([cmd]) + payload)
    if expected_crc != computed_crc:
        raise ValueError(f"CRC mismatch: expected {expected_crc:#06x}, got {computed_crc:#06x}")
    return cmd, payload

Retry protocol

The collector sends a command; the node responds. If the collector does not receive a valid frame within a timeout window, it retries up to N times with exponential backoff before giving up.

import asyncio

class HC12Protocol:
    def __init__(self, reader: asyncio.StreamReader,
                 writer: asyncio.StreamWriter,
                 max_retries: int = 3,
                 base_timeout: float = 0.5,
                 backoff_factor: float = 2.0):
        self.reader = reader
        self.writer = writer
        self.max_retries = max_retries
        self.base_timeout = base_timeout
        self.backoff_factor = backoff_factor

    async def request(self, cmd: int, payload: bytes = b'') -> bytes:
        timeout = self.base_timeout
        for attempt in range(self.max_retries):
            try:
                frame = build_frame(cmd, payload)
                self.writer.write(frame)
                await self.writer.drain()
                raw = await asyncio.wait_for(self._read_frame(), timeout=timeout)
                return raw
            except asyncio.TimeoutExpired:
                if attempt == self.max_retries - 1:
                    raise
                timeout *= self.backoff_factor
                await asyncio.sleep(0.05)
        raise RuntimeError("unreachable")

    async def _read_frame(self) -> bytes:
        # Wait for start delimiter
        while True:
            b = await self.reader.readexactly(1)
            if b[0] == DELIM:
                break
        # Read rest: LEN(2) + CMD(1) + PAYLOAD(LEN) + CRC(2) + DELIM(1)
        length = struct.unpack('>H', await self.reader.readexactly(2))[0]
        body = await self.reader.readexactly(1 + length + 2 + 1)
        if body[-1] != DELIM:
            raise ValueError("bad end delimiter")
        return bytes([DELIM]) + struct.pack('>H', length) + body

Three retries with 500 ms base timeout and 2× backoff works for links where the round-trip is under 1 second. Measure your own round-trip by timestamping at send and at response, then subtract the node’s own processing time. The HC-12 module’s TX-to-RX switching delay is specified at 65 µs for the Si4463 radio core (Si4463 datasheet, typical value); module-level latency is higher because of the STC microcontroller and UART buffering. At 9600 baud UART, the serial transmission time for an 11-byte frame dominates the per-packet latency. For the HC-12’s FU3 over-air rate of 15 kbps, the air time for that same frame is ~5.9 ms, so the UART serialization is the bottleneck at 9600 baud — but at lower UART rates the air time can dominate. Measure both legs on your own link.


Failure modes and what went wrong

1. Spurious byte during channel scan. When the HC-12 is in AT+RESET or AT+TEST mode, it can emit diagnostic strings on the UART that look like garbage to the parser. I added a while True read-ahead loop in _read_frame that discards bytes until it sees 0x7E. Without that, a stray byte from a misconfigured node would poison the parse state and reject the next valid frame.

2. Framing desync on partial sends. If the host UART is faster than the HC-12 module can drain its UART FIFO, the module drops bytes silently. I observed this when sending a 128-byte payload at 115200 baud on a Pi 4 with CPU throttling. The Si4463 radio core has 64-byte TX/RX FIFOs (Si4463 datasheet); anything larger risks overflow if the host does not pace writes. My fix was to lower the baud rate to 9600 and cap payloads at 32 bytes for sensor readings — a value chosen empirically from observed drops at 128 bytes, not derived from a datasheet spec.

3. Retry storm on persistent interference. If a strong 433 MHz source is interfering on the chosen channel, retries all fail and the collector blocks for the sum of all timeout windows. With max_retries=3, base_timeout=0.5 s, and backoff_factor=2.0, the worst-case wait is 0.5 + 1.0 + 2.0 = 3.5 seconds per lost sensor node per cycle. On a house with five Nest thermostats and multiple ESP32-C3 collectors, this added up. I now track consecutive failures per node and back off the polling interval for that node instead of hammering retries.

4. CRC-16 variant mismatch. I initially used CRC-16/IBM (polynomial 0x8005) because one Stack Overflow thread suggested it. The receiving node ran CRC-16-CCITT (0x1021) and all frames failed validation silently. The two polynomials catch different error patterns. I locked both ends to crc-ccitt-false (init 0xFFFF) and validated with a known payload.


Comparison: raw UART vs framed protocol

PropertyRaw UART (no framing)Application-layer framed
Corruption detectionNoneCRC-16 per packet
Framing recoveryImpossible if byte lostDelimiter resyncs parser
Retry on lossNoneConfigurable N retries + backoff
Bandwidth overhead0 bytes7 bytes per packet
Latency penalty0 msOne round-trip per lost packet
Implementation complexityTrivialModerate (~80 lines Python)

The 7-byte overhead per packet is negligible for sensor telemetry. A DS18B20 reading plus a 1-byte node ID and 1-byte sequence counter is 4 bytes payload; the framed packet is 11 bytes. At 9600 baud with 8-N-1 framing, an 11-byte frame takes about 11.5 ms to transmit — fine for a 5-second polling interval.


What to measure before settling the numbers

If you are adapting this to your own HC-12 link, measure these instead of copying my settings:


Honest limitations

This protocol fixes corruption detection and retry, but it does not fix throughput. The HC-12 is half-duplex. Only one end can transmit at a time. If you need simultaneous bi-directional telemetry, you need two channels or a different radio (e.g., LoRa). The Si4463 supports sub-GHz up to 250 kbps (Si4463 datasheet); the HC-12 firmware typically caps you at 9600–38400 baud UART rates depending on the selected transmission mode. I have not measured bit-error rate versus received signal strength across those intermediate rates.

I also have not measured bit-error rate versus received signal strength. The Si4463 datasheet gives sensitivity curves for 2.4 kbps and 250 kbps, but HC-12 modules run at intermediate baud rates where I do not have a clean curve. If your application requires a quantified link budget, measure RSSI from the HC-12’s AT+RSSI? response against a known path loss.

Security. This protocol has no authentication and no encryption. Any receiver on the same channel within range can inject frames. If your data is sensitive or you need to prevent spoofing, add a message authentication code or switch to a radio that supports AES at the hardware level (some LoRa modules include AES accelerators, though software AES on the MCU is also common).

Regulatory. 433 MHz ISM-band use carries country-specific duty-cycle and power limits. In the EU, the 433 MHz band is limited to 25 mW ERP and 10% duty cycle (ERC 70-03). In the US, the 420–450 MHz allocation is primarily amateur radio (Part 97); unlicensed Part 15 operation in adjacent bands has its own constraints. Check your local regulator before deploying.

Related reading