""" Minimal HART data-link frame parser. Parses a raw HART frame (as bytes or a hex string) into its component fields and verifies the checksum. Works for both short-frame and long-frame addressing, and for both request and response frames. This only handles the DATA LINK layer (framing). It does not decode the meaning of the data field itself -- that depends on the specific command number and requires the command's own spec (see the command tables from FieldComm Group, or a device manual). """ from dataclasses import dataclass, field from typing import Optional # Delimiter frame-type codes (bits 2-0 of the delimiter byte) FRAME_TYPE = { 0x01: "BACK (burst message from slave)", 0x02: "STX (master to slave, request)", 0x06: "ACK (slave to master, response)", } RESPONSE_TYPES = {0x06, 0x86, 0x01, 0x81} # ACK / BACK carry status bytes @dataclass class HartFrame: preamble_len: int delimiter: int is_long_frame: bool frame_type: str address: bytes command: int byte_count: int response_code: Optional[int] device_status: Optional[int] data: bytes checksum: int checksum_ok: bool raw: bytes = field(repr=False) def _to_bytes(frame) -> bytes: if isinstance(frame, str): frame = frame.replace(" ", "").replace("0x", "") return bytes.fromhex(frame) return bytes(frame) def parse_hart_frame(frame) -> HartFrame: raw = _to_bytes(frame) # 1. Skip preamble (leading 0xFF bytes) i = 0 while i < len(raw) and raw[i] == 0xFF: i += 1 preamble_len = i # 2. Delimiter delimiter = raw[i] i += 1 is_long_frame = bool(delimiter & 0x80) frame_type = FRAME_TYPE.get(delimiter & 0x07, f"unknown (0x{delimiter & 0x07:02X})") # 3. Address (1 byte short frame, 5 bytes long frame) addr_len = 5 if is_long_frame else 1 address = raw[i:i + addr_len] i += addr_len # 4. Command command = raw[i] i += 1 # 5. Byte count (bytes remaining, excluding checksum) byte_count = raw[i] i += 1 payload_start = i payload = raw[payload_start:payload_start + byte_count] # 6. Status bytes only present in response frames (ACK / BACK) response_code = None device_status = None data = payload if delimiter in RESPONSE_TYPES and len(payload) >= 2: response_code = payload[0] device_status = payload[1] data = payload[2:] i = payload_start + byte_count # 7. Checksum checksum = raw[i] computed = 0 for b in raw[preamble_len:i]: # delimiter through end of data field computed ^= b checksum_ok = (computed == checksum) return HartFrame( preamble_len=preamble_len, delimiter=delimiter, is_long_frame=is_long_frame, frame_type=frame_type, address=address, command=command, byte_count=byte_count, response_code=response_code, device_status=device_status, data=data, checksum=checksum, checksum_ok=checksum_ok, raw=raw, ) def pretty_print(f: HartFrame) -> None: print(f"Preamble length : {f.preamble_len} bytes") print(f"Delimiter : 0x{f.delimiter:02X} ({f.frame_type}, " f"{'long' if f.is_long_frame else 'short'} frame)") print(f"Address : {f.address.hex(' ').upper()}") print(f"Command : {f.command} (0x{f.command:02X})") print(f"Byte count : {f.byte_count}") if f.response_code is not None: err_flag = "COMM ERROR" if f.response_code & 0x80 else "ok" print(f"Response code : 0x{f.response_code:02X} ({err_flag})") print(f"Device status : 0x{f.device_status:02X}") print(f"Data : {f.data.hex(' ').upper()}") print(f"Checksum : 0x{f.checksum:02X} " f"({'valid' if f.checksum_ok else 'INVALID'})") if __name__ == "__main__": # Example: response to Command 3 (Read Dynamic Variables and Loop Current) # Long frame, address A6 55 69 52 E4, command 03, byte count 1A (26), # status 00 00 (ok), then loop current + PV unit + PV + ... , checksum 00 example_response = ( "FF FF FF FF " "86 A6 55 69 52 E4 " "03 1A " "00 00 " "40 80 11 92 04 3F 2B 95 3B " "20 41 D9 1F C0 F0 38 DD 5B 00 20 41 D9 1F C0 " "00" ) frame = parse_hart_frame(example_response) pretty_print(frame)