summaryrefslogtreecommitdiff
path: root/stacks/daemon/ax25_codec.py
blob: 03ee671356262d0d75ee253a2c9bbb2912ccac28 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
"""
AX.25 UI frame codec for max25d — address fields, CRC-CCITT FCS, UI parse/build.

Derived from libax25 axutils.c (address encoding) and ax25ipd/crc.c (RFC 1171 FCS).
KISS DATA carries the AX.25 body without FCS; over-the-air and some paths include FCS.
"""
from __future__ import annotations

import re
from typing import Optional

# HDLC CRC-CCITT (polynomial 0x1021), RFC 1171 / ax25ipd table
_FCS_TABLE: tuple[int, ...] = (
    0x0000, 0x1189, 0x2312, 0x329B, 0x4624, 0x57AD, 0x6536, 0x74BF,
    0x8C48, 0x9DC1, 0xAF5A, 0xBED3, 0xCA6C, 0xDBE5, 0xE97E, 0xF8F7,
    0x1081, 0x0108, 0x3393, 0x221A, 0x56A5, 0x472C, 0x75B7, 0x643E,
    0x9CC9, 0x8D40, 0xBFDB, 0xAE52, 0xDAED, 0xCB64, 0xF9FF, 0xE876,
    0x2102, 0x308B, 0x0210, 0x1399, 0x6726, 0x76AF, 0x4434, 0x55BD,
    0xAD4A, 0xBCC3, 0x8E58, 0x9FD1, 0xEB6E, 0xFAE7, 0xC87C, 0xD9F5,
    0x3183, 0x200A, 0x1291, 0x0318, 0x77A7, 0x662E, 0x54B5, 0x453C,
    0xBDCB, 0xAC42, 0x9ED9, 0x8F50, 0xFBEF, 0xEA66, 0xD8FD, 0xC974,
    0x4204, 0x538D, 0x6116, 0x709F, 0x0420, 0x15A9, 0x2732, 0x36BB,
    0xCE4C, 0xDFC5, 0xED5E, 0xFCD7, 0x8868, 0x99E1, 0xAB7A, 0xBAF3,
    0x5285, 0x430C, 0x7197, 0x601E, 0x14A1, 0x0528, 0x37B3, 0x263A,
    0xDECD, 0xCF44, 0xFDDF, 0xEC56, 0x98E9, 0x8960, 0xBBFB, 0xAA72,
    0x6306, 0x728F, 0x4014, 0x519D, 0x2522, 0x34AB, 0x0630, 0x17B9,
    0xEF4E, 0xFEC7, 0xCC5C, 0xDDD5, 0xA96A, 0xB8E3, 0x8A78, 0x9BF1,
    0x7387, 0x620E, 0x5095, 0x411C, 0x35A3, 0x242A, 0x16B1, 0x0738,
    0xFFCF, 0xEE46, 0xDCDD, 0xCD54, 0xB9EB, 0xA862, 0x9AF9, 0x8B70,
    0x8408, 0x9581, 0xA71A, 0xB693, 0xC22C, 0xD3A5, 0xE13E, 0xF0B7,
    0x0840, 0x19C9, 0x2B52, 0x3ADB, 0x4E64, 0x5FED, 0x6D76, 0x7CFF,
    0x9489, 0x8500, 0xB79B, 0xA612, 0xD2AD, 0xC324, 0xF1BF, 0xE036,
    0x18C1, 0x0948, 0x3BD3, 0x2A5A, 0x5EE5, 0x4F6C, 0x7DF7, 0x6C7E,
    0xA50A, 0xB483, 0x8618, 0x9791, 0xE32E, 0xF2A7, 0xC03C, 0xD1B5,
    0x2942, 0x38CB, 0x0A50, 0x1BD9, 0x6F66, 0x7EEF, 0x4C74, 0x5DFD,
    0xB58B, 0xA402, 0x9699, 0x8710, 0xF3AF, 0xE226, 0xD0BD, 0xC134,
    0x39C3, 0x284A, 0x1AD1, 0x0B58, 0x7FE7, 0x6E6E, 0x5CF5, 0x4D7C,
    0xC60C, 0xD785, 0xE51E, 0xF497, 0x8028, 0x91A1, 0xA33A, 0xB2B3,
    0x4A44, 0x5BCD, 0x6956, 0x78DF, 0x0C60, 0x1DE9, 0x2F72, 0x3EFB,
    0xD68D, 0xC704, 0xF59F, 0xE416, 0x90A9, 0x8120, 0xB3BB, 0xA232,
    0x5AC5, 0x4B4C, 0x79D7, 0x685E, 0x1CE1, 0x0D68, 0x3FF3, 0x2E7A,
    0xE70E, 0xF687, 0xC41C, 0xD595, 0xA12A, 0xB0A3, 0x8238, 0x93B1,
    0x6B46, 0x7ACF, 0x4854, 0x59DD, 0x2D62, 0x3CEB, 0x0E70, 0x1FF9,
    0xF78F, 0xE606, 0xD49D, 0xC514, 0xB1AB, 0xA022, 0x92B9, 0x8330,
    0x7BC7, 0x6A4E, 0x58D5, 0x495C, 0x3DE3, 0x2C6A, 0x1EF1, 0x0F78,
)

