Skip to content

Building AX.25 Frames

AX.25 sends all of its data in packets that contain data frames. While a full frame sent over the air contains not only the addressing, control, and information segments of the frame, but also Cyclic Redundancy Check information, we only concern ourselves with the first three. This is because KISS mode from TNCs does not contain the checksum section of the packet, and it is not relevant to anything within the scope of AX.25's function1.

These frames are implemented primarily using Python dataclasses with assemble and disassemble functions that serialize and deserialize the frames for sending over relevant interfaces, respectively.

Much of the contents of this page is described in more detail within the AX.25 v2.2 specification document, though note that pax25 does not implement this specification in full, focusing on a subset closer to what might be an unpublished/unformalized '2.0' standard widely compatible with known equipment.

The Frame class

The Frame class is used to assemble and disassemble AX.25 frames. Frames may be of three types, determined by their Control field, may contain a protocol ID, and may contain an information field. They contain addressing information that are used to route the packet.

Known protocol IDs are listed in the AX.25 v2.2 specification, but most commonly, the 'text' protocol, or perhaps better labeled 'no special protocol' is used.

The information field is used by Unnumbered Information frames and by Information frames. It contains a data payload to be used by the application layer-- such as text to display to the user.

pax25.ax25.frame.Frame

Represents an AX.25 frame for transmission or reception

Source code in pax25/ax25/frame.py
class Frame(NamedTuple):
    """
    Represents an AX.25 frame for transmission or reception
    """

    pid: int | None
    route: Route
    control: Unnumbered | Supervisory | Info
    info: bytes = b""

    def size(self) -> int:
        return sum(
            size(item or "")
            for item in (
                self.route,
                self.control,
                (self.pid is not None) and self.pid.to_bytes(1, AX25_ENDIAN),
                self.info,
            )
        )

    def __str__(self) -> str:
        """
        String representation of an AX25 frame. Emulates (mostly) how a TNC displays a
        frame, with the main exception being that we display binary data as its hex
        representation rather than sending it literally.
        """
        segments = [
            str(self.route),
            ": ",
        ]
        control_segment = str(self.control)
        if is_command(self):
            control_segment = f"<{control_segment}>"
        elif is_response(self):
            control_segment = f"<{control_segment}>".lower()
        segments.append(control_segment)
        if self.info:
            segments.extend(
                [
                    ":",
                    os.linesep,
                    normalize_line_endings(self.info).decode(
                        encoding="utf-8", errors="backslashreplace"
                    ),
                ]
            )
        return "".join(segments)

    def assemble(self) -> bytes:
        """
        Assemble this frame into a bytearray suitable for transmission.
        """
        data = bytearray()
        data.extend(self.route.assemble())
        data.extend(self.control.assemble())
        # PID could only be set if the control byte is set, per disassembly.
        if self.pid is not None:
            data.extend(bytearray(self.pid.to_bytes(1, AX25_ENDIAN)))
        data.extend(self.info)
        return bytes(data)

    @classmethod
    def disassemble(cls, data: bytes) -> Self:
        """
        Given a bytestream frame pulled from the wire, create an Frame instance.
        """
        data, route = consume_assembler(data, Route)
        control_class = derive_control_class(data)
        data, control = consume_assembler(data, control_class)
        try:
            pid = data[0]
        except IndexError as err:
            if not isinstance(control, Supervisory | Unnumbered):
                raise DisassemblyError("Protocol ID is missing.") from err
            pid = None
        data = data[1:]
        info = bytes(data)
        return cls(
            route=route,
            control=cast(Unnumbered | Info | Supervisory, control),
            pid=pid,
            info=info,
        )

    def to_json(self) -> JSONObj:
        return {
            "__class__": self.__class__.__name__,
            "pid": self.pid,
            "route": self.route.to_json(),
            "control": self.control.to_json(),
            "info": repr(self.info),
        }

    @classmethod
    def from_json(cls, obj: JSONObj) -> Self:
        kwargs = {
            "pid": cast(int, obj["pid"]),
            "route": Route.from_json(cast(JSONObj, obj["route"])),
            "control": control_from_json(cast(JSONObj, obj["control"])),
            "info": literal_eval(cast(str, obj["info"])),
        }
        return cls(**kwargs)

__str__() -> str

