hfmodemDocumentation

frame.py

# SPDX-License-Identifier: AGPL-3.0-only
# SPDX-FileCopyrightText: 2026 Saul St John (W9SSJ)

"""AX.25 addresses and frames; public frame values exclude the FCS."""
from __future__ import annotations

from dataclasses import dataclass

from hfmodem.core.crc import crc16

CALLSIGN_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 "


def address(call: str, *, last: bool = False, flag: bool = False) -> bytes:
    name, sep, suffix = call.partition("-")
    if not 1 <= len(name) <= 6 or any(c not in CALLSIGN_CHARS[:-1] for c in name):
        raise ValueError("callsign must contain one to six uppercase letters or digits")
    try:
        ssid = int(suffix) if sep else 0
    except ValueError:
        raise ValueError("invalid SSID") from None
    if not 0 <= ssid <= 15:
        raise ValueError("SSID must be 0 to 15")
    return bytes(ord(c) << 1 for c in name.ljust(6)) + bytes([
        0x60 | ssid << 1 | int(flag) << 7 | int(last)])


def validate_address(raw: bytes) -> None:
    if len(raw) != 7:
        raise ValueError("an address must have seven bytes")
    if any(b & 1 or chr(b >> 1) not in CALLSIGN_CHARS for b in raw[:6]):
        raise ValueError("invalid shifted callsign character")


@dataclass(frozen=True)
class Frame:
    addresses: tuple[bytes, ...]
    control: int
    pid: int | None
    info: bytes

    def encode(self) -> bytes:
        needs_pid = self.control & 1 == 0 or self.control & 0xEF == 3
        if needs_pid != (self.pid is not None):
            raise ValueError("PID presence does not match control")
        raw = b"".join(self.addresses) + bytes([self.control])
        if self.pid is not None:
            raw += bytes([self.pid])
        raw += self.info
        parse(raw)
        return raw


def parse(raw: bytes) -> Frame:
    addresses = []
    offset = 0
    while True:
        a = raw[offset:offset + 7]
        validate_address(a)
        addresses.append(a)
        offset += 7
        if len(addresses) > 10:
            raise ValueError("a frame needs two to ten addresses")
        if a[6] & 1:
            break
    if len(addresses) < 2:
        raise ValueError("a frame needs two to ten addresses")
    if offset >= len(raw):
        raise ValueError("missing control byte")
    control = raw[offset]
    offset += 1
    pid = None
    if control & 1 == 0 or control & 0xEF == 3:
        if offset >= len(raw):
            raise ValueError("missing PID")
        pid = raw[offset]
        offset += 1
    return Frame(tuple(addresses), control, pid, raw[offset:])


def ui_frame(src: str, dest: str, info: bytes, via: tuple[str, ...] = ()) -> bytes:
    calls = (dest, src, *via)
    return Frame(tuple(address(c, last=i == len(calls) - 1, flag=i == 0)
                       for i, c in enumerate(calls)), 3, 0xF0, info).encode()


def with_fcs(raw: bytes) -> bytes:
    parse(raw)
    return raw + crc16(raw).to_bytes(2, "little")


def check_fcs(wire: bytes) -> bytes:
    if len(wire) < 2 or crc16(wire[:-2]) != int.from_bytes(wire[-2:], "little"):
        raise ValueError("AX.25 FCS failure")
    parse(wire[:-2])
    return wire[:-2]