_FCS_GOOD = 0xF0B8  # ax25ipd PPPGOODFCS — residual after valid frame + FCS

AX25_UI_CONTROL = 0x03
AX25_UI_PID = 0xF0
MIN_UI_FRAME = 16  # dest(7) + src(7) + ctrl + pid

_CALLSIGN_RE = re.compile(r"^([A-Z0-9]{1,6})(?:-([0-9]{1,2}))?$")


def parse_callsign(text: str) -> tuple[str, int]:
    """Parse operator callsign text; invalid SSID clamps to 0 (legacy helper)."""
    text = text.strip().upper()
    if "-" in text:
        call, ssid_s = text.split("-", 1)
        try:
            ssid = int(ssid_s)
        except ValueError:
            ssid = 0
        if ssid < 0 or ssid > 15:
            ssid = 0
        return call[:6], ssid
    return text[:6], 0


def validate_callsign(text: str) -> tuple[str, int]:
    """Strict callsign parse (libax25 ax25_aton_entry rules)."""
    text = text.strip().upper()
    match = _CALLSIGN_RE.match(text)
    if not match:
        raise ValueError(f"invalid AX.25 callsign: {text!r}")
    call = match.group(1)
    ssid = int(match.group(2)) if match.group(2) is not None else 0
    if ssid < 0 or ssid > 15:
        raise ValueError(f"invalid AX.25 SSID in {text!r}")
    return call, ssid


def format_callsign(call: str, ssid: int) -> str:
    """Textual callsign; omit -0 suffix (libax25 ax25_ntoa convention)."""
    call = call.strip().upper()
    if ssid <= 0:
        return call
    if ssid >= 10:
        return f"{call}-{ssid}"
    return f"{call}-{ssid}"


def ax25_crc(data: bytes) -> int:
    """Compute AX.25 FCS over body (addresses + control + PID + info)."""
    fcs = 0xFFFF
    for b in data:
        fcs = (fcs >> 8) ^ _FCS_TABLE[(fcs ^ b) & 0xFF]
    return fcs ^ 0xFFFF


def ax25_crc_valid(frame: bytes) -> bool:
    """True when frame includes a valid little-endian FCS trailer."""
    fcs = 0xFFFF
    for b in frame:
        fcs = (fcs >> 8) ^ _FCS_TABLE[(fcs ^ b) & 0xFF]
    return fcs == _FCS_GOOD


def ax25_encode_address(call: str, ssid: int, last: bool) -> bytes:
    """Seven-byte AX.25 address field (libax25 ax25_aton_entry layout)."""
    padded = call.upper().ljust(6)[:6]
    raw = bytes((ord(c) << 1) for c in padded)
    ssid_b = ((ssid & 0x0F) << 1) | (0x01 if last else 0x00)
    return raw + bytes([ssid_b])


def ax25_decode_address(raw: bytes) -> tuple[str, int, bool]:
    call = "".join(chr((b >> 1) & 0x7F) for b in raw[:6]).strip()
    ssid = (raw[6] >> 1) & 0x0F
    last = bool(raw[6] & 0x01)
    return call, ssid, last


def ax25_build_ui(src: str, dst: str, info: bytes) -> bytes:
    src_call, src_ssid = validate_callsign(src)
    dst_call, dst_ssid = validate_callsign(dst)
    body = (
        ax25_encode_address(dst_call, dst_ssid, last=False)
        + ax25_encode_address(src_call, src_ssid, last=True)
        + bytes([AX25_UI_CONTROL, AX25_UI_PID])
        + info
    )
    crc = ax25_crc(body)
    return body + bytes((crc & 0xFF, crc >> 8))


def ax25_parse_ui(frame: bytes) -> Optional[tuple[str, str, bytes]]:
    """
    Parse a UI frame. Strips FCS only when the trailer validates (ax25ipd ok_crc).
    KISS payloads are usually FCS-free; over-the-air captures may include FCS.
    """
    if len(frame) < MIN_UI_FRAME:
        return None

    body = frame
    if len(frame) >= MIN_UI_FRAME + 2 and ax25_crc_valid(frame):
        body = frame[:-2]

    pos = 0
    addresses: list[tuple[str, int, bool]] = []
    while pos + 7 <= len(body):
        call, ssid, last = ax25_decode_address(body[pos : pos + 7])
        addresses.append((call, ssid, last))
        pos += 7
        if last:
            break
    else:
        return None

    if len(addresses) < 2:
        return None
    if pos + 2 > len(body):
        return None
    if body[pos] != AX25_UI_CONTROL or body[pos + 1] != AX25_UI_PID:
        return None

    payload = body[pos + 2 :]
    dst_call, dst_ssid, _ = addresses[0]
    src_call, src_ssid, _ = addresses[-1]
    return (
        format_callsign(src_call, src_ssid),
        format_callsign(dst_call, dst_ssid),
        payload,
    )
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com