String representation of an AX25 frame. Emulates (mostly) how a TNC displays a frame, with the main exception being that we display binary data as its hex representation rather than sending it literally.

Source code in pax25/ax25/frame.py
def __str__(self) -> str:
    """
    String representation of an AX25 frame. Emulates (mostly) how a TNC displays a
    frame, with the main exception being that we display binary data as its hex
    representation rather than sending it literally.
    """
    segments = [
        str(self.route),
        ": ",
    ]
    control_segment = str(self.control)
    if is_command(self):
        control_segment = f"<{control_segment}>"
    elif is_response(self):
        control_segment = f"<{control_segment}>".lower()
    segments.append(control_segment)
    if self.info:
        segments.extend(
            [
                ":",
                os.linesep,
                normalize_line_endings(self.info).decode(
                    encoding="utf-8", errors="backslashreplace"
                ),
            ]
        )
    return "".join(segments)

assemble() -> bytes

Assemble this frame into a bytearray suitable for transmission.

Source code in pax25/ax25/frame.py
def assemble(self) -> bytes:
    """
    Assemble this frame into a bytearray suitable for transmission.
    """
    data = bytearray()
    data.extend(self.route.assemble())
    data.extend(self.control.assemble())
    # PID could only be set if the control byte is set, per disassembly.
    if self.pid is not None:
        data.extend(bytearray(self.pid.to_bytes(1, AX25_ENDIAN)))
    data.extend(self.info)
    return bytes(data)

disassemble(data: bytes) -> Self classmethod

Given a bytestream frame pulled from the wire, create an Frame instance.

Source code in pax25/ax25/frame.py
@classmethod
def disassemble(cls, data: bytes) -> Self:
    """
    Given a bytestream frame pulled from the wire, create an Frame instance.
    """
    data, route = consume_assembler(data, Route)
    control_class = derive_control_class(data)
    data, control = consume_assembler(data, control_class)
    try:
        pid = data[0]
    except IndexError as err:
        if not isinstance(control, Supervisory | Unnumbered):
            raise DisassemblyError("Protocol ID is missing.") from err
        pid = None
    data = data[1:]
    info = bytes(data)
    return cls(
        route=route,
        control=cast(Unnumbered | Info | Supervisory, control),
        pid=pid,
        info=info,
    )

Routes

Routes are a series of address headers at the beginning of a packet. Two address headers are required for every packet: The source and the destination headers. Address headers after this point are interpreted as digipeaters.

pax25.ax25.address.Route

Specification for a packet's intended route of traffic-- its source, its destination, and what digipeaters lay between.

