Skip to content

Decoding Data Fields

brucemiranda edited this page Nov 14, 2023 · 41 revisions

Temperatures

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 and Percentages

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)

Strings

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 & Datetimes

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

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

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 IDs

Device types

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.

Device Number to ID

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}"

Device ID to Number

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
Clone this wiki locally