HC-12 Packet Loss and Retry Protocol
Hardware used in this build
- HC-12 433 MHz wireless module
HiLetgo HC-12 433 MHz SI4438 module with antenna. Reliable for 100–1 000 m links. - ESP32-C3 dev board
The shed node MCU. Runs the AI_optimized_shed_TX.ino sketch. Seeed's own board. - Raspberry Pi 4
Used in the running shed build; works reliably.
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 4 → ESP32-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]
| Field | Bytes | Purpose |
|---|---|---|
| 0x7E | 1 | Start delimiter |
| LEN | 2 | Big-endian payload length (max 255 bytes) |
| CMD | 1 | Message type identifier |
| PAYLOAD | LEN | Variable payload |
| CRC-16 | 2 | CRC-16-CCITT over CMD + PAYLOAD (initial 0xFFFF) |
| 0x7E | 1 | End 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
| Property | Raw UART (no framing) | Application-layer framed |
|---|---|---|
| Corruption detection | None | CRC-16 per packet |
| Framing recovery | Impossible if byte lost | Delimiter resyncs parser |
| Retry on loss | None | Configurable N retries + backoff |
| Bandwidth overhead | 0 bytes | 7 bytes per packet |
| Latency penalty | 0 ms | One round-trip per lost packet |
| Implementation complexity | Trivial | Moderate (~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:
- Round-trip latency at your link distance: timestamp at send, timestamp at response, subtract the node’s own processing time.
- Packet error rate per channel: run 1000 request/response cycles on each of the HC-12’s 100 channels and compare. A nearby 433 MHz source such as a wireless doorbell or a neighbor’s weather station can dominate on a specific channel. Wi-Fi operates at 2.4/5/6 GHz and does not interfere with 433 MHz directly, but a crowded 2.4 GHz environment may push you toward 433 MHz in the first place.
- Payload size vs drop rate: the HC-12 module’s host-side UART buffer size is not published in the Si4463 datasheet (that chip is a radio core with SPI interface and 64-byte radio FIFOs). Measure your own maximum reliable payload at your chosen baud rate by sending incrementally larger frames and watching for CRC failures.
- Interference floor: use an RTL-SDR or similar to scan 433.4–473.0 MHz and identify the quietest channel for your location.
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
- ESP32-C3 Deep Sleep Current: What the Datasheet Doesn't Tell You About Your Dev Board
The ESP32-C3 datasheet promises 5–7 µA deep sleep. Your dev board draws thousands of times more. Here is what is actually leaking current in a XIAO ESP32C3 and how to measure it yourself.
- An asyncio Collector Pattern for Mixed-Protocol Sensor Fleets
Why asyncio does not make 1-Wire faster, and the collector pattern I actually run: per-plugin loops, to_thread for blocking buses, and one batched SQLite writer.
- Polling Google Nest Thermostats from Python Without Tripping SDM API Rate Limits
Nest SDM API rate limits are 5 QPM / 100 QPH per device. How I poll five thermostats from Python without tripping quotas, using devices.list and exponential backoff on 429.