Source code in pax25/ax25/address.py
class Route(NamedTuple):
    """
    Specification for a packet's intended route of traffic-- its source,
    its destination, and what digipeaters lay between.
    """

    src: AddressHeader
    dest: AddressHeader
    digipeaters: tuple[AddressHeader, ...] = tuple()

    def size(self) -> int:
        """
        Length, in bytes, of this structure when assembled.
        """
        return (2 + len(self.digipeaters)) * AX25_ADDR_SIZE

    def __str__(self) -> str:
        """
        Represents a route path for a frame in string form. Will add a * to the last
        repeater which has repeated the frame.
        """
        digi_section = ""
        if self.digipeaters:
            digi_section = ","
            digi_strings = []
            last_repeated_index = None
            for index, digi in enumerate(self.digipeaters):
                digi_strings.append(str(digi.address))
                if digi.command_or_repeated:
                    last_repeated_index = index
            if last_repeated_index is not None:
                digi_strings[last_repeated_index] += "*"
            digi_section += ",".join(digi_strings) + "/V"
        return "".join(
            [
                str(self.src.address),
                ">",
                str(self.dest.address),
                digi_section,
            ]
        )

    @classmethod
    def disassemble(cls, data: bytes) -> Self:
        """
        Instantiate a route path from a data byte array. If there is leftover data,
        it will be ignored.
        """
        dest_bytes = data[:AX25_ADDR_SIZE]
        dest = AddressHeader.disassemble(dest_bytes)
        data = data[AX25_ADDR_SIZE:]
        src_bytes = data[:AX25_ADDR_SIZE]
        src = AddressHeader.disassemble(src_bytes)
        last_address = is_last_address(src_bytes[-1])
        data = data[AX25_ADDR_SIZE:]
        digipeaters = []
        while len(data) and not last_address:
            digipeater_bytes = data[:AX25_ADDR_SIZE]
            digipeater = AddressHeader.disassemble(digipeater_bytes)
            last_address = is_last_address(digipeater_bytes[-1])
            digipeaters.append(digipeater)
            data = data[AX25_ADDR_SIZE:]
        if len(digipeaters) > AX25_REPEATER_MAX:
            raise DisassemblyError(
                f"Too many digipeaters specified. Maximum is 8. Received: {digipeaters}"
            )
        return cls(src=src, dest=dest, digipeaters=tuple(digipeaters))

    def assemble(self) -> bytes:
        """
        Assemble the route path into a byte array.
        """
        data = bytearray()
        to_join = (self.dest, self.src) + self.digipeaters
        for item in to_join:
            data.extend(item.assemble())
        # The 'last_address' flag must be set on the last address in the set to indicate
        # no further addresses are forthcoming.
        ssid_byte = int(data.pop())
        ssid_byte |= 1
        return bytes(data) + ssid_byte.to_bytes(1, AX25_ENDIAN)

    def to_json(self) -> JSONObj:
        return {
            "__class__": self.__class__.__name__,
            "src": self.src.to_json(),
            "dest": self.dest.to_json(),
            "digipeaters": [digipeater.to_json() for digipeater in self.digipeaters],
        }

    @classmethod
    def from_json(cls, obj: JSONObj) -> Self:
        kwargs = {
            "src": AddressHeader.from_json(cast(JSONObj, obj["src"])),
            "dest": AddressHeader.from_json(cast(JSONObj, obj["dest"])),
            "digipeaters": tuple(
                AddressHeader.from_json(digipeater)
                for digipeater in cast(list[JSONObj], obj["digipeaters"])
            ),
        }
        return cls(**kwargs)  # type: ignore[arg-type]

__str__() -> str

Represents a route path for a frame in string form. Will add a * to the last repeater which has repeated the frame.

Source code in pax25/ax25/address.py
def __str__(self) -> str:
    """
    Represents a route path for a frame in string form. Will add a * to the last
    repeater which has repeated the frame.
    """
    digi_section = ""
    if self.digipeaters:
        digi_section = ","
        digi_strings = []
        last_repeated_index = None
        for index, digi in enumerate(self.digipeaters):
            digi_strings.append(str(digi.address))
            if digi.command_or_repeated:
                last_repeated_index = index
        if last_repeated_index is not None:
            digi_strings[last_repeated_index] += "*"
        digi_section += ",".join(digi_strings) + "/V"
    return "".join(
        [
            str(self.src.address),
            ">",
            str(self.dest.address),
            digi_section,
        ]
    )

assemble() -> bytes

Assemble the route path into a byte array.

Source code in pax25/ax25/address.py
def assemble(self) -> bytes:
    """
    Assemble the route path into a byte array.
    """
    data = bytearray()
    to_join = (self.dest, self.src) + self.digipeaters
    for item in to_join:
        data.extend(item.assemble())
    # The 'last_address' flag must be set on the last address in the set to indicate
    # no further addresses are forthcoming.
    ssid_byte = int(data.pop())
    ssid_byte |= 1
    return bytes(data) + ssid_byte.to_bytes(1, AX25_ENDIAN)

disassemble(data: bytes) -> Self classmethod

Instantiate a route path from a data byte array. If there is leftover data, it will be ignored.

Source code in pax25/ax25/address.py
@classmethod
def disassemble(cls, data: bytes) -> Self:
    """
    Instantiate a route path from a data byte array. If there is leftover data,
    it will be ignored.
    """
    dest_bytes = data[:AX25_ADDR_SIZE]
    dest = AddressHeader.disassemble(dest_bytes)
    data = data[AX25_ADDR_SIZE:]
    src_bytes = data[:AX25_ADDR_SIZE]
    src = AddressHeader.disassemble(src_bytes)
    last_address = is_last_address(src_bytes[-1])
    data = data[AX25_ADDR_SIZE:]
    digipeaters = []
    while len(data) and not last_address:
        digipeater_bytes = data[:AX25_ADDR_SIZE]
        digipeater = AddressHeader.disassemble(digipeater_bytes)
        last_address = is_last_address(digipeater_bytes[-1])
        digipeaters.append(digipeater)
        data = data[AX25_ADDR_SIZE:]
    if len(digipeaters) > AX25_REPEATER_MAX:
        raise DisassemblyError(
            f"Too many digipeaters specified. Maximum is 8. Received: {digipeaters}"
        )
    return cls(src=src, dest=dest, digipeaters=tuple(digipeaters))

