-
Notifications
You must be signed in to change notification settings - Fork 9
Decoding Data Fields
Temperatures and setpoints are encoded as degrees Celsius to two decimal places.
They are stored as 2 byte, two's-complement numbers, so that negative numbers (e.g. outside temperature) can be stored. There a reserved values for unknown temps, and unset setpoints.
def _temp(value: str) -> Union[float, bool, None]:
"""Temperatures/Setpoints are two's complement numbers."""
assert len(value) == 4
if value== "31FF": # also: FFFF?, means: N/A (== 127.99, 2s complement)
return None
if value== "7EFF": # TODO: possibly only for setpoints?
return False
if value== "7FFF": # also: FFFF?, means: N/A (== 327.67)
return None
temp = int(value, 16)
return (temp if temp < 2 ** 15 else temp - 2 ** 16) / 100
Thus, 07DC
becomes 20.12 °C, and FDDC
becomes -5.48 °C.
Floats are encoded as 1 or 2-byte unsigned integers that must be converted to a float by dividing by 100; thus, they have a precision of 0.01. Invalid values are encoded as FF
, 7F
or 7FFF
.
Some percentages are encoded with a precision of 0.5 (i.e. half a percent, 0.005). They are stored as unsigned integers from 00
to C8
('normal' percentages are to 64
) and must be halved to get the percentage.
def _percent(value: str) -> Optional[float]:
assert len(value) == 2
if value == "FF": # TODO: also FE?
return None
assert int(value, 16) <= 200
return int(value, 16) / 200
Note that some data fields can only be 00
(off/false, 0%) or C8
(on/true, 100%):
def _bool(value: str) -> Optional[bool]: # either 00 or C8
assert value in ["00", "C8", "FF"]
return {"00": False, "C8": True}.get(value)
It appears all strings are encoded using standard ASCII using printable characters between 32 and 126 (inclusive).
def _string(value: str) -> Optional[str]: # printable
_string = bytearray([x for x in bytearray.fromhex(value) if 31 < x < 127])
return _string.decode("ascii") if _string else None
Dates and datetimes are stored in local time, using three distinct structures. Note that packets 0404
(schedule) and 0418
(system fault) both use different schemes for encoding datetimes.
Dates (no time) are stored as 4-byte fields, and FFFFFFFF
is an invalid date.
def _date(value: str) -> Optional[str]: # YY-MM-DD
assert len(value) == 8
if value == "FFFFFFFF":
return None
return dt(
year=int(value[4:8], 16),
month=int(value[2:4], 16),
day=int(value[:2], 16) & 0b11111, # 1st 3 bits: DayOfWeek
).strftime("%Y-%m-%d")
These are used by 10E0
packets.
Datetimes can be stored without seconds (6 bytes) or with seconds (7 bytes).
def _datetime(value: str) -> str: # YY-MM-DD HH:MM:SS
assert len(value) in [12, 14]
if len(value) == 12:
value = f"00{value}"
return dt(
year=int(value[10:14], 16),
month=int(value[8:10], 16),
day=int(value[6:8], 16),
hour=int(value[4:6], 16) & 0b11111, # 1st 3 bits: DayOfWeek
minute=int(value[2:4], 16),
second=int(value[:2], 16) & 0b1111111, # 1st bit: DST
).strftime("%Y-%m-%d %H:%M:%S")
The shorter 6-byte version is used by the 'mode' packets: 1F41
(DHW mode), 2349
(zone mode), and 2E04
(system mode). The longer 7-bytes version (which contains seconds) is used by 313F
(sync datetime)
Thus, 00141B0A07E3
becomes 2019-10-27 20:00:00, and 0400041C0A07E3
becomes 2019-10-28 04:00:04.
DEVICE_MAP = {
"00": "00", # Some HR80 radiator controllers
"01": "CTL", # Controller
"02": "UFH", # Underfloor heating (HCC80, HCE80)
"03": " 30", # HCW80, HCW82??
"04": "TRV", # Thermostatic radiator valve (HR80, HR91, HR92)
"07": "DHW", # DHW sensor (CS92)
"08": "08", # Jasper HVAC Thermostat
"10": "OTB", # OpenTherm bridge (R8810)
"12": "THm", # Thermostat with setpoint schedule control (DTS92E)
"13": "BDR", # Wireless relay box (BDR91) (HC60NG too?)
"17": " 17", # Dunno - Outside weather sensor?
"18": "HGI", # Honeywell Gateway Interface (HGI80, HGS80)
"22": "THM", # Thermostat with setpoint schedule control (DTS92E)
"30": "GWY", # Nuaire Drimaster PIV (DRI-ECO-HEAT-HC)
"31": "31", # Dunno - ??
"32": "VNT", # (HCE80) Ventilation (Nuaire VMS-23HB33, VMN-23LMH23)
"34": "STA", # Thermostat (T87RF)
"63": "NUL", # Broadcast message
"--": " --",
} # Mixing valve (HM80)
DEVICE_LOOKUP = {v: k for k, v in DEVICE_MAP.items()}
Other device types have been seen (32), but are not yet well understood.
def dev_hex_to_id(device_hex: str, friendly_id=False) -> str:
"""Convert (say) '06368E' to '01:145038' (or 'CTL:145038')."""
if device_hex == "FFFFFF": # aka '63:262143'
return f"{'':10}" if friendly_id else f"{'':9}"
if not device_hex.strip(): # aka '--:------'
return f"{'':10}" if friendly_id else "--:------"
_tmp = int(device_hex, 16)
dev_type = f"{(_tmp & 0xFC0000) >> 18:02d}"
if friendly_id:
dev_type = DEVICE_MAP.get(dev_type, f"{dev_type:<3}")
return f"{dev_type}:{_tmp & 0x03FFFF:06d}"
def dev_id_to_hex(device_id: str) -> str:
"""Convert (say) '01:145038' (or 'CTL:145038') to '06368E'."""
if len(device_id) == 9: # e.g. '01:123456'
dev_type = device_id[:2]
else: # len(device_id) == 10, e.g. 'CTL:123456', or ' 63:262143'
dev_type = DEVICE_LOOKUP.get(device_id[:3], device_id[1:3])
return f"{(int(dev_type) << 18) + int(device_id[-6:]):0>6X}" # sans preceding 0x
0001
RF Check0004
Zone name0008
Relay demand0009
Relay failsafe000A
Zone config000C
Zone actuators0016
RF signal test0100
Localisation0404
Zone schedule0418
System fault1030
Mix zone config1060
Battery state10A0
DHW setpoint10E0
Device info1100
Boiler relay info1260
DHW temperature12B0
Window sensor1F09
System Sync1F41
DHW mode1FC9
RF bind1FD4
Opentherm ticker22C9
UFH setpoint22D9
Boiler setpoint2309
Zone setpoint2349
Setpoint override2E04
Controller mode30C9
Zone temperature313F
System datetime3150
Heat demand3220
Opentherm message3B00
Actuator sync3EF0
Actuator info3EF1
Actuator unknown
0002
External sensor0005
Zone management0006
Schedule sync000E
Unknown01D0
Unknown01E9
Unknown042F
Unknown1280
Outdoor humidity1290
Outdoor temp12A0
Indoor humidity2249
Now/next setpoint22D0
UFH unknown22F1
Ventilation unit command22F3
Ventilation unit temp. high2389
Unknown2D49
Unknown3120
Unknown31D9
HVAC Unknown31DA
HVAC Unknown31E0
HVAC Unknown