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 function.
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 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.
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 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
|
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,
)
|
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 name.
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 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.