size() -> int

Length, in bytes, of this structure when assembled.

Source code in pax25/ax25/address.py
def size(self) -> int:
    """
    Length, in bytes, of this structure when assembled.
    """
    return (2 + len(self.digipeaters)) * AX25_ADDR_SIZE

Address Headers

Address headers for AX.25 frames contain a source, a destination, and a list of Digipeaters. Every address is paired with header information. When in the digipeater list, the header information is used to determine whether the packet has been repeated by the addressed station-- that is, digipeaters recreate the packet with their respective flag set on. This ensures the next station knows that it's their turn to repeat, and also prevents infinite loops in the case they hear their own packet being transmitted.

When not in the digipeater list, the header information is primarily used to signal whether the frame is a command or response frame, or neither.

After each address is a single bit used to indicate whether an additional address follows. This bit is automatically added and set when assembling the route dataclass and isn't exposed on the Address Headers class, as it has no function outside the route's larger context and setting it inappropriately could result in errata or undefined behavior.

pax25.ax25.address.AddressHeader

Address with metadata.

Source code in pax25/ax25/address.py
class AddressHeader(NamedTuple):
    """
    Address with metadata.
    """

    address: Address
    # These reserved bits are collected, but we don't have any particular use for them.
    # They can be used by a network for whatever purpose it deems useful.
    reserved: int = 3
    command_or_repeated: bool = False

    def size(self) -> int:
        """
        Length, in bytes, of this structure when assembled.
        """
        return AX25_ADDR_SIZE

    def __str__(self) -> str:
        string = str(self.address)
        if self.command_or_repeated:
            string += "*"
        if self.reserved != 3:
            string += f"(R{self.reserved})"
        return string

    @classmethod
    def disassemble(cls, data: bytes) -> Self:
        """
        Instantiate an AddressHeader from bytes. Will fail if the length of
        bytes is not precisely correct.
        """
        address = Address.disassemble(data)
        flags = data[-1]
        reserved = (flags & AX25_SSID_MASK_RESERVED) >> AX25_SSID_SHIFT_RESERVED
        command = bool(flags & AX25_SSID_MASK_COMMAND)
        return cls(
            address=address,
            reserved=reserved,
            command_or_repeated=command,
        )

    def assemble(self) -> bytes:
        """
        Assemble the AddressHeader into bytes suitable for transmission.
        """
        data = self.address.assemble()
        # Add bitmask to set the ch flag as necessary on the last byte.
        ssid_byte = data[-1]
        data = data[:-1]
        ssid_byte |= self.reserved << AX25_SSID_SHIFT_RESERVED
        ssid_byte |= self.command_or_repeated and AX25_SSID_MASK_COMMAND
        data += ssid_byte.to_bytes(1, AX25_ENDIAN)
        return data

    def to_json(self) -> JSONObj:
        return {
            "__class__": self.__class__.__name__,
            "address": self.address.to_json(),
            "reserved": self.reserved,
            "command_or_repeated": self.command_or_repeated,
        }

    @classmethod
    def from_json(cls, obj: JSONObj) -> Self:
        kwargs = {
            "address": Address.from_json(cast(JSONObj, obj["address"])),
            "reserved": cast(int, obj["reserved"]),
            "command_or_repeated": cast(bool, obj["command_or_repeated"]),
        }
        return cls(**kwargs)  # type: ignore[arg-type]

assemble() -> bytes

Assemble the AddressHeader into bytes suitable for transmission.

Source code in pax25/ax25/address.py
def assemble(self) -> bytes:
    """
    Assemble the AddressHeader into bytes suitable for transmission.
    """
    data = self.address.assemble()
    # Add bitmask to set the ch flag as necessary on the last byte.
    ssid_byte = data[-1]
    data = data[:-1]
    ssid_byte |= self.reserved << AX25_SSID_SHIFT_RESERVED
    ssid_byte |= self.command_or_repeated and AX25_SSID_MASK_COMMAND
    data += ssid_byte.to_bytes(1, AX25_ENDIAN)
    return data

