The HESP protocol¶
HESP is the binary request/response protocol on the 19200 baud bus (X7). The name and the basic telegram structure are described in an FHEM forum thread (topic 96437) on the related Hermes / P series; the frame structure and the checksum documented here were determined from our own bus recordings.
Physical layer¶
- 19200 baud, 8N1, RS485 half duplex, differential A/B pair on X7-4/X7-3.
- The master is the STM32 of the PTC module; it polls the LPC1758 about every 5 seconds and re-asserts the current panel state (stage / mode / setpoints) as it does.
Frame format¶
Every frame:
<h0> <h1> <h2> <id_lo> <id_hi> 00 00 <len> <data …> <crc_lo> <crc_hi>
| Field | Bytes | Meaning |
|---|---|---|
h0 h1 h2 |
3 | Address header, 24-bit little-endian: type, source and destination address (see below) |
id_lo id_hi |
2 | Data point ID, 16-bit little-endian |
00 00 |
2 | Reserved / sub-selector |
len |
1 | Nibble count of the data (0x08=4 B, 0x20=16 B, 0xe0=224 B) |
data |
n | Payload (float32 LE, int, array …) |
crc |
2 | Checksum (little-endian) |
The address header¶
The first three bytes presumably form a 24-bit word H = h0 | h1 << 8 | h2 << 16 with
three fields:
| Bits | Field | Width |
|---|---|---|
| 0–3 | Type (see below) | 4 bit |
| 4–13 | Source address | 10 bit |
| 14–23 | Destination address | 10 bit |
This reading would explain the bytes previously described as a "fixed remainder of the header" or as "node-dependent": request and response swap source and destination address.
| Header | Type | Source → destination |
|---|---|---|
10 80 00 |
QUERY | 1 → 2 |
22 40 00 |
RESPONSE | 2 → 1 |
11 80 00 |
SET | 1 → 2 |
23 40 00 |
CONFIRM | 2 → 1 |
10 80 07 |
QUERY | 1 → 30 |
10 00 0a |
QUERY | 1 → 40 |
Address 1 is the polling master. The decomposition is consistent with recordings from a second unit carrying further bus participants (addresses 10–15, 30 and 40 among others): there, every response carries the swapped addresses of the preceding request. No confirmation from manufacturer documentation is available.
The type nibble¶
The low nibble of the first byte encodes the kind of telegram — exactly as documented in the forum and confirmed on the wire:
| Nibble | Type | Header example |
|---|---|---|
0 |
QUERY | 10 80 00 … |
1 |
SET (write) | 11 80 00 … |
2 |
RESPONSE | 22 40 00 … |
3 |
CONFIRM (write acknowledgement) | 23 40 00 … |
Examples¶
QUERY 10 80 00 60 01 00 00 04 01 00 49 e1 # query DP 0x0160
RESPONSE 22 40 00 d2 00 00 00 20 …16 B… 5a cb # response carrying 16 B of data
SET 11 80 00 27 02 00 00 08 00 00 98 41 8a cd # set DP 0x0227 = 19.0 (float32)
CONFIRM 23 40 00 27 02 00 00 00 39 a1 # write acknowledged
Payloads are IEEE-754 float32 little-endian for temperatures and setpoints, otherwise integers (RPM, counters, flags) or arrays (characteristic curves).
Checksum¶
The last two bytes of every frame form a CRC-16 over all preceding bytes (header through payload inclusive), stored little-endian:
| Parameter | Value |
|---|---|
| Polynomial | 0x8048 in reflected form (LSB first; normal: 0x1201) |
| Init value | 0xFFFF |
| Final XOR | none |
| Shift steps per byte | 16 |
The peculiarity is the number of shift steps: after a byte has been mixed in, the
register is shifted 16 times instead of 8. This is equivalent to each data byte entering
an ordinary reflected CRC-16 as a 16-bit word with high byte 0x00. That quirk explains
why a search over standard parameter sets (all polynomials, reflection variants, byte
orders) yields no match.
def hesp_crc(data: bytes) -> int:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(16):
crc = (crc >> 1) ^ 0x8048 if crc & 1 else crc >> 1
return crc # in the frame: crc & 0xFF, then crc >> 8
Verified against every example on this page as well as against some 6000 frames from a second unit (all four telegram types, frame lengths 10–134 bytes) without deviation. The parameters were derived from recordings; no confirmation from manufacturer documentation is available.
Relation to the affine GF(2) model¶
An earlier version of this page described the checksum empirically as an affine map over
GF(2) with a table of contribution vectors V[e]. The linearity is a direct consequence
of the CRC. The table was not uniquely determined, however: bits that were always set
together, or held constant, in the training material cannot be separated from the
length-dependent constant. The constant is in fact not zero but the CRC of an
all-zero frame of the same length (for example 27d8 for 10 bytes, edc9 for 12 bytes).
The table therefore produced correct results for the observed bit patterns, but not for
arbitrary frames. The contribution vectors follow from the algorithm as
V[e] = crc(frame with only bit e set) ⊕ crc(all-zero frame) and depend only on e, not
on the frame length.
Table of contribution vectors V[e] (from the algorithm, e = 0–95)
Bit position counted from the end of the frame: for byte i (0-based), bit b
(0 = LSB) of a frame of N bytes excluding the CRC, e = (N − 1 − i) · 8 + b. Then
CRC = K(N) ⊕ (⊕ V[e] over all set bits), where K(N) is the CRC of an all-zero frame of length N.
e |
V[e] |
e |
V[e] |
e |
V[e] |
e |
V[e] |
|---|---|---|---|---|---|---|---|
| 0 | d1f4 |
1 | a379 |
2 | 4663 |
3 | 8cc6 |
| 4 | 191d |
5 | 323a |
6 | 6474 |
7 | c8e8 |
| 8 | 80fc |
9 | 0169 |
10 | 02d2 |
11 | 05a4 |
| 12 | 0b48 |
13 | 1690 |
14 | 2d20 |
15 | 5a40 |
| 16 | cd56 |
17 | 9a3d |
18 | 34eb |
19 | 69d6 |
| 20 | d3ac |
21 | a7c9 |
22 | 4f03 |
23 | 9e06 |
| 24 | 0534 |
25 | 0a68 |
26 | 14d0 |
27 | 29a0 |
| 28 | 5340 |
29 | a680 |
30 | 4d91 |
31 | 9b22 |
| 32 | b823 |
33 | 70d7 |
34 | e1ae |
35 | c3cd |
| 36 | 870b |
37 | 0e87 |
38 | 1d0e |
39 | 3a1c |
| 40 | 78a8 |
41 | f150 |
42 | e231 |
43 | c4f3 |
| 44 | 8977 |
45 | 127f |
46 | 24fe |
47 | 49fc |
| 48 | 8e67 |
49 | 1c5f |
50 | 38be |
51 | 717c |
| 52 | e2f8 |
53 | c561 |
54 | 8a53 |
55 | 1437 |
| 56 | 0c91 |
57 | 1922 |
58 | 3244 |
59 | 6488 |
| 60 | c910 |
61 | 92b1 |
62 | 25f3 |
63 | 4be6 |
| 64 | cc6b |
65 | 9847 |
66 | 301f |
67 | 603e |
| 68 | c07c |
69 | 8069 |
70 | 0043 |
71 | 0086 |
| 72 | a403 |
73 | 4897 |
74 | 912e |
75 | 22cd |
| 76 | 459a |
77 | 8b34 |
78 | 16f9 |
79 | 2df2 |
| 80 | 96f1 |
81 | 2d73 |
82 | 5ae6 |
83 | b5cc |
| 84 | 6b09 |
85 | d612 |
86 | acb5 |
87 | 59fb |
| 88 | a03b |
89 | 40e7 |
90 | 81ce |
91 | 030d |
| 92 | 061a |
93 | 0c34 |
94 | 1868 |
95 | 30d0 |
N |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
|---|---|---|---|---|---|---|---|
K(N) |
81dd |
bfd9 |
27d8 |
ee21 |
edc9 |
4cfb |
f5be |
Worked example¶
Query frame for data point 0x0160 (see the examples above):
>>> hex(hesp_crc(bytes.fromhex("10 80 00 60 01 00 00 04 01 00")))
'0xe149'
→ crc_lo = 49, crc_hi = e1; the complete frame on the bus is therefore
10 80 00 60 01 00 00 04 01 00 49 e1
which matches the recording exactly.
Meta data points (self-description)¶
The bus describes itself in part:
| DP | Content |
|---|---|
0x0001 |
Plain-text name of the node (e.g. node 30 = "PTC module") |
0x0033 |
List of all parameter DP numbers (directory) |
0x0034 |
Parallel list of type codes for it |
0x0038 |
Access level mask per DP (see below) |
0x0039 |
Unit code per DP (8=°C, 5=seconds, 11=string …) |
0x0130 |
Fault status as plain text ("no fault") |
Node numbers
The node number is to be taken from the address header (see above), not from a single
header byte: the PTC module's responses begin with e2 41 00, which under the 24-bit
decomposition means RESPONSE from node 30 to node 1. An earlier version of this page
read the middle byte as a node ID ("node 0x41").
Access mask 0x0038¶
One byte per DP encodes the write permissions across four access levels (2 bits R/W each):
| Mask | Meaning | Applies to |
|---|---|---|
0x55 |
read-only | all measured values, counters |
0xFF |
writable everywhere | the user values set from the panel (room setpoint etc.) |
0xD5 |
writable at the top level only | the installer parameter block |
0xC0 |
visible at the top level only | factory characteristic curves, fan arrays |
The mask independently confirms which DPs make up the (PIN-protected) installer menu — useful to avoid writing blindly into protected registers when parameterising.