|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import re |
6 | | -from typing import Optional |
| 6 | +from typing import Optional, List, Tuple |
7 | 7 |
|
8 | 8 | from ..exceptions import SignalduinoParserError |
9 | 9 |
|
10 | | -_STX_ETX = re.compile(r"^\x02(M.;.*;)\x03$") |
| 10 | +_STX_ETX = re.compile(r"^\x02(M[s|u|o];.*;)\x03$") |
| 11 | + |
| 12 | + |
| 13 | +def decompress_payload(compressed_payload: str) -> str: |
| 14 | + """ |
| 15 | + Decompresses a compressed Signalduino payload (Mred=1). |
| 16 | +
|
| 17 | + The Perl logic is in 00_SIGNALduino.pm around line 1784. |
| 18 | + """ |
| 19 | + if not compressed_payload.upper().startswith(("MS;", "MU;", "MO;")): |
| 20 | + return compressed_payload |
| 21 | + |
| 22 | + # Split message parts by ';' |
| 23 | + # This split is problematic if ';' exists in the D= binary payload. |
| 24 | + # The fix is to merge all consecutive sections starting with 'D' or 'd' into one. |
| 25 | + msg_parts: List[str] = compressed_payload.split(';') |
| 26 | + decompressed_parts: List[str] = [] |
| 27 | + |
| 28 | + i = 0 |
| 29 | + while i < len(msg_parts): |
| 30 | + msg_part = msg_parts[i] |
| 31 | + |
| 32 | + if not msg_part: |
| 33 | + i += 1 |
| 34 | + continue |
| 35 | + |
| 36 | + m0 = msg_part[0] if len(msg_part) > 0 else '' |
| 37 | + m1 = msg_part[1:] if len(msg_part) > 1 else '' |
| 38 | + mnr0 = ord(m0) if m0 else 0 |
| 39 | + |
| 40 | + # --- Data Reduction logic (D= or d= - Perl line 1819) --- |
| 41 | + if m0 in ('D', 'd'): |
| 42 | + |
| 43 | + # Merge consecutive split parts that likely belong to the D= payload |
| 44 | + current_data_payload = msg_part |
| 45 | + j = i + 1 |
| 46 | + while j < len(msg_parts): |
| 47 | + next_part = msg_parts[j] |
| 48 | + if not next_part: |
| 49 | + j += 1 |
| 50 | + continue |
| 51 | + |
| 52 | + # Check if next_part looks like a valid field which breaks the D= sequence |
| 53 | + # Valid fields start with a letter. |
| 54 | + # Special case: Fxx (1-2 hex digits) -> F=... |
| 55 | + # Special case: C=, R=, Px= |
| 56 | + |
| 57 | + # Heuristic: If it starts with a letter and is short (likely a command/field) |
| 58 | + # or matches specific patterns, we stop merging. |
| 59 | + # However, binary data can also look like this. |
| 60 | + # The most robust check based on Perl code is to check for specific field patterns. |
| 61 | + |
| 62 | + # Perl fields: |
| 63 | + # P[0-7]=... |
| 64 | + # C=... / S=... (length 1 value) |
| 65 | + # o... / m... |
| 66 | + # Xyy (1 letter + 1-2 hex digits) -> X=dec(yy) |
| 67 | + # X... (1 letter + anything) -> X=... |
| 68 | + |
| 69 | + next_m0 = next_part[0] if next_part else '' |
| 70 | + next_m1 = next_part[1:] if len(next_part) > 1 else '' |
| 71 | + |
| 72 | + is_field = False |
| 73 | + |
| 74 | + if not next_m0.isalpha(): |
| 75 | + pass # Not a field start |
| 76 | + elif next_m0 in ('D', 'd'): |
| 77 | + # Start of a NEW data block (unlikely in valid compressed stream but possible) |
| 78 | + is_field = True |
| 79 | + elif ord(next_m0) > 127: |
| 80 | + # Pattern definition |
| 81 | + is_field = True |
| 82 | + elif next_m0 == 'M': |
| 83 | + is_field = True |
| 84 | + elif next_m0 in ('C', 'S') and len(next_m1) == 1: |
| 85 | + is_field = True |
| 86 | + elif next_m0 in ('o', 'm'): |
| 87 | + is_field = True |
| 88 | + elif re.match(r"^[0-9A-F]{1,2}$", next_m1.upper()): |
| 89 | + # Matches Xyy format (e.g. F64) |
| 90 | + is_field = True |
| 91 | + elif next_m0.isalnum() and '=' in next_part: # R=..., C=... |
| 92 | + is_field = True |
| 93 | + |
| 94 | + if is_field: |
| 95 | + break |
| 96 | + |
| 97 | + current_data_payload += ';' + next_part |
| 98 | + j += 1 |
| 99 | + |
| 100 | + # The actual content for decompressing is EVERYTHING after the initial D/d. |
| 101 | + m1 = current_data_payload[1:] |
| 102 | + m0 = current_data_payload[0] # Corrected: m0 must be 'D' or 'd' |
| 103 | + i = j - 1 # Update main loop counter to skip merged parts |
| 104 | + |
| 105 | + part_d = "" |
| 106 | + # Perl logic: 1823-1827 |
| 107 | + for char_d in m1: |
| 108 | + char_ord = ord(char_d) |
| 109 | + m_h = (char_ord >> 4) & 0xF |
| 110 | + m_l = char_ord & 0x7 |
| 111 | + part_d += f"{m_h}{m_l}" |
| 112 | + |
| 113 | + # Perl logic: 1829-1831 (remove last digit if odd number of digits - when d= for MU) |
| 114 | + if m0 == 'd': |
| 115 | + part_d = part_d[:-1] |
| 116 | + |
| 117 | + # Perl logic: 1832 (remove leading 8) |
| 118 | + if part_d.startswith('8'): |
| 119 | + part_d = part_d[1:] |
| 120 | + |
| 121 | + decompressed_parts.append(f"D={part_d}") |
| 122 | + |
| 123 | + # --- M-part (M, m) --- |
| 124 | + elif m0 == 'M': |
| 125 | + # M-part is always uc in Perl's decompressed message |
| 126 | + decompressed_parts.append(f"M{m1.upper()}") |
| 127 | + |
| 128 | + # --- Pattern compression logic (mnr0 > 127 - Perl line 1801) --- |
| 129 | + elif mnr0 > 127: |
| 130 | + # Perl logic: 1802-1814 |
| 131 | + decompressed_part = f"P{mnr0 & 7}=" |
| 132 | + # In Perl, m1 is a 2-char string. |
| 133 | + if len(m1) == 2: |
| 134 | + # Assuming the two characters contain the high and low pattern bytes |
| 135 | + # We use ord() on single characters now (after encoding fix) |
| 136 | + m_l_ord = ord(m1[0]) |
| 137 | + m_h_ord = ord(m1[1]) |
| 138 | + |
| 139 | + m_l = m_l_ord & 127 |
| 140 | + m_h = m_h_ord & 127 |
| 141 | + |
| 142 | + if (mnr0 & 0b00100000) != 0: # Vorzeichen 32 |
| 143 | + decompressed_part += "-" |
| 144 | + if (mnr0 & 0b00010000): # Bit 7 von Pattern low 16 |
| 145 | + m_l += 128 |
| 146 | + |
| 147 | + # mH * 256 + mL is the final pulse length |
| 148 | + decompressed_part += str(m_h * 256 + m_l) |
| 149 | + decompressed_parts.append(decompressed_part) |
| 150 | + |
| 151 | + # --- C/S Pulse compression (C= or S= - Perl line 1836) --- |
| 152 | + elif m0 in ('C', 'S') and len(m1) == 1: |
| 153 | + decompressed_parts.append(f"{m0}P={m1}") |
| 154 | + |
| 155 | + # --- o/m fields (Perl line 1840) --- |
| 156 | + elif m0 in ('o', 'm'): |
| 157 | + decompressed_parts.append(f"{m0}{m1}") |
| 158 | + |
| 159 | + # --- Hex to Dec conversion for 1 or 2 Hex Digits (Perl line 1842) --- |
| 160 | + elif m1 and re.match(r"^[0-9A-F]{1,2}$", m1.upper()): |
| 161 | + decompressed_parts.append(f"{m0}={int(m1, 16)}") |
| 162 | + |
| 163 | + # --- Other fields (R=, B=, t=, etc. - Perl line 1845) --- |
| 164 | + elif m0.isalnum(): |
| 165 | + decompressed_parts.append(f"{m0}{'=' if m1 else ''}{m1}") |
| 166 | + |
| 167 | + i += 1 |
| 168 | + |
| 169 | + # The final message is concatenated and the trailing semicolon is added |
| 170 | + return ";".join(decompressed_parts) + ";" |
11 | 171 |
|
12 | 172 |
|
13 | 173 | def extract_payload(line: str) -> Optional[str]: |
14 | | - """Return the payload between STX/ETX markers if present.""" |
| 174 | + """ |
| 175 | + Return the payload between STX/ETX markers if present. |
15 | 176 |
|
| 177 | + Includes logic for decompressing the Mred=1 format. |
| 178 | + """ |
16 | 179 | if not line: |
17 | 180 | return None |
18 | | - match = _STX_ETX.match(line.strip()) |
| 181 | + |
| 182 | + line_stripped = line.strip() |
| 183 | + match = _STX_ETX.match(line_stripped) |
| 184 | + |
19 | 185 | if not match: |
20 | 186 | return None |
21 | | - return match.group(1) |
| 187 | + |
| 188 | + payload = match.group(1) |
| 189 | + |
| 190 | + # All framed messages are passed through the decompression logic in Perl (L1784) |
| 191 | + # The result is the final payload without STX/ETX, which matches the required output. |
| 192 | + return decompress_payload(payload) |
22 | 193 |
|
23 | 194 |
|
24 | 195 | def ensure_message_type(payload: str, expected: str) -> None: |
|
0 commit comments