disassemble(data: bytes) -> Self classmethod

Instantiate an AddressHeader from bytes. Will fail if the length of bytes is not precisely correct.

Source code in pax25/ax25/address.py
@classmethod
def disassemble(cls, data: bytes) -> Self:
    """
    Instantiate an AddressHeader from bytes. Will fail if the length of
    bytes is not precisely correct.
    """
    address = Address.disassemble(data)
    flags = data[-1]
    reserved = (flags & AX25_SSID_MASK_RESERVED) >> AX25_SSID_SHIFT_RESERVED
    command = bool(flags & AX25_SSID_MASK_COMMAND)
    return cls(
        address=address,
        reserved=reserved,
        command_or_repeated=command,
    )

size() -> int

Length, in bytes, of this structure when assembled.

Source code in pax25/ax25/address.py
def size(self) -> int:
    """
    Length, in bytes, of this structure when assembled.
    """
    return AX25_ADDR_SIZE

Addresses

Addresses are composed of two segments: A name, and an SSID. Address names must be between 1 and 7 characters long, and must be uppercase characters present in the ASCII table.

SSIDs must be between 0 and 15, totaling 16 possible SSIDs per name. Stations typically use their callsign as their name, but may elect to use other names, so long as beacon indicates the callsign using this name1.

pax25.ax25.address.Address

Address information used for connections.

Source code in pax25/ax25/address.py
class Address(NamedTuple):
    """
    Address information used for connections.
    """

    name: str
    ssid: int = 0

    def size(self) -> int:
        """
        Length, in bytes, of this structure when assembled.
        """
        return AX25_ADDR_SIZE

    @classmethod
    def disassemble(cls, data: bytes) -> Self:
        """
        Instantiate an Address from bytes. Will fail if the length of
        bytes is not precisely correct. Ignores flags on the SSID byte.
        """

        if len(data) != AX25_ADDR_SIZE:
            raise DisassemblyError(
                f"Address of incorrect length. Expected {AX25_ADDR_SIZE}, "
                f"got {len(data)} ({data!r})"
            )
        ssid = int((data[-1] & AX25_SSID_MASK_SSID) >> AX25_SSID_SHIFT_SSID)
        array = bytearray(data)
        # Addresses may only be valid ASCII values for upper-case letters or numbers.
        for i in range(0, AX25_ADDR_SIZE - 1):
            array[i] >>= 1
            if not (array[i] == 32 or (48 <= array[i] <= 57) or (65 <= array[i] <= 90)):
                raise DisassemblyError(f"ERROR: Corrupt address field: {array}")
        # ...But we decode as utf-8 so we don't end up with type errors later.
        name = array[: AX25_ADDR_SIZE - 1].decode("utf-8").strip()
        return cls(name=name, ssid=ssid)

    def assemble(self) -> bytes:
        """
        Takes the address and serializes it to a byte array.
        """
        data = bytearray()
        name = self.name.ljust(AX25_ADDR_SIZE - 1)
        data += bytes(name, AX25_CODEPAGE)
        for i in range(0, AX25_ADDR_SIZE - 1):
            data[i] <<= 1
        ssid_byte = 0
        ssid_byte = ssid_byte | (self.ssid << AX25_SSID_SHIFT_SSID)
        data += ssid_byte.to_bytes(1, AX25_ENDIAN)
        return bytes(data)

    @classmethod
    def from_pattern_string(cls, data: str) -> Iterator[Self]:
        """
        Generates an iterable of all matching strings for an address pattern.

        If a string is a normal address, like KW6FOX or K1LEO-2, it will return only
        that entry. However, if it's a pattern like KW6FOX-*, it will return all valid
        SSIDs for KW6FOX.

        In the _connect_future, we may support more patterns than
        """
        segments = data.split("-", maxsplit=1)
        if len(segments) < 2:
            yield cls.from_string(data)
            return
        if segments[1] == "*":
            for i in range(16):
                yield cls(name=segments[0], ssid=i)
            return
        yield cls.from_string(data)

    @classmethod
    def from_string(cls, data: str) -> Self:
        """
        Given a standard address string, create an Address. Address strings look like:
        KW6FOX
        K1LEO-3
        FOXBOX
        """
        if not data:
            raise ValueError("Empty strings are not valid addresses.")
        segments = data.split("-", 2)
        name = segments[0].upper()
        if 7 < len(name) > 0:
            raise ValueError(
                f"Names must be between one and six letters. {repr(name)} is invalid."
            )
        if not name.isalnum():
            raise ValueError(f"Names must be alphanumeric. {repr(name)} is invalid.")
        try:
            name.encode("ascii")
        except ValueError as err:
            raise ValueError(
                f"Invalid character(s) found. Must be ASCII. {repr(name)} is invalid."
            ) from err
        ssid = 0
        if len(segments) == 2:
            try:
                ssid = int(segments[1])
            except ValueError as error:
                raise ValueError(
                    f"SSID must be an integer. Found: {repr(segments[1])}"
                ) from error
            if not 0 <= ssid <= 15:
                raise ValueError(
                    f"SSID must be between 0 and 15 inclusive. Found: {ssid}"
                )
        return cls(name=name, ssid=ssid)

    def __str__(self) -> str:
        """
        Form to string representation.
        """
        if self.ssid == 0:
            return self.name
        return f"{self.name}-{self.ssid}"

    def to_json(self) -> JSONObj:
        return {
            "__class__": self.__class__.__name__,
            "name": self.name,
            "ssid": self.ssid,
        }

    @classmethod
    def from_json(cls, obj: JSONObj) -> Self:
        kwargs = {
            "name": cast(str, obj["name"]),
            "ssid": cast(int, obj["ssid"]),
        }
        return cls(**kwargs)  # type: ignore[arg-type]

