Guest User

Untitled

a guest
Jul 19th, 2026
54
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 31.67 KB | Cybersecurity | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. Reverse-engineered parser for Logitech Harmony .EZHex update packages
  4. (EasyZapper protocol, board 1.8.0 / protocol 8 / skin 15 samples).
  5.  
  6. Confirmed by cross-referencing 4 sample files of different sizes/content:
  7.  - XML "INFORMATION" header, then a binary payload:
  8.      TPTP <91-byte pointer/size table>
  9.      WLWL <capability/flag table, ~56-60 short records>
  10.      ...opaque region, ~10% of which is periodic padding/reserved flash
  11.      space (see find_padding_runs) rather than real content...
  12.      0xA7-tagged symbol table (Activity Engine state names) -> terminated by 0xBEEF
  13.      ...further opaque region, real per-device IR waveform data mixed
  14.      with more padding...
  15.      DKDK  <- last 4 bytes of the file, end marker
  16.  
  17. Confidence levels are noted per field. Anything not explicitly marked
  18. "confirmed" is a working hypothesis based on cross-file comparison, not a
  19. verified spec.
  20.  
  21. IMPORTANT CAVEAT: a full-file scan (see find_all_tlv_runs equivalent used
  22. during analysis) confirms there is exactly ONE human-readable string table
  23. in this format -- the 0xA7 symbol table below. Device/button names (e.g.
  24. "Sony", "Volume Up") are NOT stored in this file at all; only generic
  25. Activity Engine state identifiers are. The remaining non-padding bytes are
  26. numeric IR waveform/protocol data with no known public spec -- decoding
  27. them into verified microsecond timings requires a known-plaintext sample
  28. (two captures differing by exactly one code) that isn't available from
  29. static analysis of these 4 files alone.
  30.  
  31. RULED OUT (see check_against_known_protocols): a 363k-code ground-truth IR
  32. catalog (local ir_catalog.db, brand/protocol/raw-timing per entry) was used
  33. to build canonical raw-timing headers for every major protocol family in
  34. the catalog (NEC/NECext/NEC1/NEC2/NECx1/NECx2, Kaseikyo/Panasonic, RC5/RC5X,
  35. RC6, Sony/SIRC, Samsung32 -- collectively covering the large majority of
  36. the catalog's 363,873 entries by protocol). Each 12-byte raw-timing header
  37. was searched byte-for-byte against all 4 sample files at all 8 possible
  38. bit alignments (not just byte-aligned). Zero matches, anywhere. Given a
  39. 12-byte (96-bit) pattern, a coincidental match at any of 8 shifts across
  40. a ~450KB file has effectively zero probability, so this is a confirmed
  41. elimination, not an inconclusive search: Harmony does NOT store raw
  42. protocol timing values directly (at any bit alignment) anywhere in this
  43. file. The real encoding is a genuine transform/compression, not a plain
  44. dump -- consistent with the low-but-nonzero entropy (~6.8 bits/byte) and
  45. skewed byte-frequency distribution measured in real_data_bytes().
  46.  
  47. CRACKED (see decode_nec_records): a content-aware diff between successive
  48. sample files (which are chronological snapshots of the same physical
  49. remote, each a superset of the last) isolated the literal bytes inserted
  50. when new devices were added -- effectively a free known-plaintext pair,
  51. without needing a fresh capture. Two of the inserted regions repeated with
  52. an exact 136-byte period; investigating one revealed:
  53.  
  54.    16 A1 86 10                          <- record header (4 bytes)
  55.    ( 24 82 22 06 | 24 82 f4 01 ) x 32   <- one bit per 4-byte token (0|1)
  56.    24 82 84 62                          <- record trailer (4 bytes)
  57.  
  58. repeated 3x back to back (each button's code sent 3x), followed by an
  59. 18-byte footer. The 32 decoded bits split into 4 bytes (addr, ~addr, cmd,
  60. ~cmd) -- the classic 8-bit NEC protocol structure, where byte1 must equal
  61. ~byte0 and byte3 must equal ~byte2. Tested against all 239 occurrences of
  62. the header across the sample files: 239/239 satisfy both complement checks
  63. (a coincidental pass has probability ~1/65536 per record, so this is a
  64. confirmed decode, not a pattern-matching guess). This is Harmony's own
  65. bit-level encoding of the *logical* NEC address/command payload -- it is
  66. NOT the same as storing raw microsecond timings (which was ruled out
  67. above), so actual IR transmission timing is presumably generated at
  68. runtime by dedicated NEC-encoder hardware/firmware from these decoded
  69. bits, not read from a stored waveform table.
  70.  
  71. Only 2 of the ~6-7 configured devices decode this way (address 0x5a with
  72. 42 commands, address 0xda with 21 commands) -- these are almost certainly
  73. the two devices added between Update.EZHex and Update(1).EZHex (TV_2 and
  74. AV_Receiver, going by the symbol-table diff). The remaining devices likely
  75. use a different protocol with a different header/bit-token pair that
  76. hasn't been found -- a second candidate header (9F E4 07 10) was located
  77. in the Update(1)->Update(2) diff (where Game_Console_(With_DVD) was
  78. added) but its content doesn't show the same clean 2-token alternation or
  79. a checksum to verify against, so it is NOT decoded, only flagged as a
  80. location worth investigating further.
  81. """
  82. import os
  83. import struct
  84. import sys
  85. from dataclasses import dataclass
  86.  
  87. IR_CATALOG_DB = os.environ.get(
  88.     "EZHEX_IR_CATALOG_DB",
  89.     "/Users/user/git/bt-to-all-devices/src/btad/ir_catalog/data/ir_catalog.db",
  90. )
  91.  
  92.  
  93. # ---------------------------------------------------------------- XML header
  94.  
  95. def split_header(data: bytes):
  96.     """Return (xml_header_bytes, binary_payload_bytes)."""
  97.     marker = b'</INFORMATION>'
  98.     idx = data.find(marker)
  99.     if idx == -1:
  100.         raise ValueError("not an EZHex file: missing </INFORMATION>")
  101.     bin_start = idx + len(marker) + 2  # + CRLF
  102.     return data[:bin_start], data[bin_start:]
  103.  
  104.  
  105. def extract_xml_fields(xml: bytes):
  106.     import re
  107.     fields = {}
  108.     for tag in (b'BOARD', b'FLASH', b'PROTOCOL', b'SKIN',
  109.                 b'BINARYDATASIZE', b'CHECKSUM', b'SERVER'):
  110.         m = re.search(rb'<%s>(.*?)</%s>' % (tag, tag), xml)
  111.         if m:
  112.             fields[tag.decode()] = m.group(1).decode(errors='replace')
  113.     return fields
  114.  
  115.  
  116. # ------------------------------------------------------------- TPTP header
  117.  
  118. @dataclass
  119. class TptpHeader:
  120.     raw_values: tuple
  121.     decompressed_size: int          # confirmed pattern: ~1.27-1.3x binary_len across all 4 samples
  122.     constant_field: int             # confirmed: literally 5376 in all 4 samples (board/protocol constant)
  123.     group_a: tuple                  # hypothesis: offsets to fixed-size metadata records
  124.     group_b: tuple                  # hypothesis: offsets that scale with device/activity content
  125.  
  126.  
  127. def parse_tptp(binary: bytes) -> TptpHeader:
  128.     if binary[0:4] != b'TPTP':
  129.         raise ValueError("expected TPTP marker at start of binary payload")
  130.     wlwl_off = binary.find(b'WLWL')
  131.     header = binary[4:wlwl_off]
  132.     n = len(header) // 4
  133.     vals = struct.unpack('<%dI' % n, header[:n * 4])
  134.  
  135.     # index 10 is a 0-value divider observed in all 4 samples; splits the
  136.     # offset table into two groups (see class docstring for rationale)
  137.     div_idx = vals.index(0) if 0 in vals else len(vals)
  138.     group_a = vals[2:div_idx]
  139.     group_b = vals[div_idx + 1:-1] if vals and vals[-1] == 0 else vals[div_idx + 1:]
  140.  
  141.     return TptpHeader(
  142.         raw_values=vals,
  143.         decompressed_size=vals[0],
  144.         constant_field=vals[1],
  145.         group_a=group_a,
  146.         group_b=group_b,
  147.     )
  148.  
  149.  
  150. # ------------------------------------------------------------- WLWL table
  151.  
  152. @dataclass
  153. class WlwlRecord:
  154.     offset: int          # byte offset within binary payload
  155.     id_byte: int
  156.     raw: bytes            # the 4 raw bytes of the record
  157.     simple: bool           # True if bytes[1:] == b'\x00\x00\x00' (plain flag/ID, no payload)
  158.  
  159.  
  160. def parse_wlwl(binary: bytes, max_records: int = 200) -> list:
  161.     """
  162.    Parses the short capability/flag table right after WLWL.
  163.    Format (confirmed structurally, semantics still a hypothesis):
  164.      1 stray length/count byte, then a sequence of 4-byte records:
  165.        - "simple" records: 1 ID byte + 3 zero bytes (flag present)
  166.        - "data" records: ID byte whose remaining 3 bytes carry a value
  167.                          that differs between sample files (e.g. counts)
  168.      The table ends once the byte pattern stops looking like small
  169.      sequential IDs (empirically ~56-60 records / ~230 bytes).
  170.    """
  171.     wlwl_off = binary.find(b'WLWL')
  172.     pos = wlwl_off + 4 + 1  # skip 'WLWL' + 1 stray byte (seen as 0x38 in samples)
  173.     records = []
  174.     for i in range(max_records):
  175.         off = pos + i * 4
  176.         word = binary[off:off + 4]
  177.         if len(word) < 4:
  178.             break
  179.         simple = (word[1] == 0 and word[2] == 0 and word[3] == 0)
  180.         # heuristic stop condition: once IDs stop being in the plausible
  181.         # single-byte enum range and simple-records dry up, we've left
  182.         # the flag table and entered the opaque compressed region.
  183.         if not simple and word[0] not in (0x06, 0x07, 0xab, 0x2d) and i > 45:
  184.             break
  185.         records.append(WlwlRecord(off, word[0], word, simple))
  186.     return records
  187.  
  188.  
  189. # ------------------------------------------------------- 0xA7 symbol table
  190.  
  191. @dataclass
  192. class SymbolEntry:
  193.     offset: int
  194.     parent_id: int
  195.     id: int
  196.     name: str
  197.  
  198.  
  199. def find_symbol_table(binary: bytes):
  200.     """
  201.    Locates and parses the 0xA7-tagged hierarchical symbol table
  202.    (Activity Engine state/menu names). CONFIRMED structure:
  203.  
  204.        0xA7 <len:u16 LE, = len(name)+4> <parent_id:u16 LE> <id:u16 LE> <name bytes>
  205.  
  206.    repeated until a 0xBEEF (bytes EF BE) marker, which ends the table.
  207.    `parent_id` links each entry back to the `id` of an earlier TYPE-0
  208.    entry, forming a small tree (Root -> State -> <state names>,
  209.    Root -> HarmonyAssistant -> <menu names>, etc).
  210.    """
  211.     start = binary.find(b'\xa7')
  212.     # Walk forward validating the record structure so we don't anchor on
  213.     # a coincidental 0xa7 byte inside compressed data.
  214.     while start != -1:
  215.         entries = []
  216.         pos = start
  217.         ok = True
  218.         while True:
  219.             if binary[pos:pos + 1] != b'\xa7':
  220.                 ok = pos != start  # fine if we've already parsed >=1 entries and hit BEEF
  221.                 break
  222.             if binary[pos + 1:pos + 3] == b'\xef\xbe':  # shouldn't happen, guard anyway
  223.                 break
  224.             length = struct.unpack('<H', binary[pos + 1:pos + 3])[0]
  225.             if length < 4 or length > 200:
  226.                 ok = False
  227.                 break
  228.             parent_id, ident = struct.unpack('<HH', binary[pos + 3:pos + 7])
  229.             name_len = length - 4
  230.             name_bytes = binary[pos + 7:pos + 7 + name_len]
  231.             try:
  232.                 name = name_bytes.decode('ascii')
  233.             except UnicodeDecodeError:
  234.                 ok = False
  235.                 break
  236.             if not name or not all(32 <= b < 127 for b in name_bytes):
  237.                 ok = False
  238.                 break
  239.             entries.append(SymbolEntry(pos, parent_id, ident, name))
  240.             pos += 7 + name_len
  241.             if binary[pos:pos + 2] == b'\xef\xbe':
  242.                 pos += 2
  243.                 break
  244.         if ok and len(entries) >= 2:
  245.             return entries, start, pos
  246.         start = binary.find(b'\xa7', start + 1)
  247.     return [], -1, -1
  248.  
  249.  
  250. def render_symbol_tree(entries):
  251.     by_parent = {}
  252.     for e in entries:
  253.         by_parent.setdefault(e.parent_id, []).append(e)
  254.  
  255.     lines = []
  256.     for root_entry in by_parent.get(0, []):
  257.         lines.append(f"{root_entry.name} (id={root_entry.id})")
  258.         for child in by_parent.get(root_entry.id, []):
  259.             lines.append(f"  - {child.name} (id={child.id})")
  260.     return "\n".join(lines)
  261.  
  262.  
  263. # ------------------------------------------------------------- padding map
  264.  
  265. @dataclass
  266. class PaddingRun:
  267.     start: int
  268.     end: int
  269.     length: int
  270.     pattern: bytes
  271.  
  272.  
  273. def find_padding_runs(binary: bytes, max_period: int = 4, min_len: int = 200) -> list:
  274.     """
  275.    CONFIRMED (verified on Update.EZHex, generalizes across all 4 samples):
  276.    large stretches of the "opaque" region are not real content at all --
  277.    they're short-period repeating filler (e.g. a 40960-byte run of exactly
  278.    "52 AA 52 AA..." -- 40960 = 40*1024, a suspiciously round number for
  279.    reserved/erased flash space, not compressed data). Detecting these
  280.    shrinks the amount of file that's actually unknown.
  281.    """
  282.     n = len(binary)
  283.     runs = []
  284.     i = 0
  285.     while i < n:
  286.         best = None
  287.         for period in range(1, max_period + 1):
  288.             if i + period > n:
  289.                 continue
  290.             pat = binary[i:i + period]
  291.             j = i + period
  292.             while j + period <= n and binary[j:j + period] == pat:
  293.                 j += period
  294.             length = j - i
  295.             if length >= min_len and (best is None or length > best[1]):
  296.                 best = (period, length, pat)
  297.         if best:
  298.             period, length, pat = best
  299.             runs.append(PaddingRun(i, i + length, length, pat))
  300.             i += length
  301.         else:
  302.             i += 1
  303.     return runs
  304.  
  305.  
  306. # --------------------------------------------------- real-data characterization
  307. # UNVERIFIED / best-effort only -- everything below is structural/statistical
  308. # characterization of the still-opaque IR waveform region, not a confirmed
  309. # decode. No ground-truth IR capture was available to check it against.
  310.  
  311. def real_data_bytes(binary: bytes) -> bytes:
  312.     """binary minus TPTP/WLWL headers, the symbol table, and padding runs --
  313.    i.e. just the still-unexplained bytes, presumed per-device IR waveform
  314.    and protocol descriptor data."""
  315.     padding = find_padding_runs(binary)
  316.     _entries, sym_start, sym_end = find_symbol_table(binary)
  317.     wlwl = parse_wlwl(binary)
  318.     wlwl_start = binary.find(b'WLWL')
  319.     wlwl_end = (wlwl[-1].offset + 4) if wlwl else wlwl_start
  320.     exclude = sorted([(0, binary.find(b'WLWL')), (wlwl_start, wlwl_end),
  321.                        (sym_start, sym_end)]
  322.                       + [(r.start, r.end) for r in padding])
  323.     out, pos = bytearray(), 0
  324.     for s, e in exclude:
  325.         if s > pos:
  326.             out += binary[pos:s]
  327.         pos = max(pos, e)
  328.     out += binary[pos:]
  329.     return bytes(out)
  330.  
  331.  
  332. def byte_histogram(data: bytes, top_n: int = 15):
  333.     from collections import Counter
  334.     import math
  335.     c = Counter(data)
  336.     n = len(data)
  337.     ent = -sum((v / n) * math.log2(v / n) for v in c.values())
  338.     return ent, c.most_common(top_n)
  339.  
  340.  
  341. def estimate_bytes_per_device(paths_in_growth_order: list):
  342.     """
  343.    Best-effort scaling estimate: diffs real_data_bytes() size against the
  344.    change in device/activity state count (from the symbol table) between
  345.    successive files, assumed to be given in chronological growth order.
  346.    Only meaningful if each file is a superset of the previous one's devices
  347.    (true for the 4 known samples, oldest to newest by mtime).
  348.    """
  349.     rows = []
  350.     for p in paths_in_growth_order:
  351.         data = open(p, 'rb').read()
  352.         _, binary = split_header(data)
  353.         entries, _, _ = find_symbol_table(binary)
  354.         states = {e.name for e in entries if e.parent_id == 1
  355.                   and not e.name.startswith('CurrentActivityState')
  356.                   and e.name != 'CurrentLocation_1'}
  357.         rd = len(real_data_bytes(binary))
  358.         rows.append((p, rd, states))
  359.  
  360.     print("Best-effort scaling estimate (UNVERIFIED hypothesis):")
  361.     for i in range(1, len(rows)):
  362.         p0, rd0, s0 = rows[i - 1]
  363.         p1, rd1, s1 = rows[i]
  364.         added = s1 - s0
  365.         delta = rd1 - rd0
  366.         per_state = delta / len(added) if added else float('nan')
  367.         print(f"  {p0} -> {p1}: real-data delta={delta:+d} bytes, "
  368.               f"+{len(added)} new states {sorted(added) or '(none)'}, "
  369.               f"~{per_state:.0f} bytes/new-state" if added else
  370.               f"  {p0} -> {p1}: real-data delta={delta:+d} bytes, no new states "
  371.               f"(likely added buttons/codes on existing devices)")
  372.  
  373.  
  374. # -------------------------------------------------- NEC IR record decoder
  375. # CONFIRMED, not a hypothesis -- see module docstring "CRACKED" section.
  376.  
  377. NEC_RECORD_HEADER = bytes.fromhex("16a18610")
  378. NEC_BIT0_TOKEN = bytes.fromhex("24822206")
  379. NEC_BIT1_TOKEN = bytes.fromhex("2482f401")
  380.  
  381.  
  382. @dataclass
  383. class NecCode:
  384.     offset: int
  385.     address: int
  386.     command: int
  387.     trailer: bytes
  388.  
  389.  
  390. def decode_nec_records(binary: bytes) -> list:
  391.     """
  392.    Scans for NEC_RECORD_HEADER, decodes the following 32 bit-tokens into 4
  393.    bytes, and keeps only records passing the NEC complement check
  394.    (byte1 == ~byte0, byte3 == ~byte2). In practice this check is 100% or
  395.    0% per file -- there's no partial-confidence middle ground, which is
  396.    part of why this is trustworthy rather than a fitted pattern.
  397.    """
  398.     results = []
  399.     i = binary.find(NEC_RECORD_HEADER)
  400.     while i != -1:
  401.         p = i + 4
  402.         bits = []
  403.         while binary[p:p + 4] in (NEC_BIT0_TOKEN, NEC_BIT1_TOKEN) and len(bits) < 32:
  404.             bits.append('0' if binary[p:p + 4] == NEC_BIT0_TOKEN else '1')
  405.             p += 4
  406.         if len(bits) == 32:
  407.             bitstr = ''.join(bits)
  408.             b0, b1, b2, b3 = (int(bitstr[j:j + 8], 2) for j in (0, 8, 16, 24))
  409.             if (b0 ^ b1) == 0xff and (b2 ^ b3) == 0xff:
  410.                 results.append(NecCode(i, b0, b2, binary[p:p + 4]))
  411.         i = binary.find(NEC_RECORD_HEADER, i + 1)
  412.     return results
  413.  
  414.  
  415. def summarize_nec_codes(codes: list) -> str:
  416.     by_addr = {}
  417.     for c in codes:
  418.         by_addr.setdefault(c.address, set()).add(c.command)
  419.     lines = []
  420.     for addr, cmds in sorted(by_addr.items()):
  421.         cmd_list = ', '.join(f'0x{c:02x}' for c in sorted(cmds))
  422.         lines.append(f"  address=0x{addr:02x}: {len(cmds)} commands: {cmd_list}")
  423.     return "\n".join(lines)
  424.  
  425.  
  426. # --------------------------------------------------------- NEC code injection
  427. # File-level proof of concept only -- see module docstring "CRACKED" section
  428. # for what is and isn't verified about this format. Deliberately scoped to
  429. # NEVER touch anything past each record's trailer: the bytes immediately
  430. # after a record are the surrounding Activity Engine bytecode (confirmed by
  431. # finding the same (address,command) pair at two file locations with
  432. # different trailing bytes), not a fixed per-record footer/checksum, so
  433. # there is nothing safe to compute there without a hardware ground truth.
  434.  
  435. NEC_REPEAT_LEN = 136          # header(4) + 32 bit-tokens(128) + trailer(4)
  436. NEC_REPEATS_PER_RECORD = 3
  437. NEC_RECORD_TRAILER = bytes.fromhex("24828462")
  438.  
  439.  
  440. def find_clean_nec_records(binary: bytes) -> list:
  441.     """
  442.    Isolated, unambiguous button records: 3 consecutive header occurrences
  443.    each exactly NEC_REPEAT_LEN bytes apart, all decoding to the identical
  444.    (address, command). (Some (address,command) pairs also appear woven
  445.    into longer Activity Engine sequences with other commands interleaved --
  446.    those are intentionally excluded here since overwriting one repeat
  447.    of an interleaved sequence without understanding the sequence itself
  448.    would be a real guess, not a verified edit.)
  449.    Returns a list of (start_offset, address, command).
  450.    """
  451.     offs = []
  452.     i = binary.find(NEC_RECORD_HEADER)
  453.     while i != -1:
  454.         offs.append(i)
  455.         i = binary.find(NEC_RECORD_HEADER, i + 1)
  456.  
  457.     def decode_repeat(pos):
  458.         p = pos + 4
  459.         bits = []
  460.         while binary[p:p + 4] in (NEC_BIT0_TOKEN, NEC_BIT1_TOKEN) and len(bits) < 32:
  461.             bits.append('0' if binary[p:p + 4] == NEC_BIT0_TOKEN else '1')
  462.             p += 4
  463.         if len(bits) != 32:
  464.             return None
  465.         bs = ''.join(bits)
  466.         return tuple(int(bs[j:j + 8], 2) for j in (0, 8, 16, 24))
  467.  
  468.     records, j = [], 0
  469.     while j < len(offs) - 2:
  470.         o0, o1, o2 = offs[j], offs[j + 1], offs[j + 2]
  471.         if o1 - o0 == NEC_REPEAT_LEN and o2 - o1 == NEC_REPEAT_LEN:
  472.             d0, d1, d2 = decode_repeat(o0), decode_repeat(o1), decode_repeat(o2)
  473.             if d0 and d1 and d2 and d0[:2] == d1[:2] == d2[:2] \
  474.                     and (d0[0] ^ d0[1]) == 0xff and (d0[2] ^ d0[3]) == 0xff:
  475.                 records.append((o0, d0[0], d0[2]))
  476.                 j += 3
  477.                 continue
  478.         j += 1
  479.     return records
  480.  
  481.  
  482. def build_nec_repeat(address: int, command: int) -> bytes:
  483.     addr_c = 0xff ^ address
  484.     cmd_c = 0xff ^ command
  485.     bits = f"{address:08b}{addr_c:08b}{command:08b}{cmd_c:08b}"
  486.     out = bytearray(NEC_RECORD_HEADER)
  487.     for b in bits:
  488.         out += NEC_BIT1_TOKEN if b == '1' else NEC_BIT0_TOKEN
  489.     out += NEC_RECORD_TRAILER
  490.     return bytes(out)
  491.  
  492.  
  493. def inject_nec_code(binary: bytes, target_offset: int, new_address: int, new_command: int) -> bytes:
  494.     """
  495.    Overwrites exactly one isolated clean record (NEC_REPEATS_PER_RECORD x
  496.    NEC_REPEAT_LEN = 408 bytes starting at target_offset) with a freshly
  497.    built record for (new_address, new_command). Everything else in the
  498.    file -- including the bytes immediately following this record -- is
  499.    left byte-for-byte untouched. Raises if target_offset doesn't point at
  500.    a real record header, so this can't silently corrupt an arbitrary
  501.    offset.
  502.    """
  503.     if binary[target_offset:target_offset + 4] != NEC_RECORD_HEADER:
  504.         raise ValueError(f"offset {target_offset} is not a NEC record header")
  505.     touched_len = NEC_REPEAT_LEN * NEC_REPEATS_PER_RECORD
  506.     new_repeat = build_nec_repeat(new_address, new_command)
  507.     new_span = new_repeat * NEC_REPEATS_PER_RECORD
  508.     new_binary = binary[:target_offset] + new_span + binary[target_offset + touched_len:]
  509.     assert len(new_binary) == len(binary)
  510.     return new_binary
  511.  
  512.  
  513. def inject_and_write(src_path: str, dst_path: str, target_offset: int,
  514.                       new_address: int, new_command: int) -> None:
  515.     """CLI-friendly wrapper: read src, inject, verify with decode_nec_records,
  516.    write dst. Verification failure raises rather than writing a bad file."""
  517.     data = open(src_path, 'rb').read()
  518.     xml, binary = split_header(data)
  519.     new_binary = inject_nec_code(binary, target_offset, new_address, new_command)
  520.  
  521.     decoded = decode_nec_records(new_binary)
  522.     hit = [c for c in decoded if c.offset == target_offset]
  523.     if not (hit and hit[0].address == new_address and hit[0].command == new_command):
  524.         raise RuntimeError("post-injection verification failed -- refusing to write output")
  525.  
  526.     diff_positions = [i for i, (a, b) in enumerate(zip(binary, new_binary)) if a != b]
  527.     span = (min(diff_positions), max(diff_positions) + 1) if diff_positions else (0, 0)
  528.     print(f"injected addr=0x{new_address:02x} cmd=0x{new_command:02x} at offset {target_offset}")
  529.     print(f"bytes changed: {len(diff_positions)}, all within {span} "
  530.           f"(expected within [{target_offset}:{target_offset + NEC_REPEAT_LEN*NEC_REPEATS_PER_RECORD}))")
  531.  
  532.     with open(dst_path, 'wb') as f:
  533.         f.write(xml + new_binary)
  534.     print(f"wrote {dst_path} ({len(xml) + len(new_binary)} bytes, "
  535.           f"{'same size as input' if len(data) == len(xml)+len(new_binary) else 'SIZE CHANGED -- unexpected'})")
  536.  
  537.  
  538. # ------------------------------------------------- ir_catalog.db cross-reference
  539. # CONFIRMED via exact match, not fuzzy/guessed: decoded NEC (address, command)
  540. # byte pairs are re-packed into the 16-bit "address"/"command" convention used
  541. # by this catalog's NECext rows (value = low_byte | (~low_byte << 8) -- the
  542. # classic 8-bit-NEC-with-complement pair folded into one 16-bit field), then
  543. # looked up by exact string match against protocol_params. A hit means the
  544. # catalog has that literal brand's exact address+command pair on file; it is
  545. # not a similarity/nearest-neighbor match.
  546.  
  547. def pack_nec_16bit(low_byte: int) -> int:
  548.     return low_byte | ((0xff ^ low_byte) << 8)
  549.  
  550.  
  551. def match_against_ir_catalog(nec_codes: list, db_path: str) -> dict:
  552.     """
  553.    For each decoded NEC (address, command) pair, look up an exact match in
  554.    ir_catalog.db's NECext rows. Returns {(address, command): [(brand, model,
  555.    function), ...]}  for every pair that has at least one hit. Requires the
  556.    sqlite3 stdlib module and a readable ir_catalog.db at db_path.
  557.    """
  558.     import sqlite3
  559.     conn = sqlite3.connect(db_path)
  560.     cur = conn.cursor()
  561.     dedup = sorted(set((c.address, c.command) for c in nec_codes))
  562.     hits = {}
  563.     for addr, cmd in dedup:
  564.         params = f"address={pack_nec_16bit(addr)} command={pack_nec_16bit(cmd)}"
  565.         cur.execute(
  566.             "SELECT brand, model, function FROM ir_catalog "
  567.             "WHERE protocol='NECext' AND protocol_params = ?", (params,))
  568.         rows = cur.fetchall()
  569.         if rows:
  570.             hits[(addr, cmd)] = sorted(set(rows))
  571.     conn.close()
  572.     return hits
  573.  
  574.  
  575. # --------------------------------------------------- known-protocol elimination
  576.  
  577. # Canonical raw-timing headers (LE u16 microsecond mark/space pairs, as used
  578. # by the ir_catalog.db ground-truth corpus), one representative per major
  579. # protocol family. Derived by decoding real catalog entries, not invented.
  580. KNOWN_PROTOCOL_HEADERS = {
  581.     "NEC/lirc/raw":       bytes.fromhex("28239411" "30023002" "30023002"),
  582.     "Kaseikyo/Panasonic": bytes.fromhex("800dc006" "b001b001" "b0011005"),
  583.     "RC5":                bytes.fromhex("79037903" "f206f206" "f2067903"),
  584.     "RC6":                bytes.fromhex("6a0a7903" "bc017803" "bc01bc01"),
  585.     "Sony/SIRC":          bytes.fromhex("60095802" "58025802" "58025802"),
  586.     "Samsung32":          bytes.fromhex("9411941130029a06"),
  587. }
  588.  
  589.  
  590. def _shift_bytes_left(data: bytes, shift: int) -> bytes:
  591.     """Shift a byte string left by 0-7 bits (zero-filled), so a pattern that
  592.    occurs at a non-byte-aligned bit offset in `data` lands on a byte
  593.    boundary and becomes findable with a plain substring search."""
  594.     if shift == 0:
  595.         return data
  596.     n = len(data)
  597.     as_int = int.from_bytes(data, 'big')
  598.     shifted = (as_int << shift) & ((1 << (n * 8)) - 1)
  599.     return shifted.to_bytes(n, 'big')
  600.  
  601.  
  602. def check_against_known_protocols(binary: bytes, headers: dict = KNOWN_PROTOCOL_HEADERS):
  603.     """
  604.    For each candidate protocol header, search for it at all 8 bit
  605.    alignments. Returns {name: [(shift, byte_offset), ...]} for any hits.
  606.    An empty result (as found on all 4 sample files) is a real negative:
  607.    these headers are long enough that coincidental matches are effectively
  608.    impossible, so "no hits" means "not stored as a raw timing dump."
  609.    """
  610.     results = {}
  611.     for name, pat in headers.items():
  612.         hits = []
  613.         for shift in range(8):
  614.             idx = _shift_bytes_left(binary, shift).find(pat)
  615.             if idx != -1:
  616.                 hits.append((shift, idx))
  617.         if hits:
  618.             results[name] = hits
  619.     return results
  620.  
  621.  
  622. # --------------------------------------------------------------------- main
  623.  
  624. def analyze(path: str):
  625.     data = open(path, 'rb').read()
  626.     xml, binary = split_header(data)
  627.     meta = extract_xml_fields(xml)
  628.  
  629.     print(f"=== {path} ===")
  630.     print(f"file size: {len(data)}   binary payload: {len(binary)}")
  631.     print(f"xml metadata: {meta}")
  632.  
  633.     tptp = parse_tptp(binary)
  634.     print(f"\nTPTP header: decompressed_size~={tptp.decompressed_size} "
  635.           f"(ratio {tptp.decompressed_size/len(binary):.3f}x)  "
  636.           f"constant_field={tptp.constant_field}")
  637.     print(f"  group_a (fixed-size record offsets): {tptp.group_a}")
  638.     print(f"  group_b (content-scaling offsets):    {tptp.group_b}")
  639.  
  640.     wlwl = parse_wlwl(binary)
  641.     data_records = [r for r in wlwl if not r.simple]
  642.     print(f"\nWLWL flag table: {len(wlwl)} records, "
  643.           f"{len(wlwl) - len(data_records)} simple flags, "
  644.           f"{len(data_records)} data-carrying:")
  645.     for r in data_records:
  646.         print(f"  id=0x{r.id_byte:02x} raw={r.raw.hex()}")
  647.  
  648.     entries, sym_start, sym_end = find_symbol_table(binary)
  649.     print(f"\nSymbol table: {len(entries)} entries, "
  650.           f"bytes [{sym_start}:{sym_end}] ({sym_end - sym_start} bytes)")
  651.     print(render_symbol_tree(entries))
  652.  
  653.     dkdk_at = binary.rfind(b'DKDK')
  654.     print(f"\nEOF marker DKDK at {dkdk_at} "
  655.           f"({'confirmed last 4 bytes' if dkdk_at == len(binary) - 4 else 'NOT at end -- unexpected'})")
  656.  
  657.     padding = find_padding_runs(binary)
  658.     total_padding = sum(r.length for r in padding)
  659.     print(f"\nPadding/reserved runs: {len(padding)} found, "
  660.           f"{total_padding} bytes total ({total_padding/len(binary)*100:.1f}% of binary payload)")
  661.     for r in padding:
  662.         print(f"  [{r.start:7d}:{r.end:7d}]  len={r.length:6d}  pattern={r.pattern.hex()}")
  663.  
  664.     rd = real_data_bytes(binary)
  665.     ent, top = byte_histogram(rd)
  666.     print(f"\nUnexplained/opaque real-data bytes remaining: {len(rd)} "
  667.           f"({len(rd)/len(binary)*100:.1f}% of binary payload) -- "
  668.           f"presumed per-device IR waveform/protocol data, numeric, no embedded text")
  669.     print(f"  entropy: {ent:.2f} bits/byte (max 8.0) -- structured, not raw noise")
  670.     print(f"  top byte values: {[(hex(k), f'{v/len(rd)*100:.1f}%') for k, v in top[:8]]}")
  671.  
  672.     proto_hits = check_against_known_protocols(binary)
  673.     if proto_hits:
  674.         print(f"\nKnown-protocol raw-timing headers FOUND (unexpected): {proto_hits}")
  675.     else:
  676.         print(f"\nKnown-protocol raw-timing headers: no match for any of "
  677.               f"{list(KNOWN_PROTOCOL_HEADERS)} at any of 8 bit alignments -- "
  678.               f"confirmed NOT a raw timing dump")
  679.  
  680.     nec_codes = decode_nec_records(binary)
  681.     print(f"\nDecoded NEC-protocol IR codes: {len(nec_codes)} valid records "
  682.           f"(complement check passes on all of them, by construction)")
  683.     if nec_codes:
  684.         print(summarize_nec_codes(nec_codes))
  685.  
  686.         if IR_CATALOG_DB and os.path.exists(IR_CATALOG_DB):
  687.             hits = match_against_ir_catalog(nec_codes, IR_CATALOG_DB)
  688.             dedup = sorted(set((c.address, c.command) for c in nec_codes))
  689.             print(f"\n  Catalog cross-reference ({IR_CATALOG_DB}): "
  690.                   f"{len(hits)}/{len(dedup)} codes matched exactly")
  691.             for (addr, cmd), rows in sorted(hits.items()):
  692.                 labels = ', '.join(f"{b}/{m}:{fn}" for b, m, fn in rows)
  693.                 print(f"    addr=0x{addr:02x} cmd=0x{cmd:02x} -> {labels}")
  694.  
  695.         clean = find_clean_nec_records(binary)
  696.         print(f"\n  Injection targets (isolated, safely overwritable records): {len(clean)}")
  697.         for off, addr, cmd in clean[:10]:
  698.             print(f"    offset={off}  addr=0x{addr:02x} cmd=0x{cmd:02x}")
  699.         if len(clean) > 10:
  700.             print(f"    ... and {len(clean)-10} more")
  701.     print()
  702.  
  703.  
  704. def main():
  705.     args = sys.argv[1:]
  706.  
  707.     if args and args[0] == 'inject':
  708.         # usage: ezhex_parser.py inject <src> <dst> <target_offset> <addr_hex> <cmd_hex>
  709.         if len(args) != 6:
  710.             print("usage: ezhex_parser.py inject <src> <dst> <target_offset> <addr_hex> <cmd_hex>")
  711.             print("  target_offset must be one of the offsets listed under "
  712.                   "'Injection targets' in the normal analyze output.")
  713.             sys.exit(1)
  714.         _, src, dst, offset_str, addr_str, cmd_str = args
  715.         inject_and_write(src, dst, int(offset_str), int(addr_str, 16), int(cmd_str, 16))
  716.         return
  717.  
  718.     if args and args[0] == 'targets':
  719.         # usage: ezhex_parser.py targets <file>
  720.         data = open(args[1], 'rb').read()
  721.         _, binary = split_header(data)
  722.         for off, addr, cmd in find_clean_nec_records(binary):
  723.             print(f"offset={off}  addr=0x{addr:02x} cmd=0x{cmd:02x}")
  724.         return
  725.  
  726.     paths = args or [
  727.         "Update.EZHex", "Update(1).EZHex", "Update(2) .EZHex", "Update(3).EZHex",
  728.     ]
  729.     for p in paths:
  730.         analyze(p)
  731.  
  732.     print("=" * 70)
  733.     # NOTE: this order is by confirmed file mtime (oldest -> newest); only
  734.     # valid because each successive sample is a superset of the previous
  735.     # one's configured devices.
  736.     growth_order = [
  737.         "Update.EZHex", "Update(1).EZHex", "Update(2) .EZHex", "Update(3).EZHex",
  738.     ]
  739.     if all(p in paths for p in growth_order):
  740.         estimate_bytes_per_device(growth_order)
  741.  
  742.  
  743. if __name__ == '__main__':
  744.     main()
  745.  
Advertisement
Add Comment
Please, Sign In to add comment