__str__() -> str

Form to string representation.

Source code in pax25/ax25/address.py
def __str__(self) -> str:
    """
    Form to string representation.
    """
    if self.ssid == 0:
        return self.name
    return f"{self.name}-{self.ssid}"

assemble() -> bytes

Takes the address and serializes it to a byte array.

Source code in pax25/ax25/address.py
def assemble(self) -> bytes:
    """
    Takes the address and serializes it to a byte array.
    """
    data = bytearray()
    name = self.name.ljust(AX25_ADDR_SIZE - 1)
    data += bytes(name, AX25_CODEPAGE)
    for i in range(0, AX25_ADDR_SIZE - 1):
        data[i] <<= 1
    ssid_byte = 0
    ssid_byte = ssid_byte | (self.ssid << AX25_SSID_SHIFT_SSID)
    data += ssid_byte.to_bytes(1, AX25_ENDIAN)
    return bytes(data)

disassemble(data: bytes) -> Self classmethod

Instantiate an Address from bytes. Will fail if the length of bytes is not precisely correct. Ignores flags on the SSID byte.

Source code in pax25/ax25/address.py
@classmethod
def disassemble(cls, data: bytes) -> Self:
    """
    Instantiate an Address from bytes. Will fail if the length of
    bytes is not precisely correct. Ignores flags on the SSID byte.
    """

    if len(data) != AX25_ADDR_SIZE:
        raise DisassemblyError(
            f"Address of incorrect length. Expected {AX25_ADDR_SIZE}, "
            f"got {len(data)} ({data!r})"
        )
    ssid = int((data[-1] & AX25_SSID_MASK_SSID) >> AX25_SSID_SHIFT_SSID)
    array = bytearray(data)
    # Addresses may only be valid ASCII values for upper-case letters or numbers.
    for i in range(0, AX25_ADDR_SIZE - 1):
        array[i] >>= 1
        if not (array[i] == 32 or (48 <= array[i] <= 57) or (65 <= array[i] <= 90)):
            raise DisassemblyError(f"ERROR: Corrupt address field: {array}")
    # ...But we decode as utf-8 so we don't end up with type errors later.
    name = array[: AX25_ADDR_SIZE - 1].decode("utf-8").strip()
    return cls(name=name, ssid=ssid)

from_pattern_string(data: str) -> Iterator[Self] classmethod

Generates an iterable of all matching strings for an address pattern.

If a string is a normal address, like KW6FOX or K1LEO-2, it will return only that entry. However, if it's a pattern like KW6FOX-*, it will return all valid SSIDs for KW6FOX.

In the _connect_future, we may support more patterns than

Source code in pax25/ax25/address.py
@classmethod
def from_pattern_string(cls, data: str) -> Iterator[Self]:
    """
    Generates an iterable of all matching strings for an address pattern.

    If a string is a normal address, like KW6FOX or K1LEO-2, it will return only
    that entry. However, if it's a pattern like KW6FOX-*, it will return all valid
    SSIDs for KW6FOX.

    In the _connect_future, we may support more patterns than
    """
    segments = data.split("-", maxsplit=1)
    if len(segments) < 2:
        yield cls.from_string(data)
        return
    if segments[1] == "*":
        for i in range(16):
            yield cls(name=segments[0], ssid=i)
        return
    yield cls.from_string(data)

from_string(data: str) -> Self classmethod

Given a standard address string, create an Address. Address strings look like: KW6FOX K1LEO-3 FOXBOX

Source code in pax25/ax25/address.py
@classmethod
def from_string(cls, data: str) -> Self:
    """
    Given a standard address string, create an Address. Address strings look like:
    KW6FOX
    K1LEO-3
    FOXBOX
    """
    if not data:
        raise ValueError("Empty strings are not valid addresses.")
    segments = data.split("-", 2)
    name = segments[0].upper()
    if 7 < len(name) > 0:
        raise ValueError(
            f"Names must be between one and six letters. {repr(name)} is invalid."
        )
    if not name.isalnum():
        raise ValueError(f"Names must be alphanumeric. {repr(name)} is invalid.")
    try:
        name.encode("ascii")
    except ValueError as err:
        raise ValueError(
            f"Invalid character(s) found. Must be ASCII. {repr(name)} is invalid."
        ) from err
    ssid = 0
    if len(segments) == 2:
        try:
            ssid = int(segments[1])
        except ValueError as error:
            raise ValueError(
                f"SSID must be an integer. Found: {repr(segments[1])}"
            ) from error
        if not 0 <= ssid <= 15:
            raise ValueError(
                f"SSID must be between 0 and 15 inclusive. Found: {ssid}"
            )
    return cls(name=name, ssid=ssid)

size() -> int

Length, in bytes, of this structure when assembled.

Source code in pax25/ax25/address.py
def size(self) -> int:
    """
    Length, in bytes, of this structure when assembled.
    """
    return AX25_ADDR_SIZE

Control fields

There are three main categories of AX.25 frame, each determined by its control field. Each is described in its relevant section below.

Unnumbered Frames

Unnumbered frames contain either commands or information not associated with any virtual circuit. This includes commands used to create and terminate virtual circuits. The most common AX.25 frame in use today is the Unnumbered Information frame, which is used by systems such as APRS and beacons more generally.

Some unnumbered frames must be marked as command or response frames to have their intended effect.

pax25.ax25.control.Unnumbered

Unnumbered control header flags.

Source code in pax25/ax25/control.py
class Unnumbered(NamedTuple):
    """
    Unnumbered control header flags.
    """

    frame_type: UFrameType
    poll_or_final: bool = False

    @property
    def type(self) -> Literal[FrameType.UNNUMBERED]:
        """
        Returns the frame's type.
        """
        return FrameType.UNNUMBERED

    def size(self) -> int:
        """
        Unnumbered control is one byte, always.
        """
        return 1

    def __str__(self) -> str:
        """
        String representation of an unnumbered control field.
        """
        return f"<{UCOMMAND_DISPLAY[self.frame_type]}>"

    @classmethod
    def disassemble(cls, data: bytes) -> Self:
        """
        Disassembles a control byte for an unnumbered frame.
        """
        byte = data[0]
        if (byte & AX25_CTRL_UI) != AX25_CTRL_UI:
            raise DisassemblyError(
                f"Attempted to disassemble a control byte as an "
                f"unnumbered control byte, but it was something else. "
                f"Byte was {byte!r}."
            )

        frame_type = U_FRAME_MAP[byte & AX25_CTRL_INVMASK_PF]
        poll_final = bool(byte & AX25_CTRL_MASK_PF)
        return cls(frame_type=frame_type, poll_or_final=poll_final)

    def assemble(self) -> bytes:
        """
        Assembles a control byte for an unnumbered frame.
        """
        data = int(self.poll_or_final)
        data <<= AX25_CTRL_SHIFT_PF
        data |= self.frame_type.value
        return data.to_bytes(1, AX25_ENDIAN)

    @classmethod
    def from_json(cls, obj: JSONObj) -> Self:
        kwargs = {
            "frame_type": getattr(UFrameType, cast(str, obj["frame_type"]).strip("_")),
            "poll_or_final": cast(bool, obj["poll_or_final"]),
        }
        return cls(**kwargs)

    def to_json(self) -> JSONObj:
        return {
            "__class__": self.__class__.__name__,
            "frame_type": self.frame_type.name,
            "poll_or_final": self.poll_or_final,
        }

type: Literal[FrameType.UNNUMBERED] property

Returns the frame's type.

__str__() -> str

String representation of an unnumbered control field.

Source code in pax25/ax25/control.py
def __str__(self) -> str:
    """
    String representation of an unnumbered control field.
    """
    return f"<{UCOMMAND_DISPLAY[self.frame_type]}>"

assemble() -> bytes

Assembles a control byte for an unnumbered frame.

Source code in pax25/ax25/control.py
def assemble(self) -> bytes:
    """
    Assembles a control byte for an unnumbered frame.
    """
    data = int(self.poll_or_final)
    data <<= AX25_CTRL_SHIFT_PF
    data |= self.frame_type.value
    return data.to_bytes(1, AX25_ENDIAN)

disassemble(data: bytes) -> Self classmethod

Disassembles a control byte for an unnumbered frame.

Source code in pax25/ax25/control.py
@classmethod
def disassemble(cls, data: bytes) -> Self:
    """
    Disassembles a control byte for an unnumbered frame.
    """
    byte = data[0]
    if (byte & AX25_CTRL_UI) != AX25_CTRL_UI:
        raise DisassemblyError(
            f"Attempted to disassemble a control byte as an "
            f"unnumbered control byte, but it was something else. "
            f"Byte was {byte!r}."
        )

    frame_type = U_FRAME_MAP[byte & AX25_CTRL_INVMASK_PF]
    poll_final = bool(byte & AX25_CTRL_MASK_PF)
    return cls(frame_type=frame_type, poll_or_final=poll_final)

size() -> int

Unnumbered control is one byte, always.

Source code in pax25/ax25/control.py
def size(self) -> int:
    """
    Unnumbered control is one byte, always.
    """
    return 1

A full listing of available Unnumbered Frame types is available in the specification.

Supervisory Frames

Supervisory frames are used to communicate information about the state of the current virtual circuit, such as when a packet has been rejected, or when a station is ready to receive more packets.

Supervisory frames do not have populated information fields and do not have a protocol ID. These should always be unset when constructing them-- unless you are doing something completely custom not supported by the specification (not recommended.)

Some supervisory frames must be marked as command or response frames to have their intended effect.

Information Frames

Information frames are the data that applications are transmitting and receiving in virtual circuits. They are given sequence numbers so that interacting stations can know if any frames are missing and need to be retransmitted. The number of frames which may be handled at a time is called the frame window, and this is 7 for pax25's implementation, or 127 for a mod128 implementation, not yet supported.

Information frames also contain supplementary acknowledgement information so that additional supervisory frames need not be sent during volumes of high information exchange.

Command and Response Frames

Some Unnumbered and Supervisory frames can be marked as 'command' or 'response' frames. This allows them to serve multiple semantic meanings. For example, a 'Receive Ready' frame, when not marked as a command or response frame, indicates that a station is ready to receive more data. Its sequence number is used to tell the other station what frame it expects to receive next.

However, when used as a command frame, the meaning changes to 'Did you receive this frame?' and the sequence number is repurposed to indicate to the remote station what frame it is asking about.

After receiving a command frame, a response frame is then sent by the other station. When marked as a response frame, it indicates whether it received that frame, or, if not, what the last frame it received was, so that the other state ment can transmit all intermediary frames.

Marking a frame as a command or response frame requires changing bits both in the source and destination headers, as well as toggling the 'poll' bit in the control field. Utilities for marking frames as command or response are available in the frame utilities.


  1. This is a legal requirement, not a technical one. Per amateur license requirements, stations MUST identify themselves when transmitting, and if a station's name is not its callsign, it cannot be identified without use of a supplementary beacon.