Repository: sjlongland/aioax25 (language: python)

Issue:
SABM and SABME swapped in frame.py
In the file frame.py, SABM and SABME classes have their modifiers swapped. The correct values should be :-

Class AX25SetAsyncBalancedModeExtendedFrame should have a modifier of MODIFIER = 0b01101111
Class AX25SetAsyncBalancedModeFrame should have a modifier of MODIFIER = 0b00101111

It also appears that the P/F flag needs more investigation, and should possibly be set. The basis for this is that my PicoPacket TNC with 1996 firmware has the P/F bit set. In my case, the picopacket is using 0x3f as the value for SABM.

I suspect that existing code uses a mask to ignore the p/f bit. 

Following your code, you could ADD these MODIFIER = 0b01101111 and MODIFIER = 0b00101111 respectively for SABME and SABM. 


Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index 1ae85db..2e20669 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,3 +1,6 @@
 [run]
 branch=true
 source=aioax25
+omit=
+    tests
+    aioax25/tools/**
diff --git a/.gitignore b/.gitignore
index 266b308..d0108f0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,7 +5,8 @@ __pycache__
 *.egg-info
 *.pyc
 .coverage
-coverage/
+aioax25.egg-info/
+cover/
 testreports.xml
 dist/
 build/
diff --git a/README.md b/README.md
index d87531b..ff0eb52 100644
--- a/README.md
+++ b/README.md
@@ -7,20 +7,6 @@ The aim of this project is to implement a simple-to-understand asynchronous
 AX.25 library built on `asyncio` and `pyserial`, implementing a AX.25 and APRS
 stack in pure Python.
 
-## Python 3.5+ and above is required as of 2021-11-12
-
-I did try to support 3.4, but this proved to be infeasible for the following
-reasons:
-
-* Python 3.8+ makes `asyncio.coroutine` deprecated (apparently will be removed
-  in 3.10).  This meant I needed `coroutine` and `async def` versions of some
-  API functions, and the necessary logic to "hide" the latter from Python 3.4.
-* Trying to coax generator-based coroutines to run "in the background" for unit
-  test purposes proved to be a pain in the arse.
-
-Python 3.5 support is planned to continue until it too, becomes infeasible
-(e.g. if type annotations become required).
-
 ## What works
 
 * We can put a Kantronics KPC-3 TNC into KISS mode automatically
@@ -29,11 +15,12 @@ Python 3.5 support is planned to continue until it too, becomes infeasible
   [NWDR UDRC-II](https://nw-digital-radio.groups.io/g/udrc/wiki/home))
 * We can receive AX.25 UI frames
 * We can send AX.25 UI frames
+* Connecting to AX.25 nodes (*experimental*)
+* Accepting connections from AX.25 nodes (*experimental*)
 
 ## What doesn't work
 
-* Connecting to AX.25 nodes
-* Accepting connections from AX.25 nodes
+* Nothing yet?
 
 ## What isn't tested
 
@@ -41,15 +28,17 @@ Python 3.5 support is planned to continue until it too, becomes infeasible
 
 ## Current plans
 
-Right now, I intend to get enough going for APRS operation, as that is my
-immediate need now.  Hence the focus on UI frames.
+Right now, I have the smarts to deal with basic APRS messaging.  Hence the
+focus on UI frames.  We can send and receive APRS message frames, parse some
+kinds of position frames, and do basic UI frame stuff.
 
-I intend to write a core class that will take care of some core AX.25 message
-handling work and provide the basis of what's needed to implement APRS.
+Preliminary support for AX.25 connected mode is present, but is _experimental_.
+It was tested connecting to a BPQ32 node, and being connected to by a BPQ32
+node, but not a lot of testing has been done at this stage.  **More feedback
+would be appreciated.**  The API is also very much a work-in-progress.
 
 After that, some things I'd like to tackle in no particular order:
 
-* Connected mode operation
 * NET/ROM support
 
 Supported platforms will be GNU/Linux, and possibly BSD variants.  I don't
@@ -314,3 +303,11 @@ The `APRSDigipeater` class constructor can take a single parameter,
 `digipeater_timeout`, which sets an expiry (default of 5 seconds) on queued
 digipeat messages.  If a message is not sent by the time this timeout expires,
 the message is quietly dropped, preventing the memory effect.
+
+## Specifications
+
+This library is built on the following specifications:
+
+* [AX.25 2.0](https://htmlpreview.github.io/?https://github.com/sjlongland/aioax25/blob/feature/connected-mode/doc/ax25-2p0/index.html)
+* [AX.25 2.2](doc/ax25-2p2/ax25-2p2.pdf)
+* [APRS 1.01](http://www.aprs.org/doc/APRS101.PDF)
diff --git a/aioax25/aprs/aprs.py b/aioax25/aprs/aprs.py
index a32f489..4513cf9 100644
--- a/aioax25/aprs/aprs.py
+++ b/aioax25/aprs/aprs.py
@@ -78,7 +78,6 @@ def __init__(
         # Logger instance
         log=None,
     ):
-
         super(APRSRouter, self).__init__()
         if log is None:
             log = logging.getLogger(self.__class__.__module__)
diff --git a/aioax25/aprs/datatype.py b/aioax25/aprs/datatype.py
index 0c80d19..82a8eab 100644
--- a/aioax25/aprs/datatype.py
+++ b/aioax25/aprs/datatype.py
@@ -13,28 +13,30 @@ class APRSDataType(Enum):
     not including unused or reserved types.  Page 17 of APRS 1.0.1 spec.
     """
 
-    MIC_E_BETA0 = 0x1C
-    MIC_E_OLD_BETA0 = 0x1D
-    POSITION = ord("!")
-    PEET_BROS_WX1 = ord("#")
-    RAW_GPRS_ULT2K = ord("$")
-    AGRELO_DFJR = ord("%")
-    RESERVED_MAP = ord("&")
-    MIC_E_OLD = ord("'")
-    ITEM = ord(")")
-    PEET_BROS_WX2 = ord("*")
-    TEST_DATA = ord(",")
-    POSITION_TS = ord("/")
-    MESSAGE = ord(":")
-    OBJECT = ord(";")
-    STATIONCAP = ord("<")
-    POSITION_MSGCAP = ord("=")
-    STATUS = ord(">")
-    QUERY = ord("?")
+    # fmt: off
+    MIC_E_BETA0        = 0x1C
+    MIC_E_OLD_BETA0    = 0x1D
+    POSITION           = ord("!")
+    PEET_BROS_WX1      = ord("#")
+    RAW_GPRS_ULT2K     = ord("$")
+    AGRELO_DFJR        = ord("%")
+    RESERVED_MAP       = ord("&")
+    MIC_E_OLD          = ord("'")
+    ITEM               = ord(")")
+    PEET_BROS_WX2      = ord("*")
+    TEST_DATA          = ord(",")
+    POSITION_TS        = ord("/")
+    MESSAGE            = ord(":")
+    OBJECT             = ord(";")
+    STATIONCAP         = ord("<")
+    POSITION_MSGCAP    = ord("=")
+    STATUS             = ord(">")
+    QUERY              = ord("?")
     POSITION_TS_MSGCAP = ord("@")
-    TELEMETRY = ord("T")
-    MAIDENHEAD = ord("[")
-    WX = ord("_")
-    MIC_E = ord("`")
-    USER_DEFINED = ord("{")
-    THIRD_PARTY = ord("}")
+    TELEMETRY          = ord("T")
+    MAIDENHEAD         = ord("[")
+    WX                 = ord("_")
+    MIC_E              = ord("`")
+    USER_DEFINED       = ord("{")
+    THIRD_PARTY        = ord("}")
+    # fmt: on
diff --git a/aioax25/aprs/message.py b/aioax25/aprs/message.py
index d85ce92..7a361e9 100644
--- a/aioax25/aprs/message.py
+++ b/aioax25/aprs/message.py
@@ -176,7 +176,6 @@ def _enter_state(self, state):
 
 
 class APRSMessageFrame(APRSFrame):
-
     MSGID_RE = re.compile(r"{([0-9A-Za-z]+)(}[0-9A-Za-z]*)?(\r?)$")
     ACKREJ_RE = re.compile(r"^(ack|rej)([0-9A-Za-z]+)$")
 
@@ -259,7 +258,6 @@ def __init__(
         cr=True,
         src_cr=None,
     ):
-
         self._addressee = AX25Address.decode(addressee).normalised
         self._msgid = msgid
         self._replyack = replyack
diff --git a/aioax25/frame.py b/aioax25/frame.py
index 367bd61..b4e5432 100644
--- a/aioax25/frame.py
+++ b/aioax25/frame.py
@@ -1,13 +1,53 @@
 #!/usr/bin/env python3
 
 """
-AX.25 framing
+AX.25 framing.  This module defines encoders and decoders for all frame types
+used in version 2.2 of the AX.25 standard.
+
+AX25Frame is the base class, and provides an abstract interface for interacting
+with all frame types.  AX.25 identifies the specific type of frame from bits in
+the control field which can be 8 or 16-bits wide.
+
+The only way to know if it's 16-bits is in witnessing the initial connection
+being made between two stations which means it's impossible for a stateless
+decoder to fully decode an arbitrary AX.25 frame.
+
+Thankfully the control field is sent little-endian format, so the first byte we
+encounter is the least significant bits -- which is sufficient to identify
+whether the frame is an I, S or U frame: the least significant two bits carry
+this information.
+
+For this reason there are 3 sub-classes of this top-level class:
+
+    - AX25RawFrame: This is used when we only need to decode the initial AX.25
+      addressing header to know whether we need to worry about the frame
+      further.
+
+    - AX258BitFrame: This is used for AX.25 frames with an 8-bit control field,
+      which is anything sent by an AX.25 v2.0 station, any un-numbered frame,
+      or any I or S frame where modulo-8 frame numbering is used.
+
+    - AX2516BitFrame: This is used for AX.25 v2.2 stations where modulo-128
+      frame numbering has been negotiated by both parties.
+
+Decoding is done by calling the AX25Frame.decode class method.  This takes two
+parameters:
+
+    - data: either raw bytes or an AX25Frame class.  The latter form is useful
+      when you've previously decoded a frame as a AX25RawFrame and need to
+      further dissect it as either a AX258BitFrame or AX2516BitFrame sub-class.
+
+    - modulo128: by default is None, but if set to a boolean, will decode I or
+      S frames accordingly instead of just returning AX25RawFrame.
 """
 
 import re
 import time
+import enum
 from collections.abc import Sequence
 
+from . import uint
+
 # Frame type classes
 
 
@@ -16,74 +56,106 @@ class AX25Frame(object):
     Base class for AX.25 frames.
     """
 
-    POLL_FINAL = 0b00010000
-
-    CONTROL_I_MASK = 0b00000001
-    CONTROL_I_VAL = 0b00000000
+    # fmt: off
+    # The following are the same for 8 and 16-bit control fields.
+    CONTROL_I_MASK  = 0b00000001
+    CONTROL_I_VAL   = 0b00000000
     CONTROL_US_MASK = 0b00000011
-    CONTROL_S_VAL = 0b00000001
-    CONTROL_U_VAL = 0b00000011
+    CONTROL_S_VAL   = 0b00000001
+    CONTROL_U_VAL   = 0b00000011
 
     # PID codes
-    PID_ISO8208_CCITT = 0x01
+    PID_ISO8208_CCITT   = 0x01
     PID_VJ_IP4_COMPRESS = 0x06
-    PID_VJ_IP4 = 0x07
-    PID_SEGMENTATION = 0x08
-    PID_TEXNET = 0xC3
-    PID_LINKQUALITY = 0xC4
-    PID_APPLETALK = 0xCA
-    PID_APPLETALK_ARP = 0xCB
-    PID_ARPA_IP4 = 0xCC
-    PID_APRA_ARP = 0xCD
-    PID_FLEXNET = 0xCE
-    PID_NETROM = 0xCF
-    PID_NO_L3 = 0xF0
-    PID_ESCAPE = 0xFF
+    PID_VJ_IP4          = 0x07
+    PID_SEGMENTATION    = 0x08
+    PID_TEXNET          = 0xC3
+    PID_LINKQUALITY     = 0xC4
+    PID_APPLETALK       = 0xCA
+    PID_APPLETALK_ARP   = 0xCB
+    PID_ARPA_IP4        = 0xCC
+    PID_APRA_ARP        = 0xCD
+    PID_FLEXNET         = 0xCE
+    PID_NETROM          = 0xCF
+    PID_NO_L3           = 0xF0
+    PID_ESCAPE          = 0xFF
+    # fmt: on
 
     @classmethod
-    def decode(cls, data):
+    def decode(cls, data, modulo128=None):
         """
         Decode a single AX.25 frame from the given data.
         """
-        (header, data) = AX25FrameHeader.decode(data)
+        if isinstance(data, AX25Frame):
+            # We were given a previously decoded frame.
+            header = data.header
+            data = data.frame_payload
+        else:
+            # We were given raw data.
+            (header, data) = AX25FrameHeader.decode(bytes(data))
+
         if not data:
             raise ValueError("Insufficient packet data")
 
-        # Next should be the control field
+        # Next should be the control field.  Control field
+        # can be either 8 or 16-bits, we don't know at this point.
+        # We'll look at the first 8-bits to see if it's a U-frame
+        # since that has a defined bit pattern we can look for.
         control = data[0]
-        data = data[1:]
-
-        if (control & cls.CONTROL_I_MASK) == cls.CONTROL_I_VAL:
-            # This is an I frame - TODO
-            # return AX25InformationFrame.decode(header, control, data)
-            return AX25RawFrame(
-                destination=header.destination,
-                source=header.source,
-                repeaters=header.repeaters,
-                cr=header.cr,
-                src_cr=header.src_cr,
-                control=control,
-                payload=data,
-            )
-        elif (control & cls.CONTROL_US_MASK) == cls.CONTROL_S_VAL:
-            # This is a S frame - TODO
-            # return AX25SupervisoryFrame.decode(header, control, data)
-            return AX25RawFrame(
-                destination=header.destination,
-                source=header.source,
-                repeaters=header.repeaters,
-                cr=header.cr,
-                src_cr=header.src_cr,
-                control=control,
-                payload=data,
-            )
-        elif (control & cls.CONTROL_US_MASK) == cls.CONTROL_U_VAL:
-            # This is a U frame
-            return AX25UnnumberedFrame.decode(header, control, data)
-        else:  # pragma: no cover
-            # This should not happen because all possible bit combinations
-            # are covered above.
-            assert False, "How did we get here?"
+
+        if (control & cls.CONTROL_US_MASK) == cls.CONTROL_U_VAL:
+            # This is a U frame.  Data starts after the control byte
+            # which can only be 8-bits wide.
+            return AX25UnnumberedFrame.decode(header, control, data[1:])
+        else:
+            # This is either a I or S frame, both of which can either
+            # have a 8-bit or 16-bit control field.
+            if modulo128 is True:
+                # And the caller has told us it's a 16-bit field, so let's
+                # decode the rest of it!
+                if len(data) < 2:
+                    raise ValueError("Insufficient packet data")
+                control |= data[1] << 8
+
+                # Discard the control field from the data payload as we
+                # have decoded it now.
+                data = data[2:]
+
+                # We'll use these classes
+                InformationFrame = AX2516BitInformationFrame
+                SupervisoryFrame = AX2516BitSupervisoryFrame
+            elif modulo128 is False:
+                # Caller has told us it's an 8-bit field, so already decoded.
+                data = data[1:]
+
+                # We'll use these classes
+                InformationFrame = AX258BitInformationFrame
+                SupervisoryFrame = AX258BitSupervisoryFrame
+            else:
+                # We don't know at this point so the only safe answer is to
+                # return a raw frame and decode it later.
+                return AX25RawFrame(
+                    destination=header.destination,
+                    source=header.source,
+                    repeaters=header.repeaters,
+                    cr=header.cr,
+                    src_cr=header.src_cr,
+                    payload=data,
+                )
+
+            # We've got the full control field and payload now.
+            if (control & cls.CONTROL_I_MASK) == cls.CONTROL_I_VAL:
+                # This is an I frame.
+                return InformationFrame.decode(header, control, data)
+            elif (control & cls.CONTROL_US_MASK) == cls.CONTROL_S_VAL:
+                # This is a S frame.  No payload expected
+                if len(data):
+                    raise ValueError(
+                        "Supervisory frames do not " "support payloads."
+                    )
+                return SupervisoryFrame.decode(header, control)
+            else:  # pragma: no cover
+                assert False, "Unrecognised control field: 0x%04x" % control
 
     def __init__(
         self,
@@ -109,9 +181,6 @@ def _encode(self):
         for byte in bytes(self.header):
             yield byte
 
-        # Send the control byte
-        yield self._control
-
         # Send the payload
         for byte in self.frame_payload:
             yield byte
@@ -123,7 +192,11 @@ def __bytes__(self):
         return bytes(self._encode())
 
     def __str__(self):
-        return str(self._header)
+        return "%s %s:\nPayload=%r" % (
+            self.__class__.__name__,
+            self.header,
+            self.frame_payload,
+        )
 
     @property
     def timestamp(self):
@@ -150,15 +223,11 @@ def header(self):
         return self._header
 
     @property
-    def control(self):
-        return self._control
-
-    @property
-    def frame_payload(self):
+    def frame_payload(self):  # pragma: no cover
         """
-        Return the bytes in the frame payload (following the control byte)
+        Return the bytes in the frame payload (including the control bytes)
         """
-        return b""
+        raise NotImplementedError("To be implemented in sub-class")
 
     @property
     def tnc2(self):
@@ -177,25 +246,159 @@ def copy(self, header=None):
         return clone
 
 
+class AX258BitFrame(AX25Frame):
+    """
+    Base class for AX.25 frames which have a 8-bit control field.
+    """
+
+    POLL_FINAL = 0b00010000
+
+    # Control field bits
+    #
+    #  7   6   5   4   3   2   1   0
+    # --------------------------------
+    #     N(R)   | P |    N(S)   | 0   I Frame
+    #     N(R)   |P/F| S   S | 0   1   S Frame
+    #  M   M   M |P/F| M   M | 1   1   U Frame
+    CONTROL_NR_MASK = 0b11100000
+    CONTROL_NR_SHIFT = 5
+    CONTROL_NS_MASK = 0b00001110
+    CONTROL_NS_SHIFT = 1
+
+    def __init__(
+        self,
+        destination,
+        source,
+        repeaters=None,
+        cr=False,
+        src_cr=None,
+        timestamp=None,
+        deadline=None,
+    ):
+        super(AX258BitFrame, self).__init__(
+            destination=destination,
+            source=source,
+            repeaters=repeaters,
+            cr=cr,
+            src_cr=src_cr,
+            timestamp=timestamp,
+            deadline=deadline,
+        )
+
+    def __str__(self):
+        return "%s %s: Control=0x%02x\nPayload=%r" % (
+            self.__class__.__name__,
+            self.header,
+            self.control,
+            self.payload,
+        )
+
+    @property
+    def control(self):
+        return self._control
+
+    @property
+    def frame_payload(self):
+        """
+        Return the bytes in the frame payload (including the control byte)
+        """
+        return bytes([self.control])
+
+
+class AX2516BitFrame(AX25Frame):
+    """
+    Base class for AX.25 frames which have a 16-bit control field.
+    """
+
+    POLL_FINAL = 0b0000000100000000
+
+    # Control field bits.  These are sent least-significant bit first.
+    # Unnumbered frames _always_ use the 8-bit control format, so here
+    # we will only see I frames or S frames.
+    #
+    # 15  14  13  12  11  10   9   8   7   6   5   4   3   2   1   0
+    # --------------------------------------------------------------
+    #            N(R)            | P |            N(S)           | 0   I Frame
+    #            N(R)            |P/F| 0   0   0   0 | S   S | 0   1   S Frame
+    CONTROL_NR_MASK = 0b1111111000000000
+    CONTROL_NR_SHIFT = 9
+    CONTROL_NS_MASK = 0b0000000011111110
+    CONTROL_NS_SHIFT = 1
+
+    def __init__(
+        self,
+        destination,
+        source,
+        repeaters=None,
+        cr=False,
+        src_cr=None,
+        timestamp=None,
+        deadline=None,
+    ):
+        super(AX2516BitFrame, self).__init__(
+            destination=destination,
+            source=source,
+            repeaters=repeaters,
+            cr=cr,
+            src_cr=src_cr,
+            timestamp=timestamp,
+            deadline=deadline,
+        )
+
+    def __str__(self):
+        return "%s %s: Control=0x%04x\nPayload=%r" % (
+            self.__class__.__name__,
+            self.header,
+            self.control,
+            self.payload,
+        )
+
+    @property
+    def control(self):
+        return self._control
+
+    @property
+    def frame_payload(self):
+        """
+        Return the bytes in the frame payload (including the control bytes)
+        """
+        # The control field is sent in LITTLE ENDIAN format so as to avoid
+        # S frames possibly getting confused with U frames.
+        return uint.encode(self.control, big_endian=False, length=2)
+
+
 class AX25RawFrame(AX25Frame):
     """
-    A representation of a raw AX.25 frame.
+    A representation of a raw AX.25 frame.  This class is intended to capture
+    partially decoded frame data in the case where we don't know whether a
+    control field is 8 or 16-bits wide.
+
+    It may be fed to the AX25Frame.decode function again with modulo128=False
+    for known 8-bit frames, or modulo128=True for known 16-bit frames.  For
+    digipeating applications, often no further dissection is necessary and so
+    the frame can be used as-is.
     """
 
     def __init__(
         self,
         destination,
         source,
-        control,
         repeaters=None,
         cr=False,
         src_cr=None,
         payload=None,
+        timestamp=None,
+        deadline=None,
     ):
-        self._header = AX25FrameHeader(
-            destination, source, repeaters, cr, src_cr
+        super(AX25RawFrame, self).__init__(
+            destination=destination,
+            source=source,
+            repeaters=repeaters,
+            cr=cr,
+            src_cr=src_cr,
+            timestamp=timestamp,
+            deadline=deadline,
         )
-        self._control = control
         self._payload = payload or b""
 
     @property
@@ -206,7 +409,6 @@ def _copy(self):
         return self.__class__(
             destination=self.header.destination,
             source=self.header.source,
-            control=self.control,
             repeaters=self.header.repeaters,
             cr=self.header.cr,
             src_cr=self.header.src_cr,
@@ -214,23 +416,43 @@ def _copy(self):
         )
 
 
-class AX25UnnumberedFrame(AX25Frame):
+class AX25UnnumberedFrame(AX258BitFrame):
     """
-    A representation of an un-numbered frame.
+    A representation of an un-numbered frame.  U frames are used for all
+    sorts of in-band signalling as well as for connectionless data transfer
+    via UI frames (see AX25UnnumberedInformationFrame).
+
+    All U frames have an 8-bit control field.
     """
 
     MODIFIER_MASK = 0b11101111
 
+    SUBCLASSES = {}
+
+    @classmethod
+    def register(cls, subclass):
+        """
+        Register a sub-class of UnnumberedFrame with the decoder.
+        """
+        assert (
+            subclass.MODIFIER not in cls.SUBCLASSES
+        ), "Duplicate registration"
+        cls.SUBCLASSES[subclass.MODIFIER] = subclass
+
     @classmethod
     def decode(cls, header, control, data):
+        """
+        Decode an unnumbered frame which has been partially decoded by
+        AX25Frame.decode.  This inspects the value of the modifier bits
+        in the control field (see AX.25 2.2 spec 4.3.3) and passes the
+        arguments given to the appropriate sub-class.
+        """
+
         # Decode based on the control field
         modifier = control & cls.MODIFIER_MASK
-        for subclass in (
-            AX25UnnumberedInformationFrame,
-            AX25FrameRejectFrame,
-        ):
-            if modifier == subclass.MODIFIER:
-                return subclass.decode(header, control, data)
+        subclass = cls.SUBCLASSES.get(modifier, None)
+        if subclass is not None:
+            return subclass.decode(header, control, data)
 
         # If we're still here, clearly this is a plain U frame.
         if data:
@@ -258,6 +480,8 @@ def __init__(
         pf=False,
         cr=False,
         src_cr=None,
+        timestamp=None,
+        deadline=None,
     ):
         super(AX25UnnumberedFrame, self).__init__(
             destination=destination,
@@ -265,305 +489,1692 @@ def __init__(
             repeaters=repeaters,
             cr=cr,
             src_cr=src_cr,
+            timestamp=timestamp,
+            deadline=deadline,
         )
         self._pf = bool(pf)
         self._modifier = int(modifier) & self.MODIFIER_MASK
 
-    @property
-    def _control(self):
+    def __str__(self):
+        return "%s %s: Control=0x%02x P/F=%s Modifier=0x%02x" % (
+            self.__class__.__name__,
+            self.header,
+            self.control,
+            self.pf,
+            self.modifier,
+        )
+
+    @property
+    def _control(self):
+        """
+        Return the value of the control byte.
+        """
+        control = self._modifier
+        if self._pf:
+            control |= self.POLL_FINAL
+        return control
+
+    @property
+    def pf(self):
+        """
+        Return the state of the poll/final bit
+        """
+        return self._pf
+
+    @property
+    def modifier(self):
+        """
+        Return the modifier bits
+        """
+        return self._modifier
+
+    def _copy(self):
+        return self.__class__(
+            destination=self.header.destination,
+            source=self.header.source,
+            repeaters=self.header.repeaters,
+            modifier=self.modifier,
+            cr=self.header.cr,
+            src_cr=self.header.src_cr,
+            pf=self.pf,
+        )
+
+
+class AX25InformationFrameMixin(object):
+    """
+    Common code for AX.25 all information frames
+    """
+
+    @classmethod
+    def decode(cls, header, control, data):
+        return cls(
+            destination=header.destination,
+            source=header.source,
+            repeaters=header.repeaters,
+            cr=header.cr,
+            nr=int((control & cls.CONTROL_NR_MASK) >> cls.CONTROL_NR_SHIFT),
+            ns=int((control & cls.CONTROL_NS_MASK) >> cls.CONTROL_NS_SHIFT),
+            pf=bool(control & cls.POLL_FINAL),
+            pid=data[0],
+            payload=data[1:],
+        )
+
+    def __init__(
+        self,
+        destination,
+        source,
+        pid,
+        nr,
+        ns,
+        payload,
+        repeaters=None,
+        pf=False,
+        cr=False,
+        timestamp=None,
+        deadline=None,
+    ):
+        super(AX25InformationFrameMixin, self).__init__(
+            destination=destination,
+            source=source,
+            repeaters=repeaters,
+            cr=cr,
+            timestamp=timestamp,
+            deadline=deadline,
+        )
+        self._nr = int(nr)
+        self._pf = bool(pf)
+        self._ns = int(ns)
+        self._pid = int(pid) & 0xFF
+        self._payload = bytes(payload)
+
+    @property
+    def pid(self):
+        return self._pid
+
+    @property
+    def nr(self):
+        """
+        Return the receive sequence number
+        """
+        return self._nr
+
+    @property
+    def pf(self):
+        """
+        Return the state of the poll/final bit
+        """
+        return self._pf
+
+    @property
+    def ns(self):
+        """
+        Return the send sequence number
+        """
+        return self._ns
+
+    @property
+    def payload(self):
+        return self._payload
+
+    @property
+    def frame_payload(self):
+        return (
+            super(AX25InformationFrameMixin, self).frame_payload
+            + bytearray([self.pid])
+            + self.payload
+        )
+
+    @property
+    def _control(self):
+        """
+        Return the value of the control byte.
+        """
+        return (
+            ((self.nr << self.CONTROL_NR_SHIFT) & self.CONTROL_NR_MASK)
+            | (self.POLL_FINAL if self.pf else 0)
+            | ((self.ns << self.CONTROL_NS_SHIFT) & self.CONTROL_NS_MASK)
+            | self.CONTROL_I_VAL
+        )
+
+    def __str__(self):
+        return "%s %s: N(R)=%d P/F=%s N(S)=%d PID=0x%02x\nPayload=%r" % (
+            self.__class__.__name__,
+            self.header,
+            self.nr,
+            self.pf,
+            self.ns,
+            self.pid,
+            self.payload,
+        )
+
+    def _copy(self):
+        return self.__class__(
+            destination=self.header.destination,
+            source=self.header.source,
+            repeaters=self.header.repeaters,
+            cr=self.header.cr,
+            pf=self.pf,
+            pid=self.pid,
+            nr=self.nr,
+            ns=self.ns,
+            payload=self.payload,
+        )
+
+
+class AX258BitInformationFrame(AX25InformationFrameMixin, AX258BitFrame):
+    """
+    A representation of an information frame using modulo-8 acknowledgements.
+    """
+
+    pass
+
+
+class AX2516BitInformationFrame(AX25InformationFrameMixin, AX2516BitFrame):
+    """
+    A representation of an information frame using modulo-128 acknowledgements.
+    """
+
+    pass
+
+
+class AX25SupervisoryFrameMixin(object):
+    """
+    Common code for AX.25 all supervisory frames
+    """
+
+    # Supervisory field bits
+    SUPER_MASK = 0b00001100
+
+    @classmethod
+    def decode(cls, header, control):
+        code = int(control & cls.SUPER_MASK)
+        return cls.SUBCLASSES[code](
+            destination=header.destination,
+            source=header.source,
+            repeaters=header.repeaters,
+            cr=header.cr,
+            nr=int((control & cls.CONTROL_NR_MASK) >> cls.CONTROL_NR_SHIFT),
+            pf=bool(control & cls.POLL_FINAL),
+        )
+
+    def __init__(
+        self,
+        destination,
+        source,
+        nr,
+        repeaters=None,
+        pf=False,
+        cr=False,
+        timestamp=None,
+        deadline=None,
+    ):
+        super(AX25SupervisoryFrameMixin, self).__init__(
+            destination=destination,
+            source=source,
+            repeaters=repeaters,
+            cr=cr,
+            timestamp=timestamp,
+            deadline=deadline,
+        )
+        self._nr = int(nr)
+        self._code = self.SUPERVISOR_CODE
+        self._pf = bool(pf)
+
+    @property
+    def nr(self):
+        """
+        Return the receive sequence number
+        """
+        return self._nr
+
+    @property
+    def pf(self):
+        """
+        Return the state of the poll/final bit
+        """
+        return self._pf
+
+    @property
+    def code(self):
+        """
+        Return the supervisory control code
+        """
+        return self._code
+
+    @property
+    def _control(self):
+        """
+        Return the value of the control byte.
+        """
+        return (
+            ((self.nr << self.CONTROL_NR_SHIFT) & self.CONTROL_NR_MASK)
+            | (self.POLL_FINAL if self.pf else 0)
+            | (self.code & self.SUPER_MASK)
+            | self.CONTROL_S_VAL
+        )
+
+    def __str__(self):
+        return "%s %s: N(R)=%d P/F=%s Code=0x%02x" % (
+            self.__class__.__name__,
+            self.header,
+            self.nr,
+            self.pf,
+            self.code,
+        )
+
+    def _copy(self):
+        return self.__class__(
+            destination=self.header.destination,
+            source=self.header.source,
+            repeaters=self.header.repeaters,
+            cr=self.header.cr,
+            pf=self.pf,
+            nr=self.nr,
+        )
+
+
+# The 4 types of supervisory frame
+
+
+class AX25ReceiveReadyFrameMixin(AX25SupervisoryFrameMixin):
+    """
+    Receive Ready supervisory frame.
+
+    This frame is sent to indicate readyness to receive more frames.
+    If pf=True, this is a query being sent asking "are you ready?", otherwise
+    this is a response saying "I am ready".
+    """
+
+    SUPERVISOR_CODE = 0b00000000
+
+
+class AX25ReceiveNotReadyFrameMixin(AX25SupervisoryFrameMixin):
+    """
+    Receive Not Ready supervisory frame.
+
+    This is the opposite to a RR frame, and indicates we are not yet ready
+    to receive more traffic and that we may need to re-transmit frames when
+    we're ready to receive them.
+    """
+
+    SUPERVISOR_CODE = 0b00000100
+
+
+class AX25RejectFrameMixin(AX25SupervisoryFrameMixin):
+    """
+    Reject frame.
+
+    This indicates the indicated frame were not received and need to be re-sent.
+    All frames prior to the indicated frame are received, everything that
+    follows must be re-sent.
+    """
+
+    SUPERVISOR_CODE = 0b00001000
+
+
+class AX25SelectiveRejectFrameMixin(AX25SupervisoryFrameMixin):
+    """
+    Selective Reject frame.
+
+    This indicates a specific frame was not received and needs to be re-sent.
+    There is no requirement to send subsequent frames.
+    """
+
+    SUPERVISOR_CODE = 0b00001100
+
+
+# 8 and 16-bit variants of the above 4 types
+
+
+class AX258BitReceiveReadyFrame(AX25ReceiveReadyFrameMixin, AX258BitFrame):
+    pass
+
+
+class AX2516BitReceiveReadyFrame(AX25ReceiveReadyFrameMixin, AX2516BitFrame):
+    pass
+
+
+class AX258BitReceiveNotReadyFrame(
+    AX25ReceiveNotReadyFrameMixin, AX258BitFrame
+):
+    pass
+
+
+class AX2516BitReceiveNotReadyFrame(
+    AX25ReceiveNotReadyFrameMixin, AX2516BitFrame
+):
+    pass
+
+
+class AX258BitRejectFrame(AX25RejectFrameMixin, AX258BitFrame):
+    pass
+
+
+class AX2516BitRejectFrame(AX25RejectFrameMixin, AX2516BitFrame):
+    pass
+
+
+class AX258BitSelectiveRejectFrame(
+    AX25SelectiveRejectFrameMixin, AX258BitFrame
+):
+    pass
+
+
+class AX2516BitSelectiveRejectFrame(
+    AX25SelectiveRejectFrameMixin, AX2516BitFrame
+):
+    pass
+
+
+# 8 and 16-bit variants of the base class
+
+
+class AX258BitSupervisoryFrame(AX25SupervisoryFrameMixin, AX258BitFrame):
+    SUBCLASSES = dict(
+        [
+            (c.SUPERVISOR_CODE, c)
+            for c in (
+                AX258BitReceiveReadyFrame,
+                AX258BitReceiveNotReadyFrame,
+                AX258BitRejectFrame,
+                AX258BitSelectiveRejectFrame,
+            )
+        ]
+    )
+
+
+class AX2516BitSupervisoryFrame(AX25SupervisoryFrameMixin, AX2516BitFrame):
+    SUBCLASSES = dict(
+        [
+            (c.SUPERVISOR_CODE, c)
+            for c in (
+                AX2516BitReceiveReadyFrame,
+                AX2516BitReceiveNotReadyFrame,
+                AX2516BitRejectFrame,
+                AX2516BitSelectiveRejectFrame,
+            )
+        ]
+    )
+
+
+# Un-numbered frame types
+
+
+class AX25UnnumberedInformationFrame(AX25UnnumberedFrame):
+    """
+    A representation of an un-numbered information frame.
+    """
+
+    MODIFIER = 0b00000011
+
+    @classmethod
+    def decode(cls, header, control, data):
+        if not data:
+            raise ValueError("Payload of UI must be at least one byte")
+        return cls(
+            destination=header.destination,
+            source=header.source,
+            repeaters=header.repeaters,
+            cr=header.cr,
+            src_cr=header.src_cr,
+            pf=bool(control & cls.POLL_FINAL),
+            pid=data[0],
+            payload=data[1:],
+        )
+
+    def __init__(
+        self,
+        destination,
+        source,
+        pid,
+        payload,
+        repeaters=None,
+        pf=False,
+        cr=False,
+        src_cr=None,
+        timestamp=None,
+        deadline=None,
+    ):
+        super(AX25UnnumberedInformationFrame, self).__init__(
+            destination=destination,
+            source=source,
+            repeaters=repeaters,
+            cr=cr,
+            src_cr=src_cr,
+            pf=pf,
+            modifier=self.MODIFIER,
+            timestamp=timestamp,
+            deadline=deadline,
+        )
+        self._pid = int(pid) & 0xFF
+        self._payload = bytes(payload)
+
+    @property
+    def pid(self):
+        return self._pid
+
+    @property
+    def payload(self):
+        return self._payload
+
+    @property
+    def frame_payload(self):
+        return (
+            super(AX25UnnumberedInformationFrame, self).frame_payload
+            + bytearray([self.pid])
+            + self.payload
+        )
+
+    def __str__(self):
+        return (
+            "%s %s: Control=0x%02x P/F=%s Modifier=0x%02x "
+            "PID=0x%02x\nPayload=%r"
+        ) % (
+            self.__class__.__name__,
+            self.header,
+            self.control,
+            self.pf,
+            self.modifier,
+            self.pid,
+            self.payload,
+        )
+
+    def _copy(self):
+        return self.__class__(
+            destination=self.header.destination,
+            source=self.header.source,
+            repeaters=self.header.repeaters,
+            cr=self.header.cr,
+            src_cr=self.header.src_cr,
+            pf=self.pf,
+            pid=self.pid,
+            payload=self.payload,
+        )
+
+    @property
+    def tnc2(self):
+        """
+        Return the frame in "TNC2" format (default charset).
+        """
+        return self.get_tnc2()
+
+    def get_tnc2(self, charset="latin1", errors="strict"):
+        """
+        Return the frame in "TNC2" format with given charset.
+        """
+        return "%s:%s" % (
+            self.header.tnc2,
+            self.payload.decode(charset, errors),
+        )
+
+
+AX25UnnumberedFrame.register(AX25UnnumberedInformationFrame)
+
+
+class AX25FrameRejectFrame(AX25UnnumberedFrame):
+    """
+    A representation of a Frame Reject (FRMR) frame.
+
+    Not much effort has been made to decode the meaning of these bits.
+    """
+
+    # fmt: off
+    MODIFIER = 0b10000111
+    W_MASK   = 0b00000001
+    X_MASK   = 0b00000010
+    Y_MASK   = 0b00000100
+    Z_MASK   = 0b00001000
+    VR_MASK  = 0b11100000
+    VR_POS   = 5
+    CR_MASK  = 0b00010000
+    VS_MASK  = 0b00001110
+    VS_POS   = 1
+    # fmt: on
+
+    @classmethod
+    def decode(cls, header, control, data):
+        if len(data) != 3:
+            raise ValueError("Payload of FRMR must be 3 bytes")
+
+        # W, X, Y and Z bits
+        w = bool(data[0] & cls.W_MASK)
+        x = bool(data[0] & cls.X_MASK)
+        y = bool(data[0] & cls.Y_MASK)
+        z = bool(data[0] & cls.Z_MASK)
+
+        # VR, CR and VS fields
+        vr = (data[1] & cls.VR_MASK) >> cls.VR_POS
+        cr = bool(data[1] & cls.CR_MASK)
+        vs = (data[1] & cls.VS_MASK) >> cls.VS_POS
+
+        # Control field of rejected frame
+        frmr_control = data[2]
+
+        return cls(
+            destination=header.destination,
+            source=header.source,
+            repeaters=header.repeaters,
+            cr=header.cr,
+            src_cr=header.src_cr,
+            pf=bool(control & cls.POLL_FINAL),
+            w=w,
+            x=x,
+            y=y,
+            z=z,
+            vr=vr,
+            frmr_cr=cr,
+            vs=vs,
+            frmr_control=frmr_control,
+        )
+
+    def __init__(
+        self,
+        destination,
+        source,
+        w,
+        x,
+        y,
+        z,
+        vr,
+        frmr_cr,
+        vs,
+        frmr_control,
+        repeaters=None,
+        pf=False,
+        cr=False,
+        src_cr=None,
+    ):
+        super(AX25FrameRejectFrame, self).__init__(
+            destination=destination,
+            source=source,
+            repeaters=repeaters,
+            cr=cr,
+            src_cr=src_cr,
+            pf=pf,
+            modifier=self.MODIFIER,
+        )
+
+        self._w = bool(w)
+        self._x = bool(x)
+        self._y = bool(y)
+        self._z = bool(z)
+        self._frmr_cr = bool(frmr_cr)
+        self._frmr_control = int(frmr_control)
+        self._vr = int(vr)
+        self._vs = int(vs)
+
+    @property
+    def frame_payload(self):
+        return super(AX25FrameRejectFrame, self).frame_payload + bytes(
+            self._gen_frame_payload()
+        )
+
+    def _gen_frame_payload(self):
+        wxyz = 0
+        if self._w:
+            wxyz |= self.W_MASK
+        if self._x:
+            wxyz |= self.X_MASK
+        if self._y:
+            wxyz |= self.Y_MASK
+        if self._z:
+            wxyz |= self.Z_MASK
+        yield wxyz
+
+        vrcrvs = 0
+        vrcrvs |= (self._vr << self.VR_POS) & self.VR_MASK
+        if self._frmr_cr:
+            vrcrvs |= self.CR_MASK
+        vrcrvs |= (self._vs << self.VS_POS) & self.VS_MASK
+        yield vrcrvs
+
+        yield self._frmr_control
+
+    @property
+    def w(self):
+        return self._w
+
+    @property
+    def x(self):
+        return self._x
+
+    @property
+    def y(self):
+        return self._y
+
+    @property
+    def z(self):
+        return self._z
+
+    @property
+    def vs(self):
+        return self._vs
+
+    @property
+    def vr(self):
+        return self._vr
+
+    @property
+    def frmr_cr(self):
+        return self._frmr_cr
+
+    @property
+    def frmr_control(self):
+        return self._frmr_control
+
+    def _copy(self):
+        return self.__class__(
+            destination=self.header.destination,
+            source=self.header.source,
+            repeaters=self.header.repeaters,
+            w=self.w,
+            x=self.x,
+            y=self.y,
+            z=self.z,
+            frmr_cr=self.frmr_cr,
+            vr=self.vr,
+            vs=self.vs,
+            frmr_control=self.frmr_control,
+            cr=self.header.cr,
+            src_cr=self.header.src_cr,
+            pf=self.pf,
+        )
+
+
+AX25UnnumberedFrame.register(AX25FrameRejectFrame)
+
+
+class AX25BaseUnnumberedFrame(AX25UnnumberedFrame):
+    """
+    Base unnumbered frame sub-class.  This is used to provide a common
+    decode and _copy implementation for basic forms of UI frames without
+    information fields.
+    """
+
+    # Defaults for PF, CR fields
+    PF = True
+    CR = False
+
+    @classmethod
+    def decode(cls, header, control, data):
+        if len(data):
+            raise ValueError(
+                "Frame does not support payload"
+                " (header=%s control=0x%02x data=%s)"
+                % (header, control, data.hex())
+            )
+
+        return cls(
+            destination=header.destination,
+            source=header.source,
+            repeaters=header.repeaters,
+            pf=bool(control & cls.POLL_FINAL),
+            cr=header.cr,
+        )
+
+    def __init__(
+        self,
+        destination,
+        source,
+        repeaters=None,
+        pf=None,
+        cr=None,
+        timestamp=None,
+        deadline=None,
+    ):
+        if pf is None:
+            pf = self.PF
+        if cr is None:
+            cr = self.CR
+
+        super(AX25BaseUnnumberedFrame, self).__init__(
+            destination=destination,
+            source=source,
+            modifier=self.MODIFIER,
+            repeaters=repeaters,
+            cr=cr,
+            pf=pf,
+            timestamp=timestamp,
+            deadline=deadline,
+        )
+
+    def _copy(self):
+        return self.__class__(
+            destination=self.header.destination,
+            source=self.header.source,
+            repeaters=self.header.repeaters,
+            cr=self.header.cr,
+            pf=self.pf,
+        )
+
+
+class AX25SetAsyncBalancedModeFrame(AX25BaseUnnumberedFrame):
+    """
+    Set Async Balanced Mode (modulo 8).
+
+    This frame is used to initiate a connection request with the destination
+    AX.25 node.
+    """
+
+    MODIFIER = 0b00101111
+    CR = True
+
+
+AX25UnnumberedFrame.register(AX25SetAsyncBalancedModeFrame)
+
+
+class AX25SetAsyncBalancedModeExtendedFrame(AX25BaseUnnumberedFrame):
+    """
+    Set Async Balanced Mode Extended (modulo 128).
+
+    This frame is used to initiate a connection request with the destination
+    AX.25 node, using modulo 128 acknowledgements.
+    """
+
+    MODIFIER = 0b01101111
+    CR = True
+
+
+AX25UnnumberedFrame.register(AX25SetAsyncBalancedModeExtendedFrame)
+
+
+class AX25DisconnectFrame(AX25BaseUnnumberedFrame):
+    """
+    Disconnect frame.
+
+    This frame is used to initiate a disconnection from the other station.
+    """
+
+    MODIFIER = 0b01000011
+    CR = True
+
+
+AX25UnnumberedFrame.register(AX25DisconnectFrame)
+
+
+class AX25DisconnectModeFrame(AX25BaseUnnumberedFrame):
+    """
+    Disconnect mode frame.
+
+    This frame is used to indicate to the other station that it is
+    disconnected.
+    """
+
+    MODIFIER = 0b00001111
+    CR = False
+
+
+AX25UnnumberedFrame.register(AX25DisconnectModeFrame)
+
+
+class AX25XIDParameterIdentifier(enum.Enum):
+    """
+    Known values of PI in XID frames.
+    """
+
+    # Negotiates half/full duplex operation
+    ClassesOfProcedure = 2
+
+    # Selects between REJ, SREJ or both
+    HDLCOptionalFunctions = 3
+
+    # Sets the outgoing I field length in bits (not bytes)
+    IFieldLengthTransmit = 5
+
+    # Sets the incoming I field length in bits (not bytes)
+    IFieldLengthReceive = 6
+
+    # Sets the outgoing number of outstanding I-frames (k)
+    WindowSizeTransmit = 7
+
+    # Sets the incoming number of outstanding I-frames (k)
+    WindowSizeReceive = 8
+
+    # Sets the duration of the Wait For Acknowledge (T1) timer
+    AcknowledgeTimer = 9
+
+    # Sets the retry count (N1)
+    Retries = 10
+
+    def __int__(self):
+        """
+        Return the value of the PI.
+        """
+        return self.value
+
+
+class AX25XIDParameter(object):
+    """
+    Representation of a single XID parameter.
+    """
+
+    PARAMETERS = {}
+
+    @classmethod
+    def register(cls, subclass):
+        """
+        Register a sub-class of XID parameter.
+        """
+        assert subclass.PI not in cls.PARAMETERS
+        cls.PARAMETERS[subclass.PI] = subclass
+
+    @classmethod
+    def decode(cls, data):
+        """
+        Decode the parameter value given, return the parameter and the
+        remaining data.
+        """
+        if len(data) < 2:
+            raise ValueError("Insufficient data for parameter")
+
+        pi = data[0]
+        pl = data[1]
+        data = data[2:]
+
+        if pl > 0:
+            if len(data) < pl:
+                raise ValueError("Parameter is truncated")
+            pv = data[0:pl]
+            data = data[pl:]
+        else:
+            pv = None
+
+        try:
+            # Hand to the sub-class to decode
+            param = cls.PARAMETERS[AX25XIDParameterIdentifier(pi)].decode(pv)
+        except (KeyError, ValueError):
+            # Not recognised, so return a base class
+            param = AX25XIDRawParameter(pi=pi, pv=pv)
+
+        return (param, data)
+
+    def __init__(self, pi):
+        """
+        Create a new XID parameter
+        """
+        try:
+            pi = AX25XIDParameterIdentifier(pi)
+        except ValueError:
+            # Pass through the PI as given.
+            pass
+
+        self._pi = pi
+
+    @property
+    def pi(self):
+        """
+        Return the Parameter Identifier
+        """
+        return self._pi
+
+    @property
+    def pv(self):  # pragma: no cover
+        """
+        Return the Parameter Value
+        """
+        raise NotImplementedError(
+            "To be implemented in %s" % self.__class__.__name__
+        )
+
+    def __bytes__(self):
+        """
+        Return the encoded parameter value.
+        """
+        pv = self.pv
+        param = bytes([int(self.pi)])
+
+        if pv is None:
+            param += bytes([0])
+        else:
+            param += bytes([len(pv)]) + pv
+
+        return param
+
+    def copy(self):  # pragma: no cover
+        """
+        Return a copy of this parameter.
+        """
+        raise NotImplementedError(
+            "To be implemented in %s" % self.__class__.__name__
+        )
+
+
+class AX25XIDRawParameter(AX25XIDParameter):
+    """
+    Representation of a single XID parameter that we don't recognise.
+    """
+
+    def __init__(self, pi, pv):
+        """
+        Create a new XID parameter
+        """
+        if pv is not None:
+            pv = bytes(pv)
+        self._pv = pv
+        super(AX25XIDRawParameter, self).__init__(pi=pi)
+
+    @property
+    def pv(self):
+        """
+        Return the Parameter Value
+        """
+        return self._pv
+
+    def copy(self):
+        """
+        Return a copy of this parameter.
+        """
+        return self.__class__(pi=self.pi, pv=self.pv)
+
+
+class AX25XIDClassOfProceduresParameter(AX25XIDParameter):
+    """
+    Class of Procedures XID parameter.  This parameter is used to negotiate
+    half or full duplex communications between two TNCs.
+    """
+
+    PI = AX25XIDParameterIdentifier.ClassesOfProcedure
+
+    # fmt: off
+    # Bit fields for this parameter:
+    BALANCED_ABM       = 0b0000000000000001  # Should be 1
+    UNBALANCED_NRM_PRI = 0b0000000000000010  # Should be 0
+    UNBALANCED_NRM_SEC = 0b0000000000000100  # Should be 0
+    UNBALANCED_ARM_PRI = 0b0000000000001000  # Should be 0
+    UNBALANCED_ARM_SEC = 0b0000000000010000  # Should be 0
+    HALF_DUPLEX        = 0b0000000000100000  # Should oppose FULL_DUPLEX
+    FULL_DUPLEX        = 0b0000000001000000  # Should oppose HALF_DUPLEX
+    RESERVED_MASK      = 0b1111111110000000  # Should be all zeros
+    RESERVED_POS       = 7
+    # fmt: on
+
+    @classmethod
+    def decode(cls, pv):
+        # Decode the PV
+        pv = uint.decode(pv, big_endian=True)
+        return cls(
+            full_duplex=bool(pv & cls.FULL_DUPLEX),
+            half_duplex=bool(pv & cls.HALF_DUPLEX),
+            unbalanced_nrm_pri=bool(pv & cls.UNBALANCED_NRM_PRI),
+            unbalanced_nrm_sec=bool(pv & cls.UNBALANCED_NRM_SEC),
+            unbalanced_arm_pri=bool(pv & cls.UNBALANCED_ARM_PRI),
+            unbalanced_arm_sec=bool(pv & cls.UNBALANCED_ARM_SEC),
+            balanced_abm=bool(pv & cls.BALANCED_ABM),
+            reserved=((pv & cls.RESERVED_MASK) >> cls.RESERVED_POS),
+        )
+
+    def __init__(
+        self,
+        full_duplex=False,
+        half_duplex=False,
+        unbalanced_nrm_pri=False,
+        unbalanced_nrm_sec=False,
+        unbalanced_arm_pri=False,
+        unbalanced_arm_sec=False,
+        balanced_abm=True,
+        reserved=0,
+    ):
+        """
+        Create a Class Of Procedures XID parameter.  The defaults are set
+        so that at most, only half_duplex or full_duplex should need setting.
+        """
+        self._half_duplex = half_duplex
+        self._full_duplex = full_duplex
+        self._unbalanced_nrm_pri = unbalanced_nrm_pri
+        self._unbalanced_nrm_sec = unbalanced_nrm_sec
+        self._unbalanced_arm_pri = unbalanced_arm_pri
+        self._unbalanced_arm_sec = unbalanced_arm_sec
+        self._balanced_abm = balanced_abm
+        self._reserved = reserved
+        super(AX25XIDClassOfProceduresParameter, self).__init__(pi=self.PI)
+
+    @property
+    def pv(self):
+        # We reproduce all bits as given, even if the combination is invalid
+        # Value is encoded in little-endian format as two bytes.
+        return uint.encode(
+            (
+                ((self.reserved << self.RESERVED_POS) & self.RESERVED_MASK)
+                | ((self.full_duplex and self.FULL_DUPLEX) or 0)
+                | ((self.half_duplex and self.HALF_DUPLEX) or 0)
+                | ((self.unbalanced_nrm_pri and self.UNBALANCED_NRM_PRI) or 0)
+                | ((self.unbalanced_nrm_sec and self.UNBALANCED_NRM_SEC) or 0)
+                | ((self.unbalanced_arm_pri and self.UNBALANCED_ARM_PRI) or 0)
+                | ((self.unbalanced_arm_sec and self.UNBALANCED_ARM_SEC) or 0)
+                | ((self.balanced_abm and self.BALANCED_ABM) or 0)
+            ),
+            big_endian=True,
+            length=2,
+        )
+
+    @property
+    def half_duplex(self):
+        return self._half_duplex
+
+    @property
+    def full_duplex(self):
+        return self._full_duplex
+
+    @property
+    def unbalanced_nrm_pri(self):
+        return self._unbalanced_nrm_pri
+
+    @property
+    def unbalanced_nrm_sec(self):
+        return self._unbalanced_nrm_sec
+
+    @property
+    def unbalanced_arm_pri(self):
+        return self._unbalanced_arm_pri
+
+    @property
+    def unbalanced_arm_sec(self):
+        return self._unbalanced_arm_sec
+
+    @property
+    def balanced_abm(self):
+        return self._balanced_abm
+
+    @property
+    def reserved(self):
+        return self._reserved
+
+    def copy(self):
+        return self.__class__(
+            half_duplex=self.half_duplex,
+            full_duplex=self.full_duplex,
+            unbalanced_nrm_pri=self.unbalanced_nrm_pri,
+            unbalanced_nrm_sec=self.unbalanced_nrm_sec,
+            unbalanced_arm_pri=self.unbalanced_arm_pri,
+            unbalanced_arm_sec=self.unbalanced_arm_sec,
+            reserved=self.reserved,
+        )
+
+
+AX25XIDParameter.register(AX25XIDClassOfProceduresParameter)
+
+# From AX.25 2.2 spec section 6.3.2:
+AX25_20_DEFAULT_XID_COP = AX25XIDClassOfProceduresParameter(
+    half_duplex=True, full_duplex=False
+)
+AX25_22_DEFAULT_XID_COP = AX25XIDClassOfProceduresParameter(
+    half_duplex=True, full_duplex=False
+)
+
+
+class AX25XIDHDLCOptionalFunctionsParameter(AX25XIDParameter):
+    """
+    HDLC Optional Functions XID parameter.  This parameter is used to negotiate
+    what optional features of the HDLC specification will be used to
+    synchronise communications.
+    """
+
+    PI = AX25XIDParameterIdentifier.HDLCOptionalFunctions
+
+    # fmt: off
+    # Bit fields for this parameter:
+    RESERVED1           = 0b000000000000000000000001  # Should be 0
+    REJ                 = 0b000000000000000000000010  # Negotiable
+    SREJ                = 0b000000000000000000000100  # Negotiable
+    UI                  = 0b000000000000000000001000  # Should be 0
+    SIM_RIM             = 0b000000000000000000010000  # Should be 0
+    UP                  = 0b000000000000000000100000  # Should be 0
+    BASIC_ADDR          = 0b000000000000000001000000  # Should be 1
+    EXTD_ADDR           = 0b000000000000000010000000  # Should be 0
+    DELETE_I_RESP       = 0b000000000000000100000000  # Should be 0
+    DELETE_I_CMD        = 0b000000000000001000000000  # Should be 0
+    MODULO8             = 0b000000000000010000000000  # Negotiable
+    MODULO128           = 0b000000000000100000000000  # Negotiable
+    RSET                = 0b000000000001000000000000  # Should be 0
+    TEST                = 0b000000000010000000000000  # Should be 1
+    RD                  = 0b000000000100000000000000  # Should be 0
+    FCS16               = 0b000000001000000000000000  # Should be 1
+    FCS32               = 0b000000010000000000000000  # Should be 0
+    SYNC_TX             = 0b000000100000000000000000  # Should be 1
+    START_STOP_TX       = 0b000001000000000000000000  # Should be 0
+    START_STOP_FLOW_CTL = 0b000010000000000000000000  # Should be 0
+    START_STOP_TRANSP   = 0b000100000000000000000000  # Should be 0
+    SREJ_MULTIFRAME     = 0b001000000000000000000000  # Should be 0
+    RESERVED2_MASK      = 0b110000000000000000000000  # Should be 00
+    RESERVED2_POS       = 22
+    # fmt: on
+
+    @classmethod
+    def decode(cls, pv):
+        # Decode the PV
+        pv = uint.decode(pv, big_endian=False)
+        return cls(
+            modulo128=bool(pv & cls.MODULO128),
+            modulo8=bool(pv & cls.MODULO8),
+            srej=bool(pv & cls.SREJ),
+            rej=bool(pv & cls.REJ),
+            srej_multiframe=bool(pv & cls.SREJ_MULTIFRAME),
+            start_stop_transp=bool(pv & cls.START_STOP_TRANSP),
+            start_stop_flow_ctl=bool(pv & cls.START_STOP_FLOW_CTL),
+            start_stop_tx=bool(pv & cls.START_STOP_TX),
+            sync_tx=bool(pv & cls.SYNC_TX),
+            fcs32=bool(pv & cls.FCS32),
+            fcs16=bool(pv & cls.FCS16),
+            rd=bool(pv & cls.RD),
+            test=bool(pv & cls.TEST),
+            rset=bool(pv & cls.RSET),
+            delete_i_cmd=bool(pv & cls.DELETE_I_CMD),
+            delete_i_resp=bool(pv & cls.DELETE_I_RESP),
+            extd_addr=bool(pv & cls.EXTD_ADDR),
+            basic_addr=bool(pv & cls.BASIC_ADDR),
+            up=bool(pv & cls.UP),
+            sim_rim=bool(pv & cls.SIM_RIM),
+            ui=bool(pv & cls.UI),
+            reserved2=(pv & cls.RESERVED2_MASK) >> cls.RESERVED2_POS,
+            reserved1=bool(pv & cls.RESERVED1),
+        )
+
+    def __init__(
+        self,
+        modulo128=False,
+        modulo8=False,
+        srej=False,
+        rej=False,
+        srej_multiframe=False,
+        start_stop_transp=False,
+        start_stop_flow_ctl=False,
+        start_stop_tx=False,
+        sync_tx=True,
+        fcs32=False,
+        fcs16=True,
+        rd=False,
+        test=True,
+        rset=False,
+        delete_i_cmd=False,
+        delete_i_resp=False,
+        extd_addr=True,
+        basic_addr=False,
+        up=False,
+        sim_rim=False,
+        ui=False,
+        reserved2=0,
+        reserved1=False,
+    ):
+        """
+        HDLC Optional Features XID parameter.  The defaults are set
+        so that at most, only srej, rej, modulo8 and/or modulo128 need setting.
+        """
+        self._modulo128 = modulo128
+        self._modulo8 = modulo8
+        self._srej = srej
+        self._rej = rej
+        self._srej_multiframe = srej_multiframe
+        self._start_stop_transp = start_stop_transp
+        self._start_stop_flow_ctl = start_stop_flow_ctl
+        self._start_stop_tx = start_stop_tx
+        self._sync_tx = sync_tx
+        self._fcs32 = fcs32
+        self._fcs16 = fcs16
+        self._rd = rd
+        self._test = test
+        self._rset = rset
+        self._delete_i_cmd = delete_i_cmd
+        self._delete_i_resp = delete_i_resp
+        self._extd_addr = extd_addr
+        self._basic_addr = basic_addr
+        self._up = up
+        self._sim_rim = sim_rim
+        self._ui = ui
+        self._reserved2 = reserved2
+        self._reserved1 = reserved1
+
+        super(AX25XIDHDLCOptionalFunctionsParameter, self).__init__(
+            pi=self.PI
+        )
+
+    @property
+    def pv(self):
+        # We reproduce all bits as given, even if the combination is invalid
+        return uint.encode(
+            (
+                ((self.reserved2 << self.RESERVED2_POS) & self.RESERVED2_MASK)
+                | ((self.modulo128 and self.MODULO128) or 0)
+                | ((self.modulo8 and self.MODULO8) or 0)
+                | ((self.srej and self.SREJ) or 0)
+                | ((self.rej and self.REJ) or 0)
+                | ((self.srej_multiframe and self.SREJ_MULTIFRAME) or 0)
+                | ((self.start_stop_transp and self.START_STOP_TRANSP) or 0)
+                | (
+                    (self.start_stop_flow_ctl and self.START_STOP_FLOW_CTL)
+                    or 0
+                )
+                | ((self.start_stop_tx and self.START_STOP_TX) or 0)
+                | ((self.sync_tx and self.SYNC_TX) or 0)
+                | ((self.fcs32 and self.FCS32) or 0)
+                | ((self.fcs16 and self.FCS16) or 0)
+                | ((self.rd and self.RD) or 0)
+                | ((self.test and self.TEST) or 0)
+                | ((self.rset and self.RSET) or 0)
+                | ((self.delete_i_cmd and self.DELETE_I_CMD) or 0)
+                | ((self.delete_i_resp and self.DELETE_I_RESP) or 0)
+                | ((self.extd_addr and self.EXTD_ADDR) or 0)
+                | ((self.basic_addr and self.BASIC_ADDR) or 0)
+                | ((self.up and self.UP) or 0)
+                | ((self.sim_rim and self.SIM_RIM) or 0)
+                | ((self.ui and self.UI) or 0)
+                | ((self.reserved1 and self.RESERVED1) or 0)
+            ),
+            big_endian=False,
+            length=3,
+        )
+
+    @property
+    def modulo128(self):
+        return self._modulo128
+
+    @property
+    def modulo8(self):
+        return self._modulo8
+
+    @property
+    def srej(self):
+        return self._srej
+
+    @property
+    def rej(self):
+        return self._rej
+
+    @property
+    def srej_multiframe(self):
+        return self._srej_multiframe
+
+    @property
+    def start_stop_transp(self):
+        return self._start_stop_transp
+
+    @property
+    def start_stop_flow_ctl(self):
+        return self._start_stop_flow_ctl
+
+    @property
+    def start_stop_tx(self):
+        return self._start_stop_tx
+
+    @property
+    def sync_tx(self):
+        return self._sync_tx
+
+    @property
+    def fcs32(self):
+        return self._fcs32
+
+    @property
+    def fcs16(self):
+        return self._fcs16
+
+    @property
+    def rd(self):
+        return self._rd
+
+    @property
+    def test(self):
+        return self._test
+
+    @property
+    def rset(self):
+        return self._rset
+
+    @property
+    def delete_i_cmd(self):
+        return self._delete_i_cmd
+
+    @property
+    def delete_i_resp(self):
+        return self._delete_i_resp
+
+    @property
+    def extd_addr(self):
+        return self._extd_addr
+
+    @property
+    def basic_addr(self):
+        return self._basic_addr
+
+    @property
+    def up(self):
+        return self._up
+
+    @property
+    def sim_rim(self):
+        return self._sim_rim
+
+    @property
+    def ui(self):
+        return self._ui
+
+    @property
+    def reserved2(self):
+        return self._reserved2
+
+    @property
+    def reserved1(self):
+        return self._reserved1
+
+    def copy(self):
+        return self.__class__(
+            modulo128=self.modulo128,
+            modulo8=self.modulo8,
+            srej=self.srej,
+            rej=self.rej,
+            srej_multiframe=self.srej_multiframe,
+            start_stop_transp=self.start_stop_transp,
+            start_stop_flow_ctl=self.start_stop_flow_ctl,
+            start_stop_tx=self.start_stop_tx,
+            sync_tx=self.sync_tx,
+            fcs32=self.fcs32,
+            fcs16=self.fcs16,
+            rd=self.rd,
+            test=self.test,
+            rset=self.rset,
+            delete_i_cmd=self.delete_i_cmd,
+            delete_i_resp=self.delete_i_resp,
+            extd_addr=self.extd_addr,
+            basic_addr=self.basic_addr,
+            up=self.up,
+            sim_rim=self.sim_rim,
+            ui=self.ui,
+            reserved2=self.reserved2,
+            reserved1=self.reserved1,
+        )
+
+
+AX25XIDParameter.register(AX25XIDHDLCOptionalFunctionsParameter)
+
+# From AX.25 2.2 spec section 6.3.2:
+AX25_20_DEFAULT_XID_HDLCOPTFUNC = AX25XIDHDLCOptionalFunctionsParameter(
+    rej=True, srej=False, modulo8=True, modulo128=False
+)
+AX25_22_DEFAULT_XID_HDLCOPTFUNC = AX25XIDHDLCOptionalFunctionsParameter(
+    rej=False, srej=True, modulo8=True, modulo128=False
+)
+
+
+class AX25XIDBigEndianParameter(AX25XIDParameter):
+    """
+    Base class for all big-endian parameters (field lengths, window sizes, ACK
+    timers, retries).
+    """
+
+    LENGTH = None
+
+    @classmethod
+    def decode(cls, pv):
+        return cls(value=uint.decode(pv, big_endian=True))
+
+    def __init__(self, value):
         """
-        Return the value of the control byte.
+        Create a big-endian integer parameter.
         """
-        control = self._modifier
-        if self._pf:
-            control |= self.POLL_FINAL
-        return control
+        if not (isinstance(value, int) or isinstance(value, bool)):
+            raise TypeError("value must be an integer or boolean")
+
+        self._value = value
+
+        super(AX25XIDBigEndianParameter, self).__init__(pi=self.PI)
 
     @property
-    def pf(self):
-        """
-        Return the state of the poll/final bit
-        """
-        return self._pf
+    def pv(self):
+        return uint.encode(self.value, big_endian=True, length=self.LENGTH)
 
     @property
-    def modifier(self):
-        """
-        Return the modifier bits
-        """
-        return self._modifier
+    def value(self):
+        return self._value
 
-    def _copy(self):
-        return self.__class__(
-            destination=self.header.destination,
-            source=self.header.source,
-            repeaters=self.header.repeaters,
-            modifier=self.modifier,
-            cr=self.header.cr,
-            src_cr=self.header.src_cr,
-            pf=self.pf,
-        )
+    def copy(self):
+        return self.__class__(value=self.value)
 
 
-class AX25UnnumberedInformationFrame(AX25UnnumberedFrame):
+class AX25XIDIFieldLengthTransmitParameter(AX25XIDBigEndianParameter):
+    PI = AX25XIDParameterIdentifier.IFieldLengthTransmit
+
+
+AX25XIDParameter.register(AX25XIDIFieldLengthTransmitParameter)
+
+
+class AX25XIDIFieldLengthReceiveParameter(AX25XIDBigEndianParameter):
+    PI = AX25XIDParameterIdentifier.IFieldLengthReceive
+
+
+AX25XIDParameter.register(AX25XIDIFieldLengthReceiveParameter)
+
+# From AX.25 2.2 spec section 6.3.2:
+AX25_20_DEFAULT_XID_IFIELDRX = AX25XIDIFieldLengthReceiveParameter(2048)
+AX25_22_DEFAULT_XID_IFIELDRX = AX25XIDIFieldLengthReceiveParameter(2048)
+
+
+class AX25XIDWindowSizeTransmitParameter(AX25XIDBigEndianParameter):
+    PI = AX25XIDParameterIdentifier.WindowSizeTransmit
+    LENGTH = 1
+
+
+AX25XIDParameter.register(AX25XIDWindowSizeTransmitParameter)
+
+
+class AX25XIDWindowSizeReceiveParameter(AX25XIDBigEndianParameter):
+    PI = AX25XIDParameterIdentifier.WindowSizeReceive
+    LENGTH = 1
+
+
+AX25XIDParameter.register(AX25XIDWindowSizeReceiveParameter)
+
+# From AX.25 2.2 spec section 6.3.2:
+AX25_20_DEFAULT_XID_WINDOWSZRX = AX25XIDIFieldLengthReceiveParameter(7)
+AX25_22_DEFAULT_XID_WINDOWSZRX = AX25XIDIFieldLengthReceiveParameter(7)
+
+
+class AX25XIDAcknowledgeTimerParameter(AX25XIDBigEndianParameter):
+    PI = AX25XIDParameterIdentifier.AcknowledgeTimer
+
+
+AX25XIDParameter.register(AX25XIDAcknowledgeTimerParameter)
+
+# From AX.25 2.2 spec section 6.3.2:
+AX25_20_DEFAULT_XID_ACKTIMER = AX25XIDIFieldLengthReceiveParameter(3000)
+AX25_22_DEFAULT_XID_ACKTIMER = AX25XIDIFieldLengthReceiveParameter(3000)
+
+
+class AX25XIDRetriesParameter(AX25XIDBigEndianParameter):
+    PI = AX25XIDParameterIdentifier.Retries
+
+
+AX25XIDParameter.register(AX25XIDRetriesParameter)
+
+# From AX.25 2.2 spec section 6.3.2:
+AX25_20_DEFAULT_XID_RETRIES = AX25XIDIFieldLengthReceiveParameter(10)
+AX25_22_DEFAULT_XID_RETRIES = AX25XIDIFieldLengthReceiveParameter(10)
+
+
+class AX25ExchangeIdentificationFrame(AX25UnnumberedFrame):
     """
-    A representation of an un-numbered information frame.
+    Exchange Identification frame.
+
+    This frame is used to negotiate TNC features.
     """
 
-    MODIFIER = 0b00000011
+    MODIFIER = 0b10101111
 
     @classmethod
     def decode(cls, header, control, data):
-        if not data:
-            raise ValueError("Payload of UI must be at least one byte")
+        if len(data) < 4:
+            raise ValueError("Truncated XID header")
+
+        fi = data[0]
+        gi = data[1]
+        # Yep, GL is big-endian, just for a change!
+        gl = uint.decode(data[2:4], big_endian=True)
+        data = data[4:]
+
+        if len(data) != gl:
+            raise ValueError("Truncated XID data")
+
+        parameters = []
+        while data:
+            (param, data) = AX25XIDParameter.decode(data)
+            parameters.append(param)
+
         return cls(
             destination=header.destination,
             source=header.source,
             repeaters=header.repeaters,
-            cr=header.cr,
-            src_cr=header.src_cr,
+            fi=fi,
+            gi=gi,
+            parameters=parameters,
             pf=bool(control & cls.POLL_FINAL),
-            pid=data[0],
-            payload=data[1:],
+            cr=header.cr,
         )
 
+    # AX.25 2.2 sect 4.3.3.7 defines the following values for FI and GI:
+    FI = 0x82
+    GI = 0x80
+
     def __init__(
         self,
         destination,
         source,
-        pid,
-        payload,
+        parameters,
+        fi=FI,
+        gi=GI,
         repeaters=None,
         pf=False,
         cr=False,
-        src_cr=None,
+        timestamp=None,
+        deadline=None,
     ):
-        super(AX25UnnumberedInformationFrame, self).__init__(
+        super(AX25ExchangeIdentificationFrame, self).__init__(
             destination=destination,
             source=source,
             repeaters=repeaters,
             cr=cr,
-            src_cr=src_cr,
             pf=pf,
             modifier=self.MODIFIER,
+            timestamp=timestamp,
+            deadline=deadline,
         )
-        self._pid = int(pid) & 0xFF
-        self._payload = bytes(payload)
+        self._fi = int(fi)
+        self._gi = int(gi)
+        self._parameters = list(parameters)
 
     @property
-    def pid(self):
-        return self._pid
+    def fi(self):
+        return self._fi
 
     @property
-    def payload(self):
-        return self._payload
+    def gi(self):
+        return self._gi
 
     @property
-    def frame_payload(self):
-        return bytearray([self.pid]) + self.payload
+    def parameters(self):
+        return self._parameters
 
-    def __str__(self):
-        return "%s: PID=0x%02x Payload=%r" % (
-            self.header,
-            self.pid,
-            self.payload,
+    @property
+    def frame_payload(self):
+        parameters = b"".join([bytes(param) for param in self.parameters])
+        return (
+            super(AX25ExchangeIdentificationFrame, self).frame_payload
+            + bytes([self.fi, self.gi])
+            + uint.encode(len(parameters), length=2, big_endian=True)
+            + parameters
         )
 
     def _copy(self):
         return self.__class__(
             destination=self.header.destination,
             source=self.header.source,
+            fi=self.fi,
+            gi=self.gi,
+            parameters=[p.copy() for p in self.parameters],
             repeaters=self.header.repeaters,
             cr=self.header.cr,
-            src_cr=self.header.src_cr,
             pf=self.pf,
-            pid=self.pid,
-            payload=self.payload,
         )
 
-    @property
-    def tnc2(self):
-        """
-        Return the frame in "TNC2" format (default charset).
-        """
-        return self.get_tnc2()
 
-    def get_tnc2(self, charset="latin1", errors="strict"):
-        """
-        Return the frame in "TNC2" format with given charset.
-        """
-        return "%s:%s" % (
-            self.header.tnc2,
-            self.payload.decode(charset, errors),
-        )
+AX25UnnumberedFrame.register(AX25ExchangeIdentificationFrame)
 
 
-class AX25FrameRejectFrame(AX25UnnumberedFrame):
+class AX25UnnumberedAcknowledgeFrame(AX25BaseUnnumberedFrame):
     """
-    A representation of a Frame Reject (FRMR) frame.
+    Unnumbered Acknowledge frame.
 
-    Not much effort has been made to decode the meaning of these bits.
+    This frame is used to acknowledge a SABM/SABME frame.
     """
 
-    MODIFIER = 0b10000111
-    W_MASK = 0b00000001
-    X_MASK = 0b00000010
-    Y_MASK = 0b00000100
-    Z_MASK = 0b00001000
-    VR_MASK = 0b11100000
-    VR_POS = 5
-    CR_MASK = 0b00010000
-    VS_MASK = 0b00001110
-    VS_POS = 1
+    MODIFIER = 0b01100011
+    CR = False
 
-    @classmethod
-    def decode(cls, header, control, data):
-        if len(data) != 3:
-            raise ValueError("Payload of FRMR must be 3 bytes")
 
-        # W, X, Y and Z bits
-        w = bool(data[0] & cls.W_MASK)
-        x = bool(data[0] & cls.X_MASK)
-        y = bool(data[0] & cls.Y_MASK)
-        z = bool(data[0] & cls.Z_MASK)
+AX25UnnumberedFrame.register(AX25UnnumberedAcknowledgeFrame)
 
-        # VR, CR and VS fields
-        vr = (data[1] & cls.VR_MASK) >> cls.VR_POS
-        cr = bool(data[1] & cls.CR_MASK)
-        vs = (data[1] & cls.VS_MASK) >> cls.VS_POS
 
-        # Control field of rejected frame
-        frmr_control = data[2]
+class AX25TestFrame(AX25UnnumberedFrame):
+    """
+    Test frame.
+
+    This frame is used to initiate an echo request.
+    """
+
+    MODIFIER = 0b11100011
 
+    @classmethod
+    def decode(cls, header, control, data):
         return cls(
             destination=header.destination,
             source=header.source,
             repeaters=header.repeaters,
-            cr=header.cr,
-            src_cr=header.src_cr,
+            payload=data,
             pf=bool(control & cls.POLL_FINAL),
-            w=w,
-            x=x,
-            y=y,
-            z=z,
-            vr=vr,
-            frmr_cr=cr,
-            vs=vs,
-            frmr_control=frmr_control,
+            cr=header.cr,
         )
 
     def __init__(
         self,
         destination,
         source,
-        w,
-        x,
-        y,
-        z,
-        vr,
-        frmr_cr,
-        vs,
-        frmr_control,
+        payload,
         repeaters=None,
         pf=False,
         cr=False,
-        src_cr=None,
+        timestamp=None,
+        deadline=None,
     ):
-        super(AX25FrameRejectFrame, self).__init__(
+        super(AX25TestFrame, self).__init__(
             destination=destination,
             source=source,
             repeaters=repeaters,
             cr=cr,
-            src_cr=src_cr,
             pf=pf,
             modifier=self.MODIFIER,
+            timestamp=timestamp,
+            deadline=deadline,
         )
-
-        self._w = bool(w)
-        self._x = bool(x)
-        self._y = bool(y)
-        self._z = bool(z)
-        self._frmr_cr = bool(frmr_cr)
-        self._frmr_control = int(frmr_control)
-        self._vr = int(vr)
-        self._vs = int(vs)
-
-    @property
-    def frame_payload(self):
-        return bytes(self._gen_frame_payload())
-
-    def _gen_frame_payload(self):
-        wxyz = 0
-        if self._w:
-            wxyz |= self.W_MASK
-        if self._x:
-            wxyz |= self.X_MASK
-        if self._y:
-            wxyz |= self.Y_MASK
-        if self._z:
-            wxyz |= self.Z_MASK
-        yield wxyz
-
-        vrcrvs = 0
-        vrcrvs |= (self._vr << self.VR_POS) & self.VR_MASK
-        if self._frmr_cr:
-            vrcrvs |= self.CR_MASK
-        vrcrvs |= (self._vs << self.VS_POS) & self.VS_MASK
-        yield vrcrvs
-
-        yield self._frmr_control
-
-    @property
-    def w(self):
-        return self._w
-
-    @property
-    def x(self):
-        return self._x
-
-    @property
-    def y(self):
-        return self._y
-
-    @property
-    def z(self):
-        return self._z
-
-    @property
-    def vs(self):
-        return self._vs
-
-    @property
-    def vr(self):
-        return self._vr
+        self._payload = bytes(payload)
 
     @property
-    def frmr_cr(self):
-        return self._frmr_cr
+    def payload(self):
+        return self._payload
 
     @property
-    def frmr_control(self):
-        return self._frmr_control
+    def frame_payload(self):
+        return super(AX25TestFrame, self).frame_payload + bytes(self.payload)
 
     def _copy(self):
         return self.__class__(
             destination=self.header.destination,
             source=self.header.source,
+            payload=self.payload,
             repeaters=self.header.repeaters,
-            w=self.w,
-            x=self.x,
-            y=self.y,
-            z=self.z,
-            frmr_cr=self.frmr_cr,
-            vr=self.vr,
-            vs=self.vs,
-            frmr_control=self.frmr_control,
             cr=self.header.cr,
-            src_cr=self.header.src_cr,
             pf=self.pf,
         )
 
 
+AX25UnnumberedFrame.register(AX25TestFrame)
+
 # Helper classes
 
 
@@ -594,16 +2205,24 @@ def decode(cls, data):
                 source=addresses[1],
                 repeaters=addresses[2:],
                 cr=addresses[0].ch,
-                src_cr=addresses[1].ch,
+                # Legacy AX.25 1.x stations set the C bits identically
+                legacy=addresses[0].ch is addresses[1].ch,
             ),
             data,
         )
 
     def __init__(
-        self, destination, source, repeaters=None, cr=False, src_cr=None
+        self,
+        destination,
+        source,
+        repeaters=None,
+        cr=False,
+        src_cr=None,
+        legacy=False,
     ):
         self._cr = bool(cr)
         self._src_cr = src_cr
+        self._legacy = bool(legacy)
         self._destination = AX25Address.decode(destination)
         self._source = AX25Address.decode(source)
         self._repeaters = AX25Path(*(repeaters or []))
@@ -679,8 +2298,12 @@ def src_cr(self):
         Command/Response bit in the source address.
         """
         if self._src_cr is None:
-            return not self.cr
+            if self.legacy:
+                return self.cr  # AX.25 1.x: the C/R bits are identical
+            else:
+                return not self.cr  # AX.25 2.x: the C/R bits are opposite
         else:
+            # We were given an explicit value.
             return self._src_cr
 
     @property
@@ -699,6 +2322,10 @@ def tnc2(self):
             (",%s" % self._repeaters) if self._repeaters else "",
         )
 
+    @property
+    def legacy(self):
+        return self._legacy
+
 
 class AX25Path(Sequence):
     """
@@ -779,7 +2406,7 @@ class AX25Address(object):
     CALL_RE = re.compile(r"^([0-9A-Z]+)(?:-([0-9]{1,2}))?(\*?)$")
 
     @classmethod
-    def decode(cls, data):
+    def decode(cls, data, ssid=None):
         """
         Decode an AX.25 address from a frame.
         """
@@ -803,10 +2430,12 @@ def decode(cls, data):
             match = cls.CALL_RE.match(data.upper())
             if not match:
                 raise ValueError("Not a valid SSID: %s" % data)
+
+            if ssid is None:
+                ssid = int(match.group(2) or 0)
+
             return cls(
-                callsign=match.group(1),
-                ssid=int(match.group(2) or 0),
-                ch=match.group(3) == "*",
+                callsign=match.group(1), ssid=ssid, ch=match.group(3) == "*"
             )
         elif isinstance(data, AX25Address):
             # Clone factory
@@ -985,10 +2614,24 @@ def copy(self, **overrides):
         mydata.update(overrides)
         return self.__class__(**mydata)
 
+    def normcopy(self, **overrides):
+        """
+        Return a normalised copy of this address.  By default, reserved bits
+        are set to 1s, C/H and extension bits are set to 0s.
+        """
+
+        # Define defaults for the overrides
+        overrides.setdefault("res0", True)
+        overrides.setdefault("res1", True)
+        overrides.setdefault("ch", False)
+        overrides.setdefault("extension", False)
+
+        return self.copy(**overrides)
+
     @property
     def normalised(self):
         """
-        Return a normalised copy of this address.  (Set reserved bits to ones,
-        clear the CH bit and extension bit.)
+        Return a normalised copy of this address.
         """
-        return self.copy(res0=True, res1=True, ch=False, extension=False)
+
+        return self.normcopy()
diff --git a/aioax25/kiss.py b/aioax25/kiss.py
index d125ee1..f943976 100644
--- a/aioax25/kiss.py
+++ b/aioax25/kiss.py
@@ -176,6 +176,49 @@ def __init__(self, port, payload, cmd=CMD_DATA):
 KISSCommand._register(CMD_DATA, KISSCmdData)
 
 
+# Remaining types for identification purposes only
+class KISSCmdTXDelay(KISSCommand):
+    pass
+
+
+KISSCommand._register(CMD_TXDELAY, KISSCmdTXDelay)
+
+
+class KISSCmdP(KISSCommand):
+    pass
+
+
+KISSCommand._register(CMD_P, KISSCmdP)
+
+
+class KISSCmdSlotTime(KISSCommand):
+    pass
+
+
+KISSCommand._register(CMD_SLOTTIME, KISSCmdSlotTime)
+
+
+class KISSCmdTXTail(KISSCommand):
+    pass
+
+
+KISSCommand._register(CMD_TXTAIL, KISSCmdTXTail)
+
+
+class KISSCmdFDuplex(KISSCommand):
+    pass
+
+
+KISSCommand._register(CMD_FDUPLEX, KISSCmdFDuplex)
+
+
+class KISSCmdSetHW(KISSCommand):
+    pass
+
+
+KISSCommand._register(CMD_SETHW, KISSCmdSetHW)
+
+
 # KISS device interface
 
 
@@ -249,6 +292,10 @@ def _receive_frame(self):
         underlying device.  If more than one frame is present, schedule
         ourselves again with the IO loop.
         """
+        # Skip if all we have is a FEND byte
+        if bytes(self._rx_buffer) == bytearray([BYTE_FEND]):
+            return
+
         # Locate the first FEND byte
         try:
             start = self._rx_buffer.index(BYTE_FEND)
@@ -293,11 +340,15 @@ def _receive_frame(self):
 
         # If we just have a FEND, stop here.
         if bytes(self._rx_buffer) == bytearray([BYTE_FEND]):
+            self._log.debug("FEND byte in receive buffer, wait for more.")
             return
 
         # If there is more to send, call ourselves via the IO loop
         if len(self._rx_buffer):
+            self._log.debug("More data in receive buffer, will check again.")
             self._loop.call_soon(self._receive_frame)
+        else:
+            self._log.debug("No data in receive buffer.  Wait for more.")
 
     def _dispatch_rx_frame(self, frame):
         """
@@ -417,6 +468,7 @@ def _make_protocol(self):
         Return a Protocol instance that will handle the KISS traffic for the
         asyncio transport.
         """
+        self._log.debug("Constructing protocol object")
         return KISSProtocol(
             self._on_connect,
             self._receive,
@@ -431,6 +483,7 @@ async def _open_connection(self):  # pragma: no cover
         raise NotImplementedError("Abstract function")
 
     def _open(self):
+        self._log.debug("Awaiting KISS transport")
         ensure_future(self._open_connection())
 
     def _on_connect(self, transport):
@@ -483,6 +536,7 @@ def __init__(self, device, baudrate, *args, **kwargs):
         self._baudrate = baudrate
 
     async def _open_connection(self):
+        self._log.debug("Delegating to KISS serial device %r", self._device)
         await create_serial_connection(
             self._loop,
             self._make_protocol,
@@ -683,6 +737,7 @@ def __init__(self, on_connect, on_receive, on_close, log):
 
     def connection_made(self, transport):
         try:
+            self._log.debug("Announcing connection: %r", transport)
             self._on_connect(transport)
         except:
             self._log.exception("Failed to handle connection establishment")
@@ -717,6 +772,7 @@ def __init__(self, on_connect, on_receive, on_close, log):
 
     def connection_made(self, transport):
         try:
+            self._log.debug("Announcing connection: %r", transport)
             self._on_connect(transport)
         except:
             self._log.exception("Failed to handle connection establishment")
diff --git a/aioax25/peer.py b/aioax25/peer.py
new file mode 100644
index 0000000..a084e7d
--- /dev/null
+++ b/aioax25/peer.py
@@ -0,0 +1,1941 @@
+#!/usr/bin/env python3
+
+"""
+AX.25 Station Peer interface.
+
+This is used as the "proxy" for interacting with a remote AX.25 station.
+"""
+
+from .signal import Signal
+
+import weakref
+import enum
+import logging
+
+from .version import AX25Version
+from .frame import (
+    AX25Frame,
+    AX25Path,
+    AX25SetAsyncBalancedModeFrame,
+    AX25SetAsyncBalancedModeExtendedFrame,
+    AX25ExchangeIdentificationFrame,
+    AX25UnnumberedFrame,
+    AX25UnnumberedAcknowledgeFrame,
+    AX25TestFrame,
+    AX25DisconnectFrame,
+    AX25DisconnectModeFrame,
+    AX25FrameRejectFrame,
+    AX25RawFrame,
+    AX2516BitInformationFrame,
+    AX258BitInformationFrame,
+    AX258BitReceiveReadyFrame,
+    AX2516BitReceiveReadyFrame,
+    AX258BitReceiveNotReadyFrame,
+    AX2516BitReceiveNotReadyFrame,
+    AX258BitRejectFrame,
+    AX2516BitRejectFrame,
+    AX258BitSelectiveRejectFrame,
+    AX2516BitSelectiveRejectFrame,
+    AX25InformationFrameMixin,
+    AX25SupervisoryFrameMixin,
+    AX25XIDParameterIdentifier,
+    AX25XIDClassOfProceduresParameter,
+    AX25XIDHDLCOptionalFunctionsParameter,
+    AX25XIDIFieldLengthTransmitParameter,
+    AX25XIDIFieldLengthReceiveParameter,
+    AX25XIDWindowSizeTransmitParameter,
+    AX25XIDWindowSizeReceiveParameter,
+    AX25XIDAcknowledgeTimerParameter,
+    AX25XIDRetriesParameter,
+    AX25_20_DEFAULT_XID_COP,
+    AX25_22_DEFAULT_XID_COP,
+    AX25_20_DEFAULT_XID_HDLCOPTFUNC,
+    AX25_22_DEFAULT_XID_HDLCOPTFUNC,
+    AX25_20_DEFAULT_XID_IFIELDRX,
+    AX25_22_DEFAULT_XID_IFIELDRX,
+    AX25_20_DEFAULT_XID_WINDOWSZRX,
+    AX25_22_DEFAULT_XID_WINDOWSZRX,
+    AX25_20_DEFAULT_XID_ACKTIMER,
+    AX25_22_DEFAULT_XID_ACKTIMER,
+    AX25_20_DEFAULT_XID_RETRIES,
+    AX25_22_DEFAULT_XID_RETRIES,
+)
+
+
+# AX25RejectMode precedence:
+_REJECT_MODE_PRECEDENCE = {"selective_rr": 2, "selective": 1, "implicit": 0}
+
+
+class AX25RejectMode(enum.Enum):
+    IMPLICIT = "implicit"
+    SELECTIVE = "selective"
+    SELECTIVE_RR = "selective_rr"
+
+    @property
+    def precedence(self):
+        """
+        Get the precedence of this mode.
+        """
+        return _REJECT_MODE_PRECEDENCE[self.value]
+
+
+class AX25PeerState(enum.Enum):
+    # DISCONNECTED: No connection has been established
+    DISCONNECTED = 0
+
+    # Awaiting response to XID request
+    NEGOTIATING = 1
+
+    # Awaiting response to SABM(E) request
+    CONNECTING = 2
+
+    # Connection is established
+    CONNECTED = 3
+
+    # Awaiting response to DISC request
+    DISCONNECTING = 4
+
+    # FRMR condition entered
+    FRMR = 5
+
+    # Incomming connection, awaiting our UA or DM response
+    INCOMING_CONNECTION = 6
+
+
+class AX25Peer(object):
+    """
+    This class is a proxy representation of the remote AX.25 peer that may be
+    connected to this station.  The factory for these objects is the
+    AX25Station's getpeer method.
+    """
+
+    def __init__(
+        self,
+        station,
+        address,
+        repeaters,
+        max_ifield,
+        max_ifield_rx,
+        max_retries,
+        max_outstanding_mod8,
+        max_outstanding_mod128,
+        rr_delay,
+        rr_interval,
+        rnr_interval,
+        ack_timeout,
+        idle_timeout,
+        protocol,
+        modulo128,
+        log,
+        loop,
+        reject_mode,
+        full_duplex,
+        reply_path=None,
+        locked_path=False,
+        paclen=128,
+    ):
+        """
+        Create a peer context for the station named by 'address'.
+        """
+        self._station = weakref.ref(station)
+        self._repeaters = repeaters
+        self._reply_path = reply_path
+        self._address = address
+        self._ack_timeout = ack_timeout
+        self._idle_timeout = idle_timeout
+        self._reject_mode = reject_mode
+        self._full_duplex = full_duplex
+        self._max_ifield = max_ifield
+        self._max_ifield_rx = max_ifield_rx
+        self._max_retries = max_retries
+        self._modulo128 = modulo128
+        self._max_outstanding_mod8 = max_outstanding_mod8
+        self._max_outstanding_mod128 = max_outstanding_mod128
+        self._rr_delay = rr_delay
+        self._rr_interval = rr_interval
+        self._rnr_interval = rnr_interval
+        self._paclen = paclen
+        self._locked_path = bool(locked_path)
+        self._protocol = protocol
+        self._log = log
+        self._loop = loop
+
+        # Internal state (see AX.25 2.2 spec 4.2.4)
+        self._state = AX25PeerState.DISCONNECTED
+        self._max_outstanding = None  # Decided when SABM(E) received
+        self._modulo = None  # Set when SABM(E) received
+        self._negotiated = False  # Set to True after XID negotiation
+        self._connected = False  # Set to true on SABM UA
+        self._last_act = 0  # Time of last activity
+
+        # 2.3.2.4.1 Send State Variable V(S)
+        # The send state variable is a variable that is internal to the DXE
+        # and is never sent. It contains the next sequential number to be
+        # assigned to the next transmitted I frame. This variable is updated
+        # upon the transmission of each I frame.
+        self._send_state = 0
+        self._send_state_name = "V(S)"
+
+        # 2.3.2.4.2 Send Sequence Number N(S)
+        # The send sequence number is found in the control field of all I
+        # frames. It contains the sequence number of the I frame being sent.
+        # Just prior to the transmission of the I frame, N(S) is updated to
+        # equal the send state variable.
+        self._send_seq = 0
+        self._send_seq_name = "N(S)"
+
+        # 2.3.2.4.3 Receive State Variable V(R)
+        # The receive state variable is a variable that is internal to the
+        # DXE. It contains the sequence number of the next expected received I
+        # frame. This variable is updated upon the reception of an error-free
+        # I frame whose send sequence number equals the present received state
+        # variable value.
+        self._recv_state = 0
+        self._recv_state_name = "V(R)"
+
+        # 2.3.2.4.4 Received Sequence Number N(R)
+        # The received sequence number is in both I and S frames. Prior to
+        # sending an I or S frame, this variable is updated to equal that of
+        # the received state variable, thus implicitly acknowledging the
+        # proper reception of all frames up to and including N(R)-1.
+        self._recv_seq = 0
+        self._recv_seq_name = "N(R)"
+
+        # ACK state number.
+        # Used to track the sequence number previously ACKed in an I or S
+        # frame N(R) field.
+        self._ack_state = 0  # AKA V(A)
+        self._ack_state_name = "V(A)"
+
+        self._local_busy = False  # Local end busy, respond to
+        # RR and I-frames with RNR.
+        self._peer_busy = False  # Peer busy, await RR.
+
+        # Time of last sent RNR notification
+        self._last_rnr_sent = 0
+
+        # Classes to use for I and S frames
+        self._IFrameClass = None
+        self._RRFrameClass = None
+        self._RNRFrameClass = None
+        self._REJFrameClass = None
+        self._SREJFrameClass = None
+
+        # Timeouts
+        self._ack_timeout_handle = None
+        self._idle_timeout_handle = None
+        self._rr_notification_timeout_handle = None
+
+        # Unacknowledged I-frames to be ACKed.  This dictionary maps the N(R)
+        # of the frame with a tuple of the form (pid, payload).
+        self._pending_iframes = {}
+
+        # Data pending to be sent.  This will be a queue of frames represented
+        # as tuples of the form (pid, payload).  Each payload is assumed to be
+        # no bigger than max_ifield.
+        self._pending_data = []
+
+        # Link quality measurement:
+        # rx_path_count is incremented each time a frame is received via a
+        # given AX.25 digipeater path.
+        self._rx_path_count = {}
+        # tx_path_score is incremented for each transmitted frame which is ACKed
+        # and decremented each time a frame is REJected.
+        self._tx_path_score = {}
+
+        # Handling of various incoming frames
+        self._testframe_handler = None
+        self._xidframe_handler = None
+        self._sabmframe_handler = None
+        self._uaframe_handler = None
+        self._dmframe_handler = None
+        self._frmrframe_handler = None
+
+        # Signals:
+
+        # Fired when any frame is received from the peer
+        self.received_frame = Signal()
+
+        # Fired when any frame is sent to the peer
+        self.sent_frame = Signal()
+
+        # Fired when an I-frame is received
+        self.received_information = Signal()
+
+        # Fired when the connection state changes
+        self.connect_state_changed = Signal()
+
+        # Kick off the idle timer
+        self._reset_idle_timeout()
+
+    @property
+    def address(self):
+        """
+        Return the peer's AX.25 address
+        """
+        return self._address
+
+    @property
+    def state(self):
+        """
+        Return the peer connection state
+        """
+        return self._state
+
+    @property
+    def reply_path(self):
+        """
+        Return the digipeater path to use when contacting this station.
+        """
+        if self._reply_path is None:
+            # No specific path set, are we locked to a given path?
+            if self._locked_path:
+                # Use the one we were given
+                return self._repeaters
+
+            # Enumerate all possible paths and select the "best" path
+            # - most highly rated TX path
+            # - most frequently seen RX path
+            all_paths = list(
+                sorted(self._tx_path_score.items(), key=lambda i: i[1])
+            ) + list(sorted(self._rx_path_count.items(), key=lambda i: i[1]))
+
+            if all_paths:
+                best_path = all_paths[-1][0]
+                self._log.info(
+                    "Choosing highest rated TX/most common RX path: %s",
+                    best_path,
+                )
+            else:
+                # If no paths exist, use whatever default path is set
+                best_path = self._repeaters
+                self._log.info(
+                    "Choosing given path for replies: %s", best_path
+                )
+
+            # Use this until we have reason to change
+            self._reply_path = AX25Path(*(best_path or []))
+
+        return self._reply_path
+
+    def weight_path(self, path, weight, relative=True):
+        """
+        Adjust the weight of a digipeater path used to reach this station.
+        If relative is True, this increments by weight; otherwise it overrides
+        the weight given.
+        """
+        path = tuple(reversed(AX25Path(*(path or [])).reply))
+
+        if relative:
+            weight += self._tx_path_score.get(path, 0)
+        self._tx_path_score[path] = weight
+        self._log.debug("Weighted score for %s: %d", path, weight)
+
+    def ping(self, payload=None, timeout=30.0, callback=None):
+        """
+        Ping the peer and wait for a response.
+        """
+        self._log.debug(
+            "Beginning ping of station (payload=%r timeout=%r)",
+            payload,
+            timeout,
+        )
+        handler = AX25PeerTestHandler(self, bytes(payload or b""), timeout)
+        handler.done_sig.connect(self._on_test_done)
+
+        if callback is not None:
+            handler.done_sig.connect(callback)
+
+        handler._go()
+        return handler
+
+    def connect(self):
+        """
+        Connect to the remote node.
+        """
+        if self._state is AX25PeerState.DISCONNECTED:
+            self._log.info("Initiating connection to remote peer")
+            self._set_conn_state(AX25PeerState.CONNECTING)
+            handler = AX25PeerConnectionHandler(self)
+            handler.done_sig.connect(self._on_connect_response)
+            handler._go()
+        else:
+            self._log.info(
+                "Will not connect to peer now, currently in state %s.",
+                self._state.name,
+            )
+
+    def accept(self):
+        """
+        Accept an incoming connection from the peer.
+        """
+        if self._state is AX25PeerState.INCOMING_CONNECTION:
+            self._log.info("Accepting incoming connection")
+            # Send a UA and set ourselves as connected
+            self._stop_ack_timer()
+            self._send_ua()
+            self._log.info("Connection accepted")
+            self._set_conn_state(AX25PeerState.CONNECTED)
+        else:
+            self._log.info(
+                "Will not accept connection from peer now, "
+                "currently in state %s.",
+                self._state.name,
+            )
+
+    def reject(self):
+        """
+        Reject an incoming connection from the peer.
+        """
+        if self._state is AX25PeerState.INCOMING_CONNECTION:
+            self._log.info("Rejecting incoming connection")
+            # Send a DM and set ourselves as disconnected
+            self._stop_ack_timer()
+            self._set_conn_state(AX25PeerState.DISCONNECTED)
+            self._send_dm()
+        else:
+            self._log.info(
+                "Will not reject connection from peer now, "
+                "currently in state %s.",
+                self._state.name,
+            )
+
+    def disconnect(self):
+        """
+        Disconnect from the remote node.
+        """
+        if self._state is AX25PeerState.CONNECTED:
+            self._uaframe_handler = self._on_disconnect
+            self._set_conn_state(AX25PeerState.DISCONNECTING)
+            self._send_disc()
+            self._start_disconnect_ack_timer()
+        else:
+            self._log.info(
+                "Will not disconnect from peer now, "
+                "currently in state %s.",
+                self._state.name,
+            )
+
+    def send(self, payload, pid=AX25Frame.PID_NO_L3):
+        """
+        Send the given payload data to the remote station.
+        """
+        while payload:
+            block = payload[: self._paclen]
+            payload = payload[self._paclen :]
+
+            self._pending_data.append((pid, block))
+
+        self._send_next_iframe()
+
+    def _cancel_idle_timeout(self):
+        """
+        Cancel the idle timeout handle
+        """
+        if self._idle_timeout_handle is not None:
+            self._idle_timeout_handle.cancel()
+            self._idle_timeout_handle = None
+
+    def _reset_idle_timeout(self):
+        """
+        Reset the idle timeout handle
+        """
+        self._cancel_idle_timeout()
+        self._idle_timeout_handle = self._loop.call_later(
+            self._idle_timeout, self._cleanup
+        )
+
+    def _cleanup(self):
+        """
+        Clean up the instance of this peer as the activity has expired.
+        """
+        if self._state not in (
+            AX25PeerState.DISCONNECTED,
+            AX25PeerState.DISCONNECTING,
+        ):
+            self._log.warning("Disconnecting peer due to inactivity")
+            if self._state is AX25PeerState.CONNECTED:
+                self.disconnect()
+            else:
+                self._send_dm()
+        else:
+            self._log.debug(
+                "Clean-up initiated in state %s", self._state.name
+            )
+
+        # Cancel other timers
+        self._cancel_rr_notification()
+
+    def _on_receive(self, frame):
+        """
+        Handle an incoming AX.25 frame from this peer.
+        """
+        self._log.debug("Received: %s", frame)
+
+        # Kick off the idle timer
+        self._reset_idle_timeout()
+
+        # Update the last activity timestamp
+        self._last_act = self._loop.time()
+
+        if not self._locked_path:
+            # Increment the received frame count
+            path = tuple(reversed(frame.header.repeaters.reply))
+            pathcount = self._rx_path_count.get(path, 0) + 1
+            self._log.debug(
+                "Observed %d frame(s) via path %s", pathcount, path
+            )
+            self._rx_path_count[path] = pathcount
+
+        # AX.25 2.2 sect 6.3.1: "The originating TNC sending a SABM(E) command
+        # ignores and discards any frames except SABM, DISC, UA and DM frames
+        # from the distant TNC."
+        if (self._state is AX25PeerState.CONNECTING) and not isinstance(
+            frame,
+            (
+                AX25SetAsyncBalancedModeFrame,  # SABM
+                AX25SetAsyncBalancedModeExtendedFrame,  # SABME
+                AX25DisconnectFrame,  # DISC
+                AX25UnnumberedAcknowledgeFrame,  # UA
+                AX25DisconnectModeFrame,
+            ),
+        ):  # DM
+            self._log.debug(
+                "Dropping frame due to pending SABM UA: %s", frame
+            )
+            return
+
+        # AX.25 2.0 sect 2.4.5: "After sending the FRMR frame, the sending DXE
+        # will enter the frame reject condition. This condition is cleared when
+        # the DXE that sent the FRMR frame receives a SABM or DISC command, or
+        # a DM response frame. Any other command received while the DXE is in
+        # the frame reject state will cause another FRMR to be sent out with
+        # the same information field as originally sent."
+        if (self._state is AX25PeerState.FRMR) and not isinstance(
+            frame,
+            (AX25SetAsyncBalancedModeFrame, AX25DisconnectFrame),  # SABM
+        ):  # DISC
+            self._log.debug("Dropping frame due to FRMR: %s", frame)
+            return
+
+        # Is this a U frame?  I frames and S frames must be decoded elsewhere.
+        if isinstance(frame, AX25UnnumberedFrame):
+            self.received_frame.emit(frame=frame, peer=self)
+
+        if isinstance(frame, AX25TestFrame):
+            # TEST frame
+            return self._on_receive_test(frame)
+        elif isinstance(frame, AX25FrameRejectFrame):
+            # FRMR frame
+            return self._on_receive_frmr(frame)
+        elif isinstance(frame, AX25UnnumberedAcknowledgeFrame):
+            # UA frame
+            return self._on_receive_ua()
+        elif isinstance(
+            frame,
+            (
+                AX25SetAsyncBalancedModeFrame,
+                AX25SetAsyncBalancedModeExtendedFrame,
+            ),
+        ):
+            # SABM or SABME frame
+            return self._on_receive_sabm(frame)
+        elif isinstance(frame, AX25DisconnectFrame):
+            # DISC frame
+            return self._on_receive_disc()
+        elif isinstance(frame, AX25DisconnectModeFrame):
+            # DM frame
+            return self._on_receive_dm()
+        elif isinstance(frame, AX25ExchangeIdentificationFrame):
+            # XID frame
+            return self._on_receive_xid(frame)
+        elif isinstance(frame, AX25RawFrame):
+            # This is either an I or S frame.  We should know enough now to
+            # decode it properly.
+            if self._state is AX25PeerState.CONNECTED:
+                # A connection is in progress, we can decode this
+                frame = AX25Frame.decode(
+                    frame, modulo128=(self._modulo == 128)
+                )
+                self._log.debug("Decoded frame: %s", frame)
+                self.received_frame.emit(frame=frame, peer=self)
+                if isinstance(frame, AX25InformationFrameMixin):
+                    # This is an I-frame
+                    return self._on_receive_iframe(frame)
+                elif isinstance(frame, AX25SupervisoryFrameMixin):
+                    # This is an S-frame
+                    return self._on_receive_sframe(frame)
+                else:  # pragma: no cover
+                    self._log.warning(
+                        "Dropping unrecognised frame: %s", frame
+                    )
+            else:
+                # No connection in progress, send a DM.
+                self._log.debug(
+                    "Received I or S frame in state %s", self._state.name
+                )
+                self.received_frame.emit(frame=frame, peer=self)
+                return self._send_dm()
+
+    def _on_receive_isframe_nr_ns(self, frame):
+        """
+        Handle the N(R) / N(S) fields from an I or S frame from the peer.
+        """
+        # "Whenever an I or S frame is correctly received, even in a busy
+        # condition, the N(R) of the received frame should be checked to see
+        # if it includes an acknowledgement of outstanding sent I frames. The
+        # T1 timer should be cancelled if the received frame actually
+        # acknowledges previously unacknowledged frames. If the T1 timer is
+        # cancelled and there are still some frames that have been sent that
+        # are not acknowledged, T1 should be started again. If the T1 timer
+        # runs out before an acknowledgement is received, the device should
+        # proceed to the retransmission procedure in 2.4.4.9."
+
+        # Check N(R) for received frames.
+        self._ack_outstanding((frame.nr - 1) % self._modulo)
+
+    def _on_receive_iframe(self, frame):
+        """
+        Handle an incoming I-frame
+        """
+        # Cancel a pending RR notification frame.
+        self._cancel_rr_notification()
+
+        # AX.25 2.2 spec 6.4.2.2: "If the TNC is in a busy condition, it
+        # ignores any I frames without reporting this condition, other than
+        # repeating the indication of the busy condition."
+        if self._local_busy:
+            self._log.warning(
+                "Dropping I-frame during busy condition: %s", frame
+            )
+            self._send_rnr_notification()
+            return
+
+        # AX.25 2.2 spec 6.4.2.1: "If a TNC receives a valid I frame (one whose
+        # send sequence number equals the receiver's receive state variable) and
+        # is not in the busy condition,…"
+        if frame.ns != self._recv_seq:
+            # TODO: should we send a REJ/SREJ after a time-out?
+            self._log.debug(
+                "I-frame sequence N(S) is %s, expecting N(R) %s, ignoring",
+                frame.ns,
+                self._recv_seq,
+            )
+            return
+
+        # "…it accepts the received I frame,
+        # increments its receive state variable, and acts in one of the
+        # following manners:…"
+        self._update_state(
+            "_recv_state", value=frame.ns + 1, comment="from I-frame N(S)"
+        )
+        self._on_receive_isframe_nr_ns(frame)
+
+        # TODO: the payload here may be a repeat of data already seen, or
+        # for _future_ data (i.e. there's an I-frame that got missed in between
+        # the last one we saw, and this one).  How do we handle this possibly
+        # out-of-order data?
+        self.received_information.emit(frame=frame, payload=frame.payload)
+
+        # "a) If it has an I frame to send, that I frame may be sent with the
+        # transmitted N(R) equal to its receive state V(R) (thus acknowledging
+        # the received frame)."
+        #
+        # We need to also be mindful of the number of outstanding frames here!
+        if len(self._pending_data) and (
+            len(self._pending_iframes) < self._max_outstanding
+        ):
+            self._log.debug("Data pending, will send I-frame in reply")
+            return self._send_next_iframe()
+
+        # "b) If there are no outstanding I frames, the receiving TNC sends
+        # an RR frame with N(R) equal to V(R)."
+        self._log.debug("No data pending, will send RR S-frame in reply")
+        self._schedule_rr_notification()
+
+    def _on_receive_sframe(self, frame):
+        """
+        Handle a S-frame from the peer.
+        """
+        self._on_receive_isframe_nr_ns(frame)
+        if isinstance(frame, self._RRFrameClass):
+            self._on_receive_rr(frame)
+        elif isinstance(frame, self._RNRFrameClass):
+            self._on_receive_rnr(frame)
+        elif isinstance(frame, self._REJFrameClass):
+            self._on_receive_rej(frame)
+        elif isinstance(frame, self._SREJFrameClass):
+            self._on_receive_srej(frame)
+        else:  # pragma: no cover
+            # Should be impossible to get here!
+            raise TypeError("Unhandled frame: %r" % frame)
+
+    def _on_receive_rr(self, frame):
+        if frame.pf:
+            # Peer requesting our RR status
+            self._log.debug("RR status requested from peer")
+            self._on_receive_rr_rnr_rej_query()
+        else:
+            # Received peer's RR status, peer no longer busy
+            self._log.debug(
+                "RR notification received from peer N(R)=%d", frame.nr
+            )
+            self._peer_busy = False
+            self._send_next_iframe()
+
+    def _on_receive_rnr(self, frame):
+        if frame.pf:
+            # Peer requesting our RNR status
+            self._log.debug("RNR status requested from peer")
+            self._on_receive_rr_rnr_rej_query()
+        else:
+            # Received peer's RNR status, peer is busy
+            self._log.debug("RNR notification received from peer")
+            self._peer_busy = True
+
+    def _on_receive_rej(self, frame):
+        if frame.pf:
+            # Peer requesting rejected frame status.
+            self._log.debug("REJ status requested from peer")
+            self._on_receive_rr_rnr_rej_query()
+        else:
+            # Reject reject.
+            self._log.debug("REJ notification received from peer")
+            # AX.25 2.2 section 6.4.7 says we set V(S) to this frame's
+            # N(R) and begin re-transmission.
+            self._log.debug("Set state V(S) from frame N(R) = %d", frame.nr)
+            self._update_state(
+                "_send_state", value=frame.nr, comment="from REJ N(R)"
+            )
+            self._send_next_iframe()
+
+    def _on_receive_srej(self, frame):
+        if frame.pf:
+            # AX.25 2.2 sect 4.3.2.4: "If the P/F bit in the SREJ is set to
+            # '1', then I frames numbered up to N(R)-1 inclusive are considered
+            # as acknowledged."
+            self._log.debug("SREJ received with P/F=1")
+            # TODO: but we always ACK up to N(R)-1 on receipt of a S-frame?
+            # What if the P/F bit is 0?
+
+        # Re-send the outstanding frame
+        self._log.debug("Re-sending I-frame %d due to SREJ", frame.nr)
+        self._transmit_iframe(frame.nr)
+
+    def _on_receive_rr_rnr_rej_query(self):
+        """
+        Handle a RR or RNR query
+        """
+        if self._local_busy:
+            self._log.debug("RR poll request from peer: we're busy")
+            self._send_rnr_notification()
+        else:
+            self._log.debug("RR poll request from peer: we're ready")
+            self._send_rr_notification()
+
+    def _ack_outstanding(self, nr):
+        """
+        Receive all frames up to N(R)-1
+        """
+        self._log.debug("%d through to %d are received", self._ack_state, nr)
+        while self._ack_state != nr:
+            if self._log.isEnabledFor(logging.DEBUG):
+                self._log.debug("Pending frames: %r", self._pending_iframes)
+
+            self._log.debug("ACKing N(R)=%s", self._ack_state)
+            try:
+                frame = self._pending_iframes.pop(self._ack_state)
+                if self._log.isEnabledFor(logging.DEBUG):
+                    self._log.debug(
+                        "Popped %s off pending queue, N(R)s pending: %r",
+                        frame,
+                        self._pending_iframes,
+                    )
+            except KeyError:
+                if self._log.isEnabledFor(logging.DEBUG):
+                    self._log.debug(
+                        "ACK to unexpected N(R) number %s, pending: %r",
+                        self._ack_state,
+                        self._pending_iframes,
+                    )
+            finally:
+                self._update_state(
+                    "_ack_state", delta=1, comment="ACKed by peer N(R)"
+                )
+
+    def _on_receive_test(self, frame):
+        self._log.debug("Received TEST response: %s", frame)
+        if not self._testframe_handler:
+            return
+
+        handler = self._testframe_handler()
+        if not handler:
+            # It's dead now.
+            self._testframe_handler = None
+            return
+
+        handler._on_receive(frame)
+
+    def _on_test_done(self, handler, **kwargs):
+        if not self._testframe_handler:
+            self._log.debug("TEST completed without frame handler")
+            return
+
+        real_handler = self._testframe_handler()
+        if (real_handler is not None) and (handler is not real_handler):
+            self._log.debug("TEST completed with stale handler")
+            return
+
+        self._log.debug("TEST completed, removing handler")
+        self._testframe_handler = None
+
+    def _on_receive_frmr(self, frame):
+        # We just received a FRMR.
+        self._log.warning("Received FRMR from peer: %s", frame)
+        if self._frmrframe_handler is not None:
+            # Pass to handler
+            self._frmrframe_handler(frame)
+        else:
+            # No handler, send SABM to recover (this is clearly an AX.25 2.0
+            # station)
+            self._send_sabm()
+
+    def _on_receive_sabm(self, frame):
+        extended = isinstance(frame, AX25SetAsyncBalancedModeExtendedFrame)
+        self._log.info("Received SABM%s", "E" if extended else "")
+        if extended:
+            # If we don't know the protocol of the peer, we can safely assume
+            # AX.25 2.2 now.
+            if self._protocol == AX25Version.UNKNOWN:
+                self._log.info("Assuming sender is AX.25 2.2")
+                self._protocol = AX25Version.AX25_22
+
+            # Make sure both ends are enabled for AX.25 2.2
+            if self._station()._protocol != AX25Version.AX25_22:
+                # We are not in AX.25 2.2 mode.
+                #
+                # "A TNC that uses a version of AX.25 prior to v2.2 responds
+                # with a FRMR".  W bit indicates the control field was not
+                # understood.
+                self._log.warning(
+                    "Sending FRMR as we are not in AX.25 2.2 mode"
+                )
+                return self._send_frmr(frame, w=True)
+
+            if self._protocol != AX25Version.AX25_22:
+                # The other station is not in AX.25 2.2 mode.
+                #
+                # "If the TNC is not capable of accepting a SABME, it
+                # responds with a DM frame".
+                self._log.warning(
+                    "Sending DM as peer is not in AX.25 2.2 mode"
+                )
+                return self._send_dm()
+
+        # Set up the connection state
+        self._init_connection(extended)
+
+        # Are we already connecting ourselves to this station?  If yes,
+        # we should just treat their SABM(E) as a UA, since _clearly_ both
+        # parties wish to connect.
+        if self._state == AX25PeerState.CONNECTING:
+            self._log.info(
+                "Auto-accepting incoming connection as we are waiting for "
+                "UA from our connection attempt."
+            )
+            self._sabmframe_handler()
+        else:
+            # Set the incoming connection state, and emit a signal via the
+            # station's 'connection_request' signal.
+            self._log.debug("Preparing incoming connection")
+            self._set_conn_state(AX25PeerState.INCOMING_CONNECTION)
+            self._start_connect_ack_timer()
+            self._log.debug("Announcing incoming connection")
+            self._station().connection_request.emit(peer=self)
+
+    def _start_connect_ack_timer(self):
+        self._start_ack_timer(self._on_incoming_connect_timeout)
+
+    def _start_disconnect_ack_timer(self):
+        self._start_ack_timer(self._on_disc_ua_timeout)
+
+    def _start_ack_timer(self, handler):
+        self._ack_timeout_handle = self._loop.call_later(
+            self._ack_timeout, handler
+        )
+
+    def _stop_ack_timer(self):
+        if self._ack_timeout_handle is not None:
+            self._ack_timeout_handle.cancel()
+
+        self._ack_timeout_handle = None
+
+    def _on_incoming_connect_timeout(self):
+        if self._state is AX25PeerState.INCOMING_CONNECTION:
+            self._log.info("Incoming connection timed out")
+            self._ack_timeout_handle = None
+            self.reject()
+        else:
+            self._log.debug(
+                "Incoming connection time-out in state %s", self._state.name
+            )
+
+    def _on_connect_response(self, response, **kwargs):
+        # Handle the connection result.
+        self._log.debug("Connection response: %r", response)
+        if response == "ack":
+            # We're in.
+            self._set_conn_state(AX25PeerState.CONNECTED)
+        else:
+            # Didn't work
+            self._set_conn_state(AX25PeerState.DISCONNECTED)
+
+    def _negotiate(self, callback):
+        """
+        Undertake negotiation with the peer station.
+        """
+        # Sanity check, ensure we are in the CONNECTING state
+        assert self._state is AX25PeerState.CONNECTING
+
+        # Sanity check, don't call this if we know the station won't take it.
+        if self._protocol not in (AX25Version.UNKNOWN, AX25Version.AX25_22):
+            raise RuntimeError(
+                "%s does not support negotiation" % self._protocol.value
+            )
+
+        self._log.debug("Attempting protocol negotiation")
+        handler = AX25PeerNegotiationHandler(self)
+        handler.done_sig.connect(self._on_negotiate_result)
+        handler.done_sig.connect(callback)
+
+        handler._go()
+        self._set_conn_state(AX25PeerState.NEGOTIATING)
+        return
+
+    def _on_negotiate_result(self, response, **kwargs):
+        """
+        Handle the negotiation response.
+        """
+        self._log.debug("Negotiation response: %r", response)
+        if response in ("frmr", "dm"):
+            # Other station did not like this.
+            self._log.info(
+                "Failed XID negotiation, loading AX.25 2.0 defaults"
+            )
+            self._process_xid_cop(AX25_20_DEFAULT_XID_COP)
+            self._process_xid_hdlcoptfunc(AX25_20_DEFAULT_XID_HDLCOPTFUNC)
+            self._process_xid_ifieldlenrx(AX25_20_DEFAULT_XID_IFIELDRX)
+            self._process_xid_winszrx(AX25_20_DEFAULT_XID_WINDOWSZRX)
+            self._process_xid_acktimer(AX25_20_DEFAULT_XID_ACKTIMER)
+            self._process_xid_retrycounter(AX25_20_DEFAULT_XID_RETRIES)
+
+            # Downgrade 2.2 to 2.0, do not unwittingly "upgrade" 1.0 to 2.0!
+            if self._protocol in (AX25Version.UNKNOWN, AX25Version.AX25_22):
+                self._log.info("Downgrading to AX.25 2.0 due to failed XID")
+                self._protocol = AX25Version.AX25_20
+
+            # AX.25 2.2 is required for Mod128, so if we get FRMR here,
+            # disable this unconditionally.
+            self._modulo128 = False
+        elif self._protocol != AX25Version.AX25_22:
+            # Clearly this station understands AX.25 2.2
+            self._log.info("Upgrading to AX.25 2.2 due to successful XID")
+            self._protocol = AX25Version.AX25_22
+
+        self._negotiated = True
+        self._log.debug("XID negotiation complete, resume connection")
+        self._set_conn_state(AX25PeerState.CONNECTING)
+
+    def _init_connection(self, extended):
+        """
+        Initialise the AX.25 connection.
+        """
+        if extended:
+            # Set the maximum outstanding frames variable
+            self._log.debug("Initialising AX.25 2.2 mod128 connection")
+            self._max_outstanding = self._max_outstanding_mod128
+
+            # Initialise the modulo value
+            self._modulo = 128
+
+            # Set the classes to use for I and S frames for modulo128 ops.
+            self._IFrameClass = AX2516BitInformationFrame
+            self._RRFrameClass = AX2516BitReceiveReadyFrame
+            self._RNRFrameClass = AX2516BitReceiveNotReadyFrame
+            self._REJFrameClass = AX2516BitRejectFrame
+            self._SREJFrameClass = AX2516BitSelectiveRejectFrame
+        else:
+            self._log.debug("Initialising AX.25 2.0/2.2 mod8 connection")
+            # Set the maximum outstanding frames variable
+            self._max_outstanding = self._max_outstanding_mod8
+
+            # Initialise the modulo value
+            self._modulo = 8
+
+            # Set the classes to use for I and S frames for modulo8 ops.
+            self._IFrameClass = AX258BitInformationFrame
+            self._RRFrameClass = AX258BitReceiveReadyFrame
+            self._RNRFrameClass = AX258BitReceiveNotReadyFrame
+            self._REJFrameClass = AX258BitRejectFrame
+            self._SREJFrameClass = AX258BitSelectiveRejectFrame
+
+        # Reset state variables
+        self._reset_connection_state()
+
+    def _reset_connection_state(self):
+        self._log.debug("Resetting the peer state")
+
+        # Reset our state
+        self._update_state("_send_state", value=0, comment="reset")
+        self._update_state("_send_seq", value=0, comment="reset")
+        self._update_state("_recv_state", value=0, comment="reset")
+        self._update_state("_recv_seq", value=0, comment="reset")
+        self._update_state("_ack_state", value=0, comment="reset")
+
+        # Unacknowledged I-frames to be ACKed
+        self._pending_iframes = {}
+
+        # Data pending to be sent.
+        self._pending_data = []
+
+    def _set_conn_state(self, state):
+        if self._state is state:
+            # Nothing to do
+            return
+
+        # Update the state
+        self._log.info("Connection state change: %s→%s", self._state, state)
+        self._state = state
+
+        # Notify any observers that the state changed
+        self.connect_state_changed.emit(
+            station=self._station(), peer=self, state=self._state
+        )
+
+    def _on_disc_ua_timeout(self):
+        if self._state is AX25PeerState.DISCONNECTING:
+            self._log.info("DISC timed out, assuming we're disconnected")
+            # Assume we are disconnected.
+            self._ack_timeout_handle = None
+            self._on_disconnect()
+        else:
+            self._log.debug("DISC time-out in state %s", self._state.name)
+
+    def _on_disconnect(self):
+        """
+        Clean up the connection.
+        """
+        self._log.info("Disconnected from peer")
+
+        # Cancel disconnect timers
+        self._stop_ack_timer()
+
+        # Set ourselves as disconnected
+        self._set_conn_state(AX25PeerState.DISCONNECTED)
+
+        # Reset our state
+        self._reset_connection_state()
+
+        # Clear data pending to be sent.
+        self._pending_data = []
+
+    def _on_receive_disc(self):
+        """
+        Handle a disconnect request from this peer.
+        """
+        # Send a UA and set ourselves as disconnected
+        self._log.info("Received DISC from peer")
+        self._send_ua()
+        self._on_disconnect()
+
+    def _on_receive_ua(self):
+        """
+        Handle a Un-numbered Acknowledge from the peer
+        """
+        self._log.info("Received UA from peer")
+        if self._uaframe_handler:
+            self._uaframe_handler()
+        else:
+            self._log.debug("No one cares about the UA")
+
+    def _on_receive_dm(self):
+        """
+        Handle a disconnect request from this peer.
+        """
+        if self._state is AX25PeerState.CONNECTED:
+            # Set ourselves as disconnected
+            self._log.info("Received DM from peer")
+            self._on_disconnect()
+        elif self._dmframe_handler:
+            self._log.debug(
+                "Received DM from peer whilst in state %s", self._state.name
+            )
+            self._dmframe_handler()
+            self._dmframe_handler = None
+        else:
+            self._log.debug(
+                "No one cares about the DM in state %s", self._state.name
+            )
+
+    def _on_receive_xid(self, frame):
+        """
+        Handle a request to negotiate parameters.
+        """
+        self._log.info("Received XID from peer")
+        if self._station()._protocol != AX25Version.AX25_22:
+            # Not supporting this in AX.25 2.0 mode
+            self._log.warning(
+                "Received XID from peer, we are not in AX.25 2.2 mode"
+            )
+            return self._send_frmr(frame, w=True)
+
+        if self._state in (
+            AX25PeerState.CONNECTING,
+            AX25PeerState.DISCONNECTING,
+        ):
+            # AX.25 2.2 sect 4.3.3.7: "A station receiving an XID command
+            # returns an XID response unless a UA response to a mode setting
+            # command is awaiting transmission, or a FRMR condition exists".
+            self._log.warning(
+                "UA is pending, dropping received XID (state %s)",
+                self._state.name,
+            )
+            return
+
+        # We have received an XID, AX.25 2.0 and earlier stations do not know
+        # this frame, so clearly this is at least AX.25 2.2.
+        if self._protocol == AX25Version.UNKNOWN:
+            self._log.info("Assuming AX.25 2.2 peer")
+            self._protocol = AX25Version.AX25_22
+
+        # Don't process the contents of the frame unless FI and GI match.
+        if (frame.fi == frame.FI) and (frame.gi == frame.GI):
+            # Key these by PI
+            params = dict([(p.pi, p) for p in frame.parameters])
+
+            # Process the parameters in this order
+
+            self._process_xid_cop(
+                params.get(
+                    AX25XIDParameterIdentifier.ClassesOfProcedure,
+                    AX25_22_DEFAULT_XID_COP,
+                )
+            )
+
+            self._process_xid_hdlcoptfunc(
+                params.get(
+                    AX25XIDParameterIdentifier.HDLCOptionalFunctions,
+                    AX25_22_DEFAULT_XID_HDLCOPTFUNC,
+                )
+            )
+
+            self._process_xid_ifieldlenrx(
+                params.get(
+                    AX25XIDParameterIdentifier.IFieldLengthReceive,
+                    AX25_22_DEFAULT_XID_IFIELDRX,
+                )
+            )
+
+            self._process_xid_winszrx(
+                params.get(
+                    AX25XIDParameterIdentifier.WindowSizeReceive,
+                    AX25_22_DEFAULT_XID_WINDOWSZRX,
+                )
+            )
+
+            self._process_xid_acktimer(
+                params.get(
+                    AX25XIDParameterIdentifier.AcknowledgeTimer,
+                    AX25_22_DEFAULT_XID_ACKTIMER,
+                )
+            )
+
+            self._process_xid_retrycounter(
+                params.get(
+                    AX25XIDParameterIdentifier.Retries,
+                    AX25_22_DEFAULT_XID_RETRIES,
+                )
+            )
+
+        if frame.header.cr:
+            # Other station is requesting negotiation, send response.
+            self._log.debug("Sending XID response")
+            self._send_xid(cr=False)
+        elif self._xidframe_handler is not None:
+            # This is a reply to our XID
+            self._log.debug("Forwarding XID response")
+            self._xidframe_handler(frame)
+            self._xidframe_handler = None
+        else:
+            # No one cared?
+            self._log.debug("Received XID response but no one cares")
+
+        # Having received the XID, we consider ourselves having negotiated
+        # parameters.  Future connections will skip this step.
+        self._negotiated = True
+        self._log.debug("XID negotiation complete")
+
+    def _process_xid_cop(self, param):
+        if param.pv is None:
+            # We were told to use defaults.  This is from a XID frame,
+            # so assume AX.25 2.2 defaults.
+            self._log.debug("XID: Assuming default Classes of Procedure")
+            param = AX25_22_DEFAULT_XID_COP
+
+        # Ensure we don't confuse ourselves if the station sets both
+        # full-duplex and half-duplex bits.  Half-duplex is always a
+        # safe choice in case of such confusion.
+        self._full_duplex = (
+            self._full_duplex
+            and param.full_duplex
+            and (not param.half_duplex)
+        )
+        self._log.debug("XID: Setting full-duplex: %s", self._full_duplex)
+
+    def _process_xid_hdlcoptfunc(self, param):
+        if param.pv is None:
+            # We were told to use defaults.  This is from a XID frame,
+            # so assume AX.25 2.2 defaults.
+            self._log.debug("XID: Assuming default HDLC Optional Features")
+            param = AX25_22_DEFAULT_XID_HDLCOPTFUNC
+
+        # Negotiable parts of this parameter are:
+        # - SREJ/REJ bits
+        if param.srej and param.rej:
+            reject_mode = AX25RejectMode.SELECTIVE_RR
+        elif param.srej:
+            reject_mode = AX25RejectMode.SELECTIVE
+        else:
+            # Technically this means also the invalid SREJ=0 REJ=0,
+            # we'll assume they meant REJ=1 in that case.
+            reject_mode = AX25RejectMode.IMPLICIT
+
+        # We take the option with the lowest precedence
+        if self._reject_mode.precedence > reject_mode.precedence:
+            self._reject_mode = reject_mode
+        self._log.debug("XID: Set reject mode: %s", self._reject_mode.value)
+
+        # - Modulo 8/128: again, unless the station positively says
+        #   "I support modulo 128", use modulo 8.
+        #   The remote station is meant to set either modulo128 *OR* modulo8.
+        #   If we have it enabled our end, and they either have the modulo8
+        #   bit set, or the modulo128 bit clear, then fail back to modulo8.
+        if self._modulo128 and ((not param.modulo128) or param.modulo8):
+            self._modulo128 = False
+        self._log.debug("XID: Set modulo128 mode: %s", self._modulo128)
+
+    def _process_xid_ifieldlenrx(self, param):
+        if param.pv is None:
+            # We were told to use defaults.  This is from a XID frame,
+            # so assume AX.25 2.2 defaults.
+            self._log.debug("XID: Assuming default I-Field Receive Length")
+            param = AX25_22_DEFAULT_XID_IFIELDRX
+
+        self._max_ifield = min(
+            [self._max_ifield, int(param.value / 8)]  # Value is given in bits
+        )
+        self._log.debug(
+            "XID: Setting I-Field Receive Length: %d", self._max_ifield
+        )
+
+    def _process_xid_winszrx(self, param):
+        if param.pv is None:
+            # We were told to use defaults.  This is from a XID frame,
+            # so assume AX.25 2.2 defaults.
+            self._log.debug("XID: Assuming default Window Size Receive")
+            param = AX25_22_DEFAULT_XID_WINDOWSZRX
+
+        self._max_outstanding = min(
+            [
+                (
+                    self._max_outstanding_mod128
+                    if self._modulo128
+                    else self._max_outstanding_mod8
+                ),
+                param.value,
+            ]
+        )
+        self._log.debug(
+            "XID: Setting Window Size Receive: %d", self._max_outstanding
+        )
+
+    def _process_xid_acktimer(self, param):
+        if param.pv is None:
+            # We were told to use defaults.  This is from a XID frame,
+            # so assume AX.25 2.2 defaults.
+            self._log.debug("XID: Assuming default ACK timer")
+            param = AX25_22_DEFAULT_XID_ACKTIMER
+
+        self._ack_timeout = (
+            max([self._ack_timeout * 1000, param.value]) / 1000
+        )
+        self._log.debug(
+            "XID: Setting ACK timeout: %.3f sec", self._ack_timeout
+        )
+
+    def _process_xid_retrycounter(self, param):
+        if param.pv is None:
+            # We were told to use defaults.  This is from a XID frame,
+            # so assume AX.25 2.2 defaults.
+            self._log.debug("XID: Assuming default retry limit")
+            param = AX25_22_DEFAULT_XID_RETRIES
+
+        self._max_retries = max([self._max_retries, param.value])
+        self._log.debug("XID: Setting retry limit: %d", self._max_retries)
+
+    def _send_sabm(self):
+        """
+        Send a SABM(E) frame to the remote station.
+        """
+        self._log.info("Sending SABM%s", "E" if self._modulo128 else "")
+        SABMClass = (
+            AX25SetAsyncBalancedModeExtendedFrame
+            if self._modulo128
+            else AX25SetAsyncBalancedModeFrame
+        )
+
+        self._transmit_frame(
+            SABMClass(
+                destination=self.address.normcopy(ch=True),
+                source=self._station().address.normcopy(ch=False),
+                repeaters=self.reply_path,
+            )
+        )
+        if self._state is not AX25PeerState.INCOMING_CONNECTION:
+            self._set_conn_state(AX25PeerState.CONNECTING)
+
+    def _send_xid(self, cr):
+        self._transmit_frame(
+            AX25ExchangeIdentificationFrame(
+                destination=self.address.normcopy(ch=True),
+                source=self._station().address.normcopy(ch=False),
+                repeaters=self.reply_path,
+                parameters=[
+                    AX25XIDClassOfProceduresParameter(
+                        half_duplex=not self._full_duplex,
+                        full_duplex=self._full_duplex,
+                    ),
+                    AX25XIDHDLCOptionalFunctionsParameter(
+                        rej=(
+                            self._reject_mode
+                            in (
+                                AX25RejectMode.IMPLICIT,
+                                AX25RejectMode.SELECTIVE_RR,
+                            )
+                        ),
+                        srej=(
+                            self._reject_mode
+                            in (
+                                AX25RejectMode.SELECTIVE,
+                                AX25RejectMode.SELECTIVE_RR,
+                            )
+                        ),
+                        modulo8=(not self._modulo128),
+                        modulo128=(self._modulo128),
+                    ),
+                    AX25XIDIFieldLengthTransmitParameter(
+                        self._max_ifield * 8
+                    ),
+                    AX25XIDIFieldLengthReceiveParameter(
+                        self._max_ifield_rx * 8
+                    ),
+                    AX25XIDWindowSizeTransmitParameter(
+                        self._max_outstanding_mod128
+                        if self._modulo128
+                        else self._max_outstanding_mod8
+                    ),
+                    AX25XIDWindowSizeReceiveParameter(
+                        self._max_outstanding_mod128
+                        if self._modulo128
+                        else self._max_outstanding_mod8
+                    ),
+                    AX25XIDAcknowledgeTimerParameter(
+                        int(self._ack_timeout * 1000)
+                    ),
+                    AX25XIDRetriesParameter(self._max_retries),
+                ],
+                cr=cr,
+            )
+        )
+
+    def _send_dm(self):
+        """
+        Send a DM frame to the remote station.
+        """
+        self._log.info("Sending DM")
+        self._transmit_frame(
+            AX25DisconnectModeFrame(
+                destination=self.address.normcopy(ch=True),
+                source=self._station().address.normcopy(ch=False),
+                repeaters=self.reply_path,
+            )
+        )
+
+    def _send_disc(self):
+        """
+        Send a DISC frame to the remote station.
+        """
+        self._log.info("Sending DISC")
+        self._transmit_frame(
+            AX25DisconnectFrame(
+                destination=self.address.normcopy(ch=True),
+                source=self._station().address.normcopy(ch=False),
+                repeaters=self.reply_path,
+            )
+        )
+
+    def _send_ua(self):
+        """
+        Send a UA frame to the remote station.
+        """
+        self._log.info("Sending UA")
+        self._transmit_frame(
+            AX25UnnumberedAcknowledgeFrame(
+                destination=self.address.normcopy(ch=True),
+                source=self._station().address.normcopy(ch=False),
+                repeaters=self.reply_path,
+            )
+        )
+
+    def _send_frmr(self, frame, w=False, x=False, y=False, z=False):
+        """
+        Send a FRMR frame to the remote station.
+        """
+        self._log.debug("Sending FRMR in reply to %s", frame)
+
+        # AX.25 2.0 sect 2.4.5: "After sending the FRMR frame, the sending DXE
+        # will enter the frame reject condition. This condition is cleared when
+        # the DXE that sent the FRMR frame receives a SABM or DISC command, or
+        # a DM response frame. Any other command received while the DXE is in
+        # the frame reject state will cause another FRMR to be sent out with
+        # the same information field as originally sent."
+        self._set_conn_state(AX25PeerState.FRMR)
+
+        # See https://www.tapr.org/pub_ax25.html
+        self._transmit_frame(
+            AX25FrameRejectFrame(
+                destination=self.address.normcopy(ch=True),
+                source=self._station().address.normcopy(ch=False),
+                repeaters=self.reply_path,
+                w=w,
+                x=x,
+                y=y,
+                z=z,
+                vr=self._recv_state,
+                vs=self._send_state,
+                frmr_cr=frame.header.cr,
+                frmr_control=frame.control,
+            )
+        )
+
+    def _cancel_rr_notification(self):
+        """
+        Cancel transmission of an RR
+        """
+        if self._rr_notification_timeout_handle is not None:
+            self._rr_notification_timeout_handle.cancel()
+            self._rr_notification_timeout_handle = None
+
+    def _schedule_rr_notification(self):
+        """
+        Schedule a RR notification frame to be sent.
+        """
+        self._log.debug("Waiting before sending RR notification")
+        self._cancel_rr_notification()
+        self._rr_notification_timeout_handle = self._loop.call_later(
+            self._rr_delay, self._send_rr_notification
+        )
+
+    def _send_rr_notification(self):
+        """
+        Send a RR notification frame
+        """
+        # "If there are no outstanding I frames, the receiving device will
+        # send a RR frame with N(R) equal to V(R). The receiving DXE may wait
+        # a small period of time before sending the RR frame to be sure
+        # additional I frames are not being transmitted."
+
+        self._cancel_rr_notification()
+        if self._state is AX25PeerState.CONNECTED:
+            self._log.debug(
+                "Sending RR with N(R) == V(R) == %d", self._recv_state
+            )
+            self._update_recv_seq()
+            self._transmit_frame(
+                self._RRFrameClass(
+                    destination=self.address.normcopy(ch=True),
+                    source=self._station().address.normcopy(ch=False),
+                    repeaters=self.reply_path,
+                    pf=False,
+                    nr=self._recv_state,
+                )
+            )
+
+    def _send_rnr_notification(self):
+        """
+        Send a RNR notification if the RNR interval has elapsed.
+        """
+        if self._state is AX25PeerState.CONNECTED:
+            now = self._loop.time()
+            if (now - self._last_rnr_sent) > self._rnr_interval:
+                self._update_recv_seq()
+                self._transmit_frame(
+                    self._RNRFrameClass(
+                        destination=self.address.normcopy(ch=True),
+                        source=self._station().address.normcopy(ch=False),
+                        repeaters=self.reply_path,
+                        nr=self._recv_seq,
+                        pf=False,
+                    )
+                )
+                self._last_rnr_sent = now
+
+    def _send_next_iframe(self):
+        """
+        Send the next I-frame, if there aren't too many frames pending.
+        """
+        if len(self._pending_iframes) >= self._max_outstanding:
+            self._log.debug("Waiting for pending I-frames to be ACKed")
+            return
+
+        # AX.25 2.2 spec 6.4.1: "Whenever a TNC has an I frame to transmit,
+        # it sends the I frame with the N(S) of the control field equal to
+        # its current send state variable V(S)…"
+        ns = self._send_state
+        if ns not in self._pending_iframes:
+            if not self._pending_data:
+                # No data waiting
+                self._log.debug("No data pending transmission")
+                return
+
+            # Retrieve the next pending I-frame payload and add it to the queue
+            self._pending_iframes[ns] = self._pending_data.pop(0)
+            self._log.debug("Creating new I-Frame %d", ns)
+
+        # Send it
+        self._log.debug("Sending new I-Frame %d", ns)
+        self._transmit_iframe(ns)
+
+        # "After the I frame is sent, the send state variable is incremented
+        # by one."
+        self._update_state(
+            "_send_state", delta=1, comment="send next I-frame"
+        )
+
+        if self._log.isEnabledFor(logging.DEBUG):
+            self._log.debug("Pending frames: %r", self._pending_iframes)
+
+    def _transmit_iframe(self, ns):
+        """
+        Transmit the I-frame identified by the given N(S) parameter.
+        """
+        # "Whenever a DXE has an I frame to transmit, it will send the I frame
+        # with N(S) of the control field equal to its current send state
+        # variable V(S). Once the I frame is sent, the send state variable is
+        # incremented by one. If timer T1 is not running, it should be
+        # started. If timer T1 is running, it should be restarted."
+
+        # "If it has an I frame to send, that I frame may be sent with the
+        # transmitted N(R) equal to its receive state variable V(R) (thus
+        # acknowledging the received frame)."
+        (pid, payload) = self._pending_iframes[ns]
+        self._log.debug(
+            "Sending I-frame N(R)=%d N(S)=%d PID=0x%02x Payload=%r",
+            self._recv_state,
+            ns,
+            pid,
+            payload,
+        )
+
+        self._update_send_seq()
+        self._update_recv_seq()
+
+        self._transmit_frame(
+            self._IFrameClass(
+                destination=self.address.normcopy(ch=True),
+                source=self._station().address.normcopy(ch=False),
+                repeaters=self.reply_path,
+                nr=self._recv_state,  # N(R) == V(R)
+                ns=ns,
+                pf=False,
+                pid=pid,
+                payload=payload,
+            )
+        )
+
+    def _transmit_frame(self, frame, callback=None):
+        self.sent_frame.emit(frame=frame, peer=self)
+
+        # Update the last activity timestamp
+        self._last_act = self._loop.time()
+
+        # Reset the idle timer
+        self._reset_idle_timeout()
+        return self._station()._interface().transmit(frame, callback=None)
+
+    def _update_state(self, prop, delta=None, value=None, comment=""):
+        if comment:
+            comment = " " + comment
+
+        if value is None:
+            value = getattr(self, prop)
+
+        if delta is not None:
+            value += delta
+            comment += " delta=%s" % delta
+
+        # Always apply modulo op (assume 8 if not set)
+        value %= self._modulo or 8
+
+        self._log.debug(
+            "%s = %s" + comment, getattr(self, "%s_name" % prop), value
+        )
+        setattr(self, prop, value)
+
+    def _update_send_seq(self):
+        """
+        Update the send sequence.  Call this just prior to sending an
+        I-frame.
+        """
+        # "Just prior to the transmission of the I frame, N(S) is updated to
+        # equal the send state variable." § 2.3.2.4.2
+        # _send_seq (aka N(S)) ← _send_state (aka V(S))
+        self._update_state(
+            "_send_seq", value=self._send_state, comment="from V(S)"
+        )
+
+    def _update_recv_seq(self):
+        """
+        Update the send sequence.  Call this just prior to sending an
+        I-frame or S-frame.
+        """
+        # "Prior to sending an I or S frame, this variable is updated to equal
+        # that of the received state variable" § 2.3.2.4.4
+        # _recv_seq (aka N(R)) ← _recv_state (aka V(R))
+        self._update_state(
+            "_recv_seq", value=self._recv_state, comment="from V(R)"
+        )
+
+
+class AX25PeerHelper(object):
+    """
+    This is a base class for handling more complex acts like connecting,
+    negotiating parameters or sending test frames.
+    """
+
+    # Class-level counter so we can differentiate instances
+    _IDX = 0
+
+    def __init__(self, peer, timeout):
+        self._peer = peer
+        self._loop = peer._loop
+        self._log = peer._log.getChild(
+            "%s-%d" % (self.__class__.__name__, self.__class__._IDX)
+        )
+        self.__class__._IDX += 1
+        self._done = False
+        self._timeout = timeout
+        self._timeout_handle = None
+
+        # Signal on "done" or time-out.
+        self.done_sig = Signal()
+
+    @property
+    def peer(self):
+        """
+        Return the peer being pinged
+        """
+        return self._peer
+
+    def _start_timer(self):
+        self._timeout_handle = self._loop.call_later(
+            self._timeout, self._on_timeout
+        )
+        self._log.debug("time-out timer started")
+
+    def _stop_timer(self):
+        if self._timeout_handle is None:
+            self._log.debug("no time-out timer to cancel")
+            return
+
+        self._timeout_handle.cancel()
+        self._timeout_handle = None
+        self._log.debug("time-out timer cancelled")
+
+    def _finish(self, **kwargs):
+        if self._done:
+            return
+
+        self._done = True
+        self._stop_timer()
+        self._log.debug("finished: %r", kwargs)
+        self.done_sig.emit(**kwargs)
+
+
+class AX25PeerConnectionHandler(AX25PeerHelper):
+    """
+    This class is used to manage the connection to the peer station.  If the
+    station has not yet negotiated with the peer, this is done (unless we know
+    the peer won't tolerate it), then a SABM or SABME connection is made.
+    """
+
+    def __init__(self, peer):
+        super(AX25PeerConnectionHandler, self).__init__(
+            peer, peer._ack_timeout
+        )
+        self._retries = peer._max_retries
+
+    def _go(self):
+        if self.peer._negotiated:
+            # Already done, we can connect immediately
+            self._log.debug("XID negotiation already done")
+            self._on_negotiated(response="already")
+        elif (
+            self.peer._protocol
+            not in (AX25Version.AX25_22, AX25Version.UNKNOWN)
+        ) or (self.peer._station()._protocol != AX25Version.AX25_22):
+            # Not compatible, just connect
+            self._log.debug("XID negotiation not supported")
+            self._on_negotiated(response="not_compatible")
+        else:
+            # Need to negotiate first.
+            self._log.debug("XID negotiation to be attempted")
+            self.peer._negotiate(self._on_negotiated)
+
+    def _on_negotiated(self, response, **kwargs):
+        if response in (
+            "xid",
+            "frmr",
+            "dm",
+            "already",
+            "not_compatible",
+            "retry",
+        ):
+            # We successfully negotiated with this station (or it was not
+            # required)
+            if (
+                (self.peer._uaframe_handler is not None)
+                or (self.peer._frmrframe_handler is not None)
+                or (self.peer._dmframe_handler is not None)
+            ):
+                # We're handling another frame now.
+                self._log.debug("Received XID, but we're busy")
+                self._finish(response="station_busy")
+                return
+
+            self._log.debug(
+                "XID done (state %s), beginning connection", response
+            )
+            self.peer._uaframe_handler = self._on_receive_ua
+            self.peer._frmrframe_handler = self._on_receive_frmr
+            self.peer._dmframe_handler = self._on_receive_dm
+            self.peer._send_sabm()
+            self._start_timer()
+        else:
+            self._log.debug("Bailing out due to XID response %s", response)
+            self._finish(response=response)
+
+    def _on_receive_ua(self):
+        # Peer just acknowledged our connection
+        self._log.debug("UA received")
+        self._log.info("Connection is established")
+        self.peer._init_connection(self.peer._modulo128)
+        self._finish(response="ack")
+
+    def _on_receive_frmr(self):
+        # Peer just rejected our connect frame, begin FRMR recovery.
+        self._log.debug("FRMR received, recovering")
+        self.peer._send_dm()
+        self._finish(response="frmr")
+
+    def _on_receive_dm(self):
+        # Peer just rejected our connect frame.
+        self._log.debug("DM received, bailing here")
+        self._finish(response="dm")
+
+    def _on_timeout(self):
+        self._unhook()
+        if self._retries:
+            self._retries -= 1
+            self._log.debug("Retrying, remaining=%d", self._retries)
+            self._on_negotiated(response="retry")
+        else:
+            self._log.debug("Giving up")
+            self._finish(response="timeout")
+
+    def _unhook(self):
+        if self.peer._uaframe_handler == self._on_receive_ua:
+            self._log.debug("Unhooking UA handler")
+            self.peer._uaframe_handler = None
+
+        if self.peer._frmrframe_handler == self._on_receive_frmr:
+            self._log.debug("Unhooking FRMR handler")
+            self.peer._frmrframe_handler = None
+
+        if self.peer._dmframe_handler == self._on_receive_dm:
+            self._log.debug("Unhooking DM handler")
+            self.peer._dmframe_handler = None
+
+    def _finish(self, **kwargs):
+        self._unhook()
+        super(AX25PeerConnectionHandler, self)._finish(**kwargs)
+
+
+class AX25PeerNegotiationHandler(AX25PeerHelper):
+    """
+    This class is used to manage the negotiation of link parameters with the
+    peer.  Notably, if the peer is an AX.25 2.0, this loads defaults for that
+    revision of AX.25 and handles the FRMR/DM condition.
+    """
+
+    def __init__(self, peer):
+        super(AX25PeerNegotiationHandler, self).__init__(
+            peer, peer._ack_timeout
+        )
+        self._retries = peer._max_retries
+
+    def _go(self):
+        # Specs say AX.25 2.2 should respond with XID and 2.0 should respond
+        # with FRMR.  It is also possible we could get a DM as some buggy AX.25
+        # implementations respond with that in reply to unknown frames.
+        if (
+            (self.peer._xidframe_handler is not None)
+            or (self.peer._frmrframe_handler is not None)
+            or (self.peer._dmframe_handler is not None)
+        ):
+            raise RuntimeError("Another frame handler is busy")
+
+        self.peer._xidframe_handler = self._on_receive_xid
+        self.peer._frmrframe_handler = self._on_receive_frmr
+        self.peer._dmframe_handler = self._on_receive_dm
+
+        self.peer._send_xid(cr=True)
+        self._start_timer()
+
+    def _on_receive_xid(self, *args, **kwargs):
+        # XID frame received, we can consider ourselves done.
+        self._finish(response="xid")
+
+    def _on_receive_frmr(self, *args, **kwargs):
+        # FRMR received.  Evidently this station does not like XID.  Caller
+        # will need to kiss and make up with the offended legacy station either
+        # with a SABM or DM.  We can be certain this is not an AX.25 2.2 station.
+        self._finish(response="frmr")
+
+    def _on_receive_dm(self, *args, **kwargs):
+        # DM received.  This is not strictly in spec, but we'll treat it as a
+        # legacy AX.25 station telling us we're disconnected.  No special
+        # handling needed.
+        self._finish(response="dm")
+
+    def _on_timeout(self):
+        # No response received
+        if self._retries:
+            self._retries -= 1
+            self._go()
+        else:
+            self._finish(response="timeout")
+
+    def _finish(self, **kwargs):
+        # Clean up hooks
+        if self.peer._xidframe_handler == self._on_receive_xid:
+            self.peer._xidframe_handler = None
+        if self.peer._frmrframe_handler == self._on_receive_frmr:
+            self.peer._frmrframe_handler = None
+        if self.peer._dmframe_handler == self._on_receive_dm:
+            self.peer._dmframe_handler = None
+        super(AX25PeerNegotiationHandler, self)._finish(**kwargs)
+
+
+class AX25PeerTestHandler(AX25PeerHelper):
+    """
+    This class is used to manage the sending of a TEST frame to the peer
+    station and receiving the peer reply.  Round-trip time is made available
+    in case the calling application needs it.
+    """
+
+    def __init__(self, peer, payload, timeout):
+        super(AX25PeerTestHandler, self).__init__(peer, timeout)
+
+        # Create the frame to send
+        self._tx_frame = AX25TestFrame(
+            destination=peer.address,
+            source=peer._station().address,
+            repeaters=peer._station().reply_path,
+            payload=payload,
+            cr=True,
+        )
+
+        # Store the received frame here
+        self._rx_frame = None
+
+        # Time of transmission
+        self._tx_time = None
+
+        # Time of reception
+        self._rx_time = None
+
+    @property
+    def tx_time(self):
+        """
+        Return the transmit time, as measured by the IO loop
+        """
+        return self._tx_time
+
+    @property
+    def tx_frame(self):
+        """
+        Return the transmitted frame
+        """
+        return self._tx_frame
+
+    @property
+    def rx_time(self):
+        """
+        Return the receive time, as measured by the IO loop
+        """
+        return self._rx_time
+
+    @property
+    def rx_frame(self):
+        """
+        Return the received frame
+        """
+        return self._rx_frame
+
+    def _go(self):
+        """
+        Start the transmission.
+        """
+        if self.peer._testframe_handler is not None:
+            raise RuntimeError("Test frame already pending")
+        self.peer._testframe_handler = weakref.ref(self)
+        self.peer._transmit_frame(self.tx_frame, callback=self._transmit_done)
+        self._start_timer()
+
+    def _transmit_done(self, *args, **kwargs):
+        """
+        Note the time that the transmission took place.
+        """
+        self._tx_time = self.peer._loop.time()
+
+    def _on_receive(self, frame, **kwargs):
+        """
+        Process the incoming frame.
+        """
+        if self._done:
+            # We are done
+            return
+
+        # Stop the clock!
+        self._rx_time = self.peer._loop.time()
+
+        # Stash the result and notify the caller
+        self._rx_frame = frame
+        self._finish(handler=self)
+
+    def _on_timeout(self):
+        """
+        Process a time-out
+        """
+        self._finish(handler=self)
diff --git a/aioax25/router.py b/aioax25/router.py
index 642273c..67380ae 100644
--- a/aioax25/router.py
+++ b/aioax25/router.py
@@ -53,6 +53,13 @@ def bind(self, callback, callsign, ssid=0, regex=False):
         else:
             call_receivers = self._receiver_str.setdefault(callsign, {})
 
+        self._log.debug(
+            "Binding callsign %r (regex %r) SSID %r to %r",
+            callsign,
+            regex,
+            ssid,
+            callback,
+        )
         call_receivers.setdefault(ssid, []).append(callback)
 
     def unbind(self, callback, callsign, ssid=0, regex=False):
@@ -73,6 +80,13 @@ def unbind(self, callback, callsign, ssid=0, regex=False):
 
         try:
             ssid_receivers.remove(callback)
+            self._log.debug(
+                "Unbound callsign %r (regex %r) SSID %r to %r",
+                callsign,
+                regex,
+                ssid,
+                callback,
+            )
         except ValueError:
             return
 
diff --git a/aioax25/station.py b/aioax25/station.py
new file mode 100644
index 0000000..f4a84b9
--- /dev/null
+++ b/aioax25/station.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+
+"""
+AX.25 Station interface.
+
+This implements the base-level AX.25 logic for a station listening at a given
+SSID.
+"""
+
+
+import logging
+import asyncio
+from .signal import Signal
+import weakref
+
+from .frame import AX25Address, AX25Path, AX25TestFrame
+
+from .peer import AX25Peer, AX25RejectMode
+from .version import AX25Version
+
+
+class AX25Station(object):
+    """
+    The AX25Station class represents the station on the AX.25 network
+    implemented by the caller of this library.  Notably, it provides
+    hooks for handling incoming connections, and methods for making
+    connections to other AX.25 stations.
+
+    To be able to participate as a connected-mode station, create an instance
+    of AX25Station, referencing an instance of AX25Interface as the interface
+    parameter; then call the attach method.
+    """
+
+    def __init__(
+        self,
+        interface,
+        # Station call-sign and SSID
+        callsign,
+        ssid=None,
+        # Classes of Procedures options
+        full_duplex=False,
+        # HDLC Optional Functions
+        modulo128=False,  # Whether to use Mod128 by default
+        reject_mode=AX25RejectMode.SELECTIVE_RR,
+        # What reject mode to use?
+        # Parameters (AX.25 2.2 sect 6.7.2)
+        max_ifield=256,  # aka N1
+        max_ifield_rx=256,  # the N1 we advertise in XIDs
+        max_retries=10,  # aka N2, value from figure 4.5
+        # k value, for mod128 and mod8 connections, this sets the
+        # advertised window size in XID.  Peer station sets actual
+        # value used here.
+        max_outstanding_mod8=7,
+        max_outstanding_mod128=127,
+        # Timer parameters
+        ack_timeout=3.0,  # Acknowledge timeout (aka T1)
+        idle_timeout=900.0,  # Idle timeout before we "forget" peers
+        rr_delay=3.0,  # Delay between I-frame and RR
+        rr_interval=30.0,  # Poll interval when peer in busy state
+        rnr_interval=10.0,  # Delay between RNRs when busy
+        # Protocol version to use for our station
+        protocol=AX25Version.AX25_22,
+        # IOLoop and logging
+        log=None,
+        loop=None,
+    ):
+        if log is None:
+            log = logging.getLogger(self.__class__.__module__)
+
+        if loop is None:
+            loop = asyncio.get_event_loop()
+
+        # Ensure we are running a supported version of AX.25
+        protocol = AX25Version(protocol)
+        if protocol not in (AX25Version.AX25_20, AX25Version.AX25_22):
+            raise ValueError(
+                "%r not a supported AX.25 protocol version" % protocol.value
+            )
+
+        # Configuration parameters
+        self._address = AX25Address.decode(callsign, ssid).normalised
+        self._interface = weakref.ref(interface)
+        self._protocol = protocol
+        self._ack_timeout = ack_timeout
+        self._idle_timeout = idle_timeout
+        self._reject_mode = AX25RejectMode(reject_mode)
+        self._modulo128 = modulo128
+        self._max_ifield = max_ifield
+        self._max_ifield_rx = max_ifield_rx
+        self._max_retries = max_retries
+        self._max_outstanding_mod8 = max_outstanding_mod8
+        self._max_outstanding_mod128 = max_outstanding_mod128
+        self._rr_delay = rr_delay
+        self._rr_interval = rr_interval
+        self._rnr_interval = rnr_interval
+        self._full_duplex = full_duplex
+        self._log = log
+        self._loop = loop
+
+        # Remote station handlers
+        self._peers = weakref.WeakValueDictionary()
+
+        # Signal emitted when a SABM(E) is received
+        self.connection_request = Signal()
+
+    @property
+    def address(self):
+        """
+        Return the source address of this station.
+        """
+        return self._address
+
+    @property
+    def protocol(self):
+        """
+        Return the protocol version of this station.
+        """
+        return self._protocol
+
+    def attach(self):
+        """
+        Connect the station to the interface.
+        """
+        interface = self._interface()
+        interface.bind(
+            self._on_receive,
+            callsign=self.address.callsign,
+            ssid=self.address.ssid,
+            regex=False,
+        )
+
+    def detach(self):
+        """
+        Disconnect from the interface.
+        """
+        interface = self._interface()
+        interface.unbind(
+            self._on_receive,
+            callsign=self.address.callsign,
+            ssid=self.address.ssid,
+            regex=False,
+        )
+
+    def getpeer(
+        self, callsign, ssid=None, repeaters=None, create=True, **kwargs
+    ):
+        """
+        Retrieve an instance of a peer context.  This creates the peer
+        object if it doesn't already exist unless create is set to False
+        (in which case it will raise KeyError).
+        """
+        address = AX25Address.decode(callsign, ssid).normalised
+        try:
+            return self._peers[address]
+        except KeyError:
+            if not create:
+                raise
+            pass
+
+        # Not there, so set some defaults, then create
+        kwargs.setdefault("full_duplex", self._full_duplex)
+        kwargs.setdefault("reject_mode", self._reject_mode)
+        kwargs.setdefault("modulo128", self._modulo128)
+        kwargs.setdefault("max_ifield", self._max_ifield)
+        kwargs.setdefault("max_ifield_rx", self._max_ifield_rx)
+        kwargs.setdefault("max_retries", self._max_retries)
+        kwargs.setdefault("max_outstanding_mod8", self._max_outstanding_mod8)
+        kwargs.setdefault(
+            "max_outstanding_mod128", self._max_outstanding_mod128
+        )
+        kwargs.setdefault("rr_delay", self._rr_delay)
+        kwargs.setdefault("rr_interval", self._rr_interval)
+        kwargs.setdefault("rnr_interval", self._rnr_interval)
+        kwargs.setdefault("ack_timeout", self._ack_timeout)
+        kwargs.setdefault("idle_timeout", self._idle_timeout)
+        kwargs.setdefault("protocol", AX25Version.UNKNOWN)
+        peer = AX25Peer(
+            self,
+            address,
+            repeaters=AX25Path(*(repeaters or [])),
+            log=self._log.getChild("peer.%s" % address),
+            loop=self._loop,
+            **kwargs
+        )
+        self._peers[address] = peer
+        return peer
+
+    def _on_receive(self, frame, **kwargs):
+        """
+        Handling of incoming messages.
+        """
+        if frame.header.cr:
+            # This is a command frame
+            self._log.debug("Checking command frame sub-class: %s", frame)
+            if isinstance(frame, AX25TestFrame):
+                # A TEST request frame, context not required
+                return self._on_receive_test(frame)
+
+        # If we're still here, then we don't handle unsolicited frames
+        # of this type, so pass it to a handler if we have one.
+        peer = self.getpeer(
+            frame.header.source, repeaters=frame.header.repeaters.reply
+        )
+        self._log.debug("Passing frame to peer %s: %s", peer.address, frame)
+        peer._on_receive(frame)
+
+    def _on_receive_test(self, frame):
+        """
+        Handle a TEST frame.
+        """
+        # The frame is a test request.
+        self._log.debug("Responding to test frame: %s", frame)
+        interface = self._interface()
+        interface.transmit(
+            AX25TestFrame(
+                destination=frame.header.source,
+                source=self.address,
+                repeaters=frame.header.repeaters.reply,
+                payload=frame.payload,
+                cr=False,
+            )
+        )
diff --git a/aioax25/tools/__init__.py b/aioax25/tools/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/aioax25/tools/call.py b/aioax25/tools/call.py
new file mode 100644
index 0000000..af92da3
--- /dev/null
+++ b/aioax25/tools/call.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+
+import asyncio
+import argparse
+import logging
+
+from prompt_toolkit import PromptSession
+from prompt_toolkit.patch_stdout import patch_stdout
+from yaml import safe_load
+
+# aioax25 imports
+# from aioax25.kiss import …
+# from aioax25.interface import …
+# etc… if you're copying this for your own code
+from ..frame import AX25Frame
+from ..kiss import make_device, KISSDeviceState
+from ..interface import AX25Interface
+from ..station import AX25Station
+from ..peer import AX25PeerState
+from ..version import AX25Version
+
+
+class AX25Call(object):
+    def __init__(self, source, destination, kissparams, port=0):
+        log = logging.getLogger(self.__class__.__name__)
+        kisslog = log.getChild("kiss")
+        kisslog.setLevel(logging.INFO)  # KISS logs are verbose!
+        intflog = log.getChild("interface")
+        intflog.setLevel(logging.INFO)  # interface logs are verbose too!
+        stnlog = log.getChild("station")
+
+        self._log = log
+        self._device = make_device(**kissparams, log=kisslog)
+        self._interface = AX25Interface(self._device[port], log=intflog)
+        self._station = AX25Station(
+            self._interface,
+            source,
+            log=stnlog,
+        )
+        self._station.attach()
+        self._peer = self._station.getpeer(destination)
+        self._peer.received_information.connect(self._on_receive)
+
+    def _on_receive(self, frame, **kwargs):
+        with patch_stdout():
+            if frame.pid == AX25Frame.PID_NO_L3:
+                # No L3 protocol
+                print("\n".join(frame.payload.decode().split("\r")))
+            else:
+                print("[PID=0x%02x] %r" % (frame.pid, frame.payload))
+
+    async def interact(self):
+        # Open the KISS interface
+        self._device.open()
+
+        # TODO: implement async functions on KISS device to avoid this!
+        while self._device.state != KISSDeviceState.OPEN:
+            await asyncio.sleep(0.1)
+
+        # Connect to the remote station
+        future = asyncio.Future()
+
+        def _state_change_fn(state, **kwa):
+            if state is AX25PeerState.CONNECTED:
+                future.set_result(None)
+            elif state is AX25PeerState.DISCONNECTED:
+                future.set_exception(IOError("Connection refused"))
+
+        self._peer.connect_state_changed.connect(_state_change_fn)
+        self._peer.connect()
+        await future
+        self._peer.connect_state_changed.disconnect(_state_change_fn)
+
+        # We should now be connected
+        self._log.info("CONNECTED to %s", self._peer.address)
+        finished = False
+        session = PromptSession()
+        while not finished:
+            if self._peer.state is not AX25PeerState.CONNECTED:
+                finished = True
+
+            with patch_stdout():
+                # Prompt for user input
+                txinput = await session.prompt_async(
+                    "%s>" % self._peer.address
+                )
+            if txinput:
+                self._peer.send(("%s\r" % txinput).encode())
+
+        if self._peer.state is not AX25PeerState.DISCONNECTED:
+            self._log.info("DISCONNECTING")
+            future = asyncio.Future()
+
+            def _state_change_fn(state, **kwa):
+                if state is AX25PeerState.DISCONNECTED:
+                    future.set_result(None)
+
+            self._peer.connect_state_changed.connect(_state_change_fn)
+            self._peer.disconnect()
+            await future
+            self._peer.connect_state_changed.disconnect(_state_change_fn)
+
+        self._log.info("Finished")
+
+
+async def main():
+    ap = argparse.ArgumentParser()
+
+    ap.add_argument("--log-level", default="info", type=str, help="Log level")
+    ap.add_argument("--port", default=0, type=int, help="KISS port number")
+    ap.add_argument(
+        "config", type=str, help="KISS serial port configuration file"
+    )
+    ap.add_argument("source", type=str, help="Source callsign/SSID")
+    ap.add_argument("destination", type=str, help="Source callsign/SSID")
+
+    args = ap.parse_args()
+
+    logging.basicConfig(
+        level=args.log_level.upper(),
+        format=(
+            "%(asctime)s %(name)s[%(filename)s:%(lineno)4d] "
+            "%(levelname)s %(message)s"
+        ),
+    )
+    config = safe_load(open(args.config, "r").read())
+
+    ax25call = AX25Call(args.source, args.destination, config, args.port)
+    await ax25call.interact()
+
+
+if __name__ == "__main__":
+    asyncio.get_event_loop().run_until_complete(main())
diff --git a/aioax25/tools/dumphex.py b/aioax25/tools/dumphex.py
new file mode 100644
index 0000000..63b8342
--- /dev/null
+++ b/aioax25/tools/dumphex.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+
+"""
+Very crude AX.25 KISS packet dump dissector.
+
+This code takes the KISS traffic seen through `socat -x` (so it could be a
+real serial port, a virtual one on a VM, or network sockets), and dumps the
+traffic it saw to the output.
+
+Usage:
+    python3 -m aioax25.tools.dumphex <socat-dump> > <dissected>
+"""
+
+from aioax25.kiss import BaseKISSDevice, KISSDeviceState, KISSCmdData
+from aioax25.frame import AX25Frame
+from binascii import a2b_hex
+from argparse import ArgumentParser
+import logging
+import asyncio
+import re
+
+
+SOCAT_HEX_RE = re.compile(r"^ [0-9a-f]{2}[0-9a-f ]*\n*$")
+NON_HEX_RE = re.compile(r"[^0-9a-f]")
+
+
+class FileKISSDevice(BaseKISSDevice):
+    def __init__(self, filename, **kwargs):
+        super(FileKISSDevice, self).__init__(**kwargs)
+        self._filename = filename
+        self._read_finished = False
+        self._frames = []
+        self._future = asyncio.Future()
+
+    async def dump(self):
+        self.open()
+        await self._future
+        self.close()
+        return self._frames
+
+    def _open(self):
+        with open(self._filename, "r") as f:
+            self._log.info("Reading frames from %r", self._filename)
+            self._state = KISSDeviceState.OPEN
+            for line in f:
+                match = SOCAT_HEX_RE.match(line)
+                if match:
+                    self._log.debug("Parsing %r", line)
+                    self._receive(a2b_hex(NON_HEX_RE.sub("", line)))
+                else:
+                    self._log.debug("Ignoring %r", line)
+
+            self._log.info("Read %r", self._filename)
+            self._read_finished = True
+
+    def _receive_frame(self):
+        super(FileKISSDevice, self)._receive_frame()
+
+        if not self._read_finished:
+            return
+
+        if self._future.done():
+            return
+
+        if len(self._rx_buffer) < 2:
+            self._log.info("Buffer is now empty")
+            self._future.set_result(None)
+
+    def _send_raw_data(self, data):
+        pass
+
+    def _dispatch_rx_frame(self, frame):
+        self._frames.append(frame)
+
+    def _close(self):
+        self._state = KISSDeviceState.CLOSED
+
+
+async def main():
+    ap = ArgumentParser()
+    ap.add_argument("hexfile", nargs="+")
+
+    args = ap.parse_args()
+
+    logging.basicConfig(level=logging.DEBUG)
+
+    for filename in args.hexfile:
+        kissdev = FileKISSDevice(filename)
+        frames = await kissdev.dump()
+
+        for frame in frames:
+            print(frame)
+            if isinstance(frame, KISSCmdData):
+                axframe = AX25Frame.decode(frame.payload, modulo128=False)
+                print(axframe)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())
diff --git a/aioax25/tools/listen.py b/aioax25/tools/listen.py
new file mode 100644
index 0000000..a68e9b7
--- /dev/null
+++ b/aioax25/tools/listen.py
@@ -0,0 +1,268 @@
+#!/usr/bin/env python3
+
+"""
+Very crude program for listening for an AX.25 connection, then launching a
+program for the remote caller to interact with.  e.g. to make a Python
+interpreter available over the packet network (and open a remote code
+execution hole in the process!), use something like:
+
+```
+$ python3 -m aioax25.tools.listen kiss-config.yml N0CALL-12 -- python -i
+```
+
+"""
+
+import asyncio
+from asyncio import subprocess
+import argparse
+import logging
+
+from yaml import safe_load
+
+# aioax25 imports
+# from aioax25.kiss import …
+# from aioax25.interface import …
+# etc… if you're copying this for your own code
+from ..kiss import make_device, KISSDeviceState
+from ..interface import AX25Interface
+from ..station import AX25Station
+from ..peer import AX25PeerState
+from ..version import AX25Version
+
+
+class SubprocProtocol(asyncio.Protocol):
+    """
+    SubprocProtocol manages the link to the sub-process on behalf of the peer
+    session.
+    """
+
+    def __init__(self, on_connect, on_receive, on_close, log):
+        super(SubprocProtocol, self).__init__()
+
+        self._on_connect = on_connect
+        self._on_receive = on_receive
+        self._on_close = on_close
+        self._log = log
+
+    def connection_made(self, transport):
+        try:
+            self._log.debug("Announcing connection: %r", transport)
+            self._on_connect(transport)
+        except Exception as e:
+            self._log.exception("Failed to handle connection establishment")
+            transport.close()
+            self._on_connect(None)
+
+    def pipe_data_received(self, fd, data):
+        try:
+            if fd == 1:  # stdout
+                self._on_receive(data)
+            else:
+                self._log.debug("Data received on fd=%d: %r", fd, data)
+        except:
+            self._log.exception(
+                "Failed to handle incoming data %r on fd=%d", data, fd
+            )
+
+    def pipe_connection_lost(self, fd, exc):
+        self._log.debug("FD %d closed (exc=%s)", fd, exc)
+        try:
+            self._on_close(exc)
+        except:
+            self._log.exception("Failed to handle process pipe close")
+
+    def process_exited(self):
+        try:
+            self._on_close(None)
+        except:
+            self._log.exception("Failed to handle process exit")
+
+
+class PeerSession(object):
+    def __init__(self, peer, command, echo, log):
+        self._peer = peer
+        self._log = log
+        self._command = command
+        self._cmd_transport = None
+        self._echo = echo
+
+        peer.received_information.connect(self._on_peer_received)
+        peer.connect_state_changed.connect(self._on_peer_state_change)
+
+    async def init(self):
+        self._log.info("Launching sub-process")
+        await asyncio.get_event_loop().subprocess_exec(
+            self._make_protocol,
+            *self._command,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+            bufsize=0
+        )
+
+    def _make_protocol(self):
+        """
+        Return a SubprocessProtocol instance that will handle the KISS traffic for the
+        asyncio transport.
+        """
+
+        def _on_connect(transport):
+            self._log.info("Sub-process transport now open")
+            self._cmd_transport = transport
+
+        return SubprocProtocol(
+            _on_connect,
+            self._on_subproc_received,
+            self._on_subproc_closed,
+            self._log.getChild("protocol"),
+        )
+
+    def _on_subproc_received(self, data):
+        """
+        Pass data from the sub-process to the AX.25 peer.
+        """
+        self._log.debug("Received from subprocess: %r", data)
+        if self._peer.state is AX25PeerState.CONNECTED:
+            # Peer still connected, pass to the peer, translating newline with
+            # CR as per AX.25 conventions.
+            data = b"\r".join(data.split(b"\n"))
+            self._log.debug("Writing to peer: %r", data)
+            self._peer.send(data)
+        elif self._peer.state is AX25PeerState.DISCONNECTED:
+            # Peer is not connected, close the subprocess.
+            self._log.info("Peer no longer connected, shutting down")
+            self._cmd_transport.close()
+
+    def _on_subproc_closed(self, exc=None):
+        if exc is not None:
+            self._log.error("Closing port due to error %r", exc)
+
+        self._log.info("Sub-process has exited")
+        self._cmd_transport = None
+        if self._peer.state is not AX25PeerState.DISCONNECTED:
+            self._log.info("Closing peer connection")
+            self._peer.disconnect()
+
+    def _on_peer_received(self, payload, **kwargs):
+        """
+        Pass data from the AX.25 peer to the sub-process.
+        """
+        self._log.debug("Received from peer: %r", payload)
+        if self._echo:
+            # Echo back to peer
+            self._peer.send(payload)
+
+        if self._cmd_transport:
+            payload = b"\n".join(payload.split(b"\r"))
+            self._log.debug("Writing to subprocess: %r", payload)
+            self._cmd_transport.get_pipe_transport(0).write(payload)
+        else:
+            # Subprocess no longer running, so shut it down.
+            self._log.info("Sub-process no longer running, disconnecting")
+            self._peer.disconnect()
+
+    def _on_peer_state_change(self, state, **kwargs):
+        """
+        Handle peer connection state change.
+        """
+        if state is AX25PeerState.DISCONNECTED:
+            self._log.info("Peer has disconnected")
+            if self._cmd_transport:
+                self._cmd_transport.close()
+
+
+class AX25Listen(object):
+    def __init__(self, source, command, kissparams, port=0, echo=False):
+        log = logging.getLogger(self.__class__.__name__)
+        kisslog = log.getChild("kiss")
+        kisslog.setLevel(logging.INFO)  # KISS logs are verbose!
+        intflog = log.getChild("interface")
+        intflog.setLevel(logging.INFO)  # interface logs are verbose too!
+        stnlog = log.getChild("station")
+
+        self._log = log
+        self._device = make_device(**kissparams, log=kisslog)
+        self._interface = AX25Interface(self._device[port], log=intflog)
+        self._station = AX25Station(
+            self._interface,
+            source,
+            log=stnlog,
+        )
+        self._station.attach()
+        self._command = command
+        self._station.connection_request.connect(self._on_connection_request)
+        self._echo = echo
+
+    async def listen(self):
+        # Open the KISS interface
+        self._device.open()
+
+        # TODO: implement async functions on KISS device to avoid this!
+        while self._device.state != KISSDeviceState.OPEN:
+            await asyncio.sleep(0.1)
+
+        self._log.info("Listening for connections")
+        while True:
+            await asyncio.sleep(1)
+
+    def _on_connection_request(self, peer, **kwargs):
+        # Bounce to the I/O loop
+        asyncio.ensure_future(self._connect_peer(peer))
+
+    async def _connect_peer(self, peer):
+        self._log.info("Incoming connection from %s", peer.address)
+        try:
+            session = PeerSession(
+                peer,
+                self._command,
+                self._echo,
+                self._log.getChild(str(peer.address)),
+            )
+            await session.init()
+        except:
+            self._log.exception("Failed to initialise peer connection")
+            peer.reject()
+            return
+
+        # All good?  Accept the connection.
+        peer.accept()
+
+
+async def main():
+    ap = argparse.ArgumentParser()
+
+    ap.add_argument("--log-level", default="info", type=str, help="Log level")
+    ap.add_argument("--port", default=0, type=int, help="KISS port number")
+    ap.add_argument(
+        "--echo",
+        default=False,
+        action="store_const",
+        const=True,
+        help="Echo input back to the caller",
+    )
+    ap.add_argument(
+        "config", type=str, help="KISS serial port configuration file"
+    )
+    ap.add_argument("source", type=str, help="Source callsign/SSID")
+    ap.add_argument(
+        "command", type=str, nargs="+", help="Program + args to run"
+    )
+
+    args = ap.parse_args()
+
+    logging.basicConfig(
+        level=args.log_level.upper(),
+        format=(
+            "%(asctime)s %(name)s[%(filename)s:%(lineno)4d] "
+            "%(levelname)s %(message)s"
+        ),
+    )
+    config = safe_load(open(args.config, "r").read())
+
+    ax25listen = AX25Listen(
+        args.source, args.command, config, args.port, args.echo
+    )
+    await ax25listen.listen()
+
+
+if __name__ == "__main__":
+    asyncio.get_event_loop().run_until_complete(main())
diff --git a/aioax25/uint.py b/aioax25/uint.py
new file mode 100644
index 0000000..c384d4b
--- /dev/null
+++ b/aioax25/uint.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+
+"""
+Unsigned Integer encoding and decoding routines.
+
+Both endians of integer are used, and sometimes the fields are odd sizes like
+the 24-bit fields in XID HDLC Optional Function fields.  This allows encoding
+or decoding of any integer, of any length, in either endianness.
+"""
+
+
+def encode(value, length=None, big_endian=False):
+    """
+    Encode the given unsigned integer value as bytes, optionally of a given
+    length.
+    """
+
+    output = bytearray()
+    while (value != 0) if (length is None) else (length > 0):
+        output += bytes([value & 0xFF])
+        value >>= 8
+        if length is not None:
+            length -= 1
+
+    if not output:
+        # No output, so return a null byte
+        output += b"\x00"
+
+    if big_endian:
+        output.reverse()
+
+    return bytes(output)
+
+
+def decode(value, big_endian=False):
+    """
+    Decode the given bytes as an unsigned integer.
+    """
+
+    output = 0
+    for byte in value if big_endian else reversed(value):
+        output <<= 8
+        output |= byte
+    return output
diff --git a/aioax25/version.py b/aioax25/version.py
new file mode 100644
index 0000000..1493388
--- /dev/null
+++ b/aioax25/version.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+
+"""
+AX.25 Version enumeration.  This is used to record whether a station is
+using a known version of AX.25.
+"""
+
+import enum
+
+
+class AX25Version(enum.Enum):
+    UNKNOWN = "0.0"  # The version is not known
+    AX25_10 = "1.x"  # AX.25 1.x in use
+    AX25_20 = "2.0"  # AX.25 2.0 in use
+    AX25_22 = "2.2"  # AX.25 2.2 in use
diff --git a/doc/ax25-2p0/index.html b/doc/ax25-2p0/index.html
new file mode 100644
index 0000000..521205e
--- /dev/null
+++ b/doc/ax25-2p0/index.html
@@ -0,0 +1,1974 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
+<link rel="stylesheet" type="text/css" href="index_files/banner-styles.css">
+<link rel="stylesheet" type="text/css" href="index_files/iconochive.css">
+<!-- End Wayback Rewrite JS Include -->
+
+<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+<title>AX.25 Amateur Packet-Radio Link-Layer Protocol</title>
+<link rel="stylesheet" href="index_files/tapr_sub.css" type="text/css" media="">
+
+<meta name="AUTHOR" content="TAPR Webmaster, Site Design by Greg Jones, WD5IVD">
+<meta name="DESCRIPTION" content="AX.25 Amateur Packet-Radio Link-Layer Protocol Version 2.0, October 1984; AX.25 Link-Layer Protocol Specification">
+<meta name="KeyWords" content="TAPR, amateur radio, digital communications, AX.25, AX.25 Link-Layer Protocol Specification, AX.25 version 2.0, ARRL, ISO 3309, CCITT X.25, packet radio">
+<meta name="Copyright" content="� 1994-2005 Tucson Amateur Packet Radio, Corp.">
+
+</head>
+<body>
+<!-- INCLUDE MASTHEAD HTML -->
+<div id="masthead"> 
+  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/index.html"><img src="index_files/tapr-logo.png" alt="" width="95" height="110" border="0"></a>
+   <div id="utility"> 
+    <a href="http://web.archive.org/web/20190831185324/https://tapr.org/tapr_showcart.php">Order Cart</a> | <a href="http://web.archive.org/web/20190831185324/https://tapr.org/contact_us.html">Contact Us</a> | <a href="http://web.archive.org/web/20190831185324/https://tapr.org/index.html">Home</a> | <a href="http://web.archive.org/web/20190831185324/https://tapr.org/tapr_sigs.html">SIGs</a>  | 
+
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/mem_portal.html">Login</a>
+<!--
+<a href="https://secure.tapr.org/np/clients/tapr/login.jsp">Login</a>
+-->
+  </div> 
+    <div id="globalLink">
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/organization.html" class="glink">Organization</a>
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/organization.html#membership" class="glink">Membership</a>
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/publications.html" class="glink">Publications</a>
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/kits.html" class="glink">Kits</a>
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/software.html" class="glink">Software</a>
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/products.php" class="glink">Products</a><br> 
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/conferences.html" class="glink">Conferences</a>
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/packetradio.html" class="glink">Packet Radio</a>
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/aprs.html" class="glink">APRS/GPS</a>
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/dsp.html" class="glink">DSP</a>
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/spread_spectrum.html" class="glink">Spread Spectrum</a>
+	  <a href="http://web.archive.org/web/20190831185324/https://tapr.org/archives.html" class="glink">Archives</a>
+  </div> 
+    <!--end globalLinks--> 
+   <!-- end globalNav --> 
+</div> 
+<!-- end masthead --> 
+
+<!-- PAGE -->
+<div id="pagecell1"> 
+  <!--pagecell1--> 
+  <img alt="" src="index_files/tl_curve_white.gif" id="tl" width="6" height="6"> <img alt="" src="index_files/tr_curve_white.gif" id="tr" width="6" height="6"> 
+  <div id="pageName"> 
+    <a href="http://web.archive.org/web/20190831185324/https://tapr.org/publications.html">Publications</a>: <h2>AX.25 Amateur Packet-Radio Link-Layer Protocol</h2>
+  </div> 
+  
+  <div id="pageNav"> 
+    <div id="sectionLinks"> 
+<h2>Books</h2>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/pub_wdcdat.html">Wireless Digital Communications: Design and Theory</a>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/pub_ssupdate.html">TAPR Spread Spectrum Update</a>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/pub_PacketRadioBook.html">TAPR Packet Radio: What? Why? How?</a>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/psr.html">TAPR PSR All Issues</a>
+<h2>CDs / DVDs</h2>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/cdromdcc.html">TAPR/ARRL DCC Proceeding CDs</a>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/pub_dvd.html">TAPR/ARRL DCC Video</a>
+<h2>Electronic - Free</h2>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/ohl.html">OHL - Open Hardware License</a>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/pub_ax25.html">AX.25 Protocol</a>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/pub_9600.html">TAPR 9600</a>
+<h2>Proceedings</h2>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/pub_dcc.html">Digital Communications Conference</a>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/pub_95meeting.html">TAPR 1995 Annual Meeting</a>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/pub_94meeting.html">TAPR 1994 Annual Meeting</a>
+<h2>Out of Print</h2>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/pub_NOSIntro.html">NOSIntro</a>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/pub_bbssysop.html">TAPR BBS Sysop Guide</a>
+    </div> 
+  </div> 
+  <div id="content"> 
+    <div class="feature"> 
+	  <!-- FEATURE ARTICLE GOES HERE -->
+
+
+
+<h5>AX.25 Amateur Packet-Radio Link-Layer Protocol</h5>
+<h4>Version 2.2, 1998</h4>
+<img src="index_files/pdf2.gif" width="32" height="31" align="middle"> <a href="http://web.archive.org/web/20190831185324/https://tapr.org/pdf/AX25.2.2.pdf">Ver 2.2 Rev: 1998</a> (pdf format 3.0, 1.6M)<br>
+
+<p>
+</p><hr>
+<p>
+
+</p><h5>AX.25 Amateur Packet-Radio Link-Layer Protocol</h5>
+<h4>Version 2.0, October 1984</h4>
+<ul>
+<center>
+<hr width="50%">
+PID Table Updated November, 1997.
+<hr width="50%">
+</center>
+</ul>
+<p>
+
+<a name="2"></a>
+</p><h1>2  AX.25 Link-Layer Protocol Specification</h1>
+
+<a name="2.1"></a>
+<h2>2.1  Scope and Field of Operation</h2>
+
+In  order  to  provide  a  mechanism  for  the   reliable
+transport  of  data  between  two  signaling  terminals,  it   is
+necessary  to define a protocol that can accept and deliver  data
+over a variety of types of communications links.  The AX.25 Link-
+Layer  Protocol is designed to provide this service,  independent
+of any other level that may or may not exist.
+
+<p> This protocol conforms to ISO Recommendations 3309,  4335
+(including DAD 1&amp;2) and 6256 high-level data link control  (HDLC)
+and  uses  some terminology found in these  documents.   It  also
+conforms with ANSI X3.66, which describes ADCCP, balanced mode.
+
+</p><p> This  protocol  follows,  in principle,  the  CCITT  X.25
+Recommendation,  with the exception of an extended address  field
+and  the addition of the Unnumbered Information (UI)  frame.   It
+also follows the principles of CCITT Recommendation Q.921  (LAPD)
+in the use of multiple links, distinguished by the address field,
+on a single shared channel.
+
+</p><p> As  defined,  this  protocol will work  equally  well  in
+either half- or full-duplex Amateur Radio environments.
+
+</p><p> This protocol has been designed to work equally well  for
+direct  connections between two individual  amateur  packet-radio
+stations or an individual station and a multiport controller.
+
+</p><p> This  protocol allows for the establishment of more  than
+one  link-layer  connection  per  device, if  the  device  is  so
+capable.
+
+</p><p> This  protocol  does not  prohibit  self-connections.   A
+self-connection  is considered to be when a device establishes  a
+link  to  itself using its own address for both  the  source  and
+destination of the frame.
+
+</p><p> Most  link-layer  protocols assume that one  primary  (or
+master)  device  (generally  called  a  DCE,  or  data   circuit-
+terminating equipment) is connected to one or more secondary  (or
+slave)  device(s)  (usually  called a DTE,  or  data  terminating
+equipment).   This type of unbalanced operation is not  practical
+in a shared-RF Amateur Radio environment.  Instead, AX.25 assumes
+that  both  ends  of  the link are of  the  same  class,  thereby
+eliminating  the two different classes of devices.  The term  DXE
+is  used in this protocol specification to describe the  balanced
+type of device found in amateur packet radio.
+
+<a name="2.2"></a>
+</p><h2>2.2  Frame Structure</h2>
+
+Link  layer packet radio transmissions are sent in  small
+blocks of data, called frames.  Each frame is made up of  several
+smaller groups, called fields.  Fig.1 shows the three basic types
+of  frames.  Note that the first bit to be transmitted is on  the
+left side.
+
+<p>
+</p><center>
+
+<a name="fig 1a"></a>
+<table cellpadding="8" border="1">
+<tbody><tr><th colspan="5" align="left">First Bit Sent</th></tr>
+<tr><th>Flag</th><th>Address</th><th>Control</th><th>FCS</th><th>Flag</th></tr>
+<tr><td>01111110</td><td>112/560 Bits</td><td>8 Bits</td><td>16 Bits</td><td>01111110</td></tr>
+</tbody><caption>Fig. 1A -- U and S frame construction</caption>
+</table>
+
+<p>
+
+<a name="fig 1b"></a>
+</p><table cellpadding="8" border="1">
+<tbody><tr><th colspan="7" align="left">First Bit Sent</th></tr>
+<tr><th>Flag</th><th>Address</th><th>Control</th><th>PID</th><th>Info.</th><th>FCS</th><th>Flag</th></tr>
+<tr><td>01111110</td><td>112/560 Bits</td><td>8 Bits</td><td>8 Bits</td><td>N*8 Bits</td><td>16 Bits</td><td>01111110</td></tr>
+</tbody><caption>Fig. 1B -- Information frame construction</caption>
+</table>
+
+</center>
+
+<p> Each field is made up of an integral number of octets (or
+bytes), and serves a specific function as outlined below.
+
+<a name="2.2.1"></a>
+</p><h4>2.2.1  Flag Field</h4>
+
+The flag field is one octet long.  Since the flag is used
+to  delimit  frames, it occurs at both the beginning and  end  of
+each  frame.  Two frames may share one flag, which  would  denote
+the  end of the first frame, and the start of the next frame.   A
+flag consists of a zero followed by six ones followed by  another
+zero,  or  01111110 (7E hex).  As a result of bit  stuffing  (see
+<a href="#2.2.6">2.2.6</a>,  below),  this sequence is not allowed
+to  occur  anywhere else inside a complete frame.
+
+<a name="2.2.2"></a>
+<h4>2.2.2  Address Field</h4>
+
+The address field is used to identify both the source  of
+the  frame and its destination.  In addition, the  address  field
+contains  the  command/response information  and  facilities  for
+level 2 repeater operation.
+
+<p> The encoding of the address field is described in
+<a href="#2.2.13">2.2.13</a>.
+
+<a name="2.2.3"></a>
+</p><h4>2.2.3  Control Field</h4>
+
+The  control field is used to identify the type of  frame
+being  passed  and  control several attributes  of  the  level  2
+connection.   It  is  one octet in length, and  its  encoding  is
+discussed in
+<a href="#2.3.2.1">2.3.2.1</a>, below.
+
+<a name="2.2.4"></a>
+<h4>2.2.4  PID Field</h4>
+
+The  Protocol  Identifier  (PID) field  shall  appear  in
+information  frames (I and UI) only.  It identifies what kind  of
+layer 3 protocol, if any, is in use.
+
+<p> The PID itself is not included as part of the octet count
+of the information field.  The encoding of the PID is as follows:
+
+</p><p>
+</p><center>
+
+
+<a name="PID encoding"></a>
+<table cellpadding="8" border="1">
+    <tbody><tr>
+	<th>HEX</th>
+	<th align="center">
+	    <tt>M&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;L<br>
+	    S&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;S<br>
+	    B&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;B</tt></th>
+	<th>Translation</th>
+    </tr>
+    <tr>
+	<td>0x01</td>
+	<td><tt>00000001</tt>
+	</td><td>ISO 8208/CCITT X.25 PLP
+    </td></tr>
+    <tr>
+	<td>0x06</td>
+	<td><tt>00000110</tt>
+	</td><td>Compressed TCP/IP packet. Van Jacobson (RFC 1144)
+    </td></tr>
+    <tr>
+	<td>0x07</td>
+	<td><tt>00000111</tt>
+	</td><td>Uncompressed TCP/IP packet. Van Jacobson (RFC 1144)
+    </td></tr>
+    <tr>
+	<td>0x08</td>
+	<td><tt>00001000</tt>
+	</td><td>Segmentation fragment
+    </td></tr>
+    <tr>
+	<td align="center">**</td>
+	<td><tt>yy01yyyy</tt></td>
+	<td>AX.25 layer 3 implemented.
+    </td></tr>
+    <tr>
+	<td align="center">**</td>
+	<td><tt>yy10yyyy</tt>
+	</td><td>AX.25 layer 3 implemented.
+    </td></tr>
+    <tr>
+	<td>0xC3</td>
+	<td><tt>11000011</tt>
+	</td><td>TEXNET datagram protocol
+    </td></tr>
+    <tr>
+	<td>0xC4</td>
+	<td><tt>11000100</tt>
+	</td><td>Link Quality Protocol
+    </td></tr>
+    <tr>
+	<td>0xCA</td>
+	<td><tt>11001010</tt>
+	</td><td>Appletalk
+    </td></tr>
+    <tr>
+	<td>0xCB</td>
+	<td><tt>11001011</tt>
+	</td><td>Appletalk ARP
+    </td></tr>
+    <tr>
+	<td>0xCC</td>
+	<td><tt>11001100</tt>
+	</td><td>ARPA Internet Protocol
+    </td></tr>
+    <tr>
+	<td>0xCD</td>
+	<td><tt>11001101</tt>
+	</td><td>ARPA Address resolution
+    </td></tr>
+    <tr>
+	<td>0xCE</td>
+	<td><tt>11001110</tt>
+	</td><td>FlexNet
+    </td></tr>
+    <tr>
+	<td>0xCF</td>
+	<td><tt>11001111</tt>
+	</td><td>NET/ROM
+    </td></tr>
+    <tr>
+	<td>0xF0</td>
+	<td><tt>11110000</tt>
+	</td><td>No layer 3 protocol implemented.
+    </td></tr>
+    <tr>
+	<td>0xFF</td>
+	<td><tt>11111111</tt>
+	</td><td>Escape character.  Next octet contains more Level 3
+	protocol information.
+    </td></tr>
+</tbody></table>
+</center>
+
+<p> <i>Where:</i><br>
+A <tt>y</tt> indicates all combinations used.
+
+</p><p> <b>Note:</b><br>
+All  forms  of  <tt>yy11yyyy</tt> and <tt>yy00yyyy</tt>  other  than  those
+listed  above  are  reserved  at this time  for  future  level  3
+protocols.   The  assignment of these formats is  up  to  amateur
+agreement.   It  is  recommended that the  creators  of  level  3
+protocols   contact  the  ARRL  Ad  Hoc  Committee   on   Digital
+Communications for suggested encodings.
+
+<a name="2.2.5"></a>
+</p><h4>2.2.5  Information Field</h4>
+
+The  information field is used to convey user  data  from
+one  end of the link to the other.  I fields are allowed in  only
+three  types of frames:  the I frame, the UI frame, and the  FRMR
+frame.   The  I  field can be up to 256 octets  long,  and  shall
+contain  an integral number of octets.  These  constraints  apply
+prior to the insertion of zero bits as specified in
+<a href="#2.2.6">2.2.6</a>, below.
+Any  information  in the I field shall be passed along  the  link
+transparently,  except  for the zero-bit  insertion  (see
+<a href="#2.2.6">2.2.6</a>)
+necessary  to prevent flags from accidentally appearing in the  I
+field.
+
+<a name="2.2.6"></a>
+<h4>2.2.6  Bit Stuffing</h4>
+
+In  order to assure that the flag bit sequence  mentioned
+above  doesn't appear accidentally anywhere else in a frame,  the
+sending  station  shall monitor the bit sequence for a  group  of
+five  or more contiguous one bits.  Any time five contiguous  one
+bits  are sent the sending station shall insert a zero bit  after
+the  fifth  one  bit.   During frame  reception,  any  time  five
+contiguous  one  bits  are  received,  a  zero  bit   immediately
+following five one bits shall be discarded.
+
+<a name="2.2.7"></a>
+<h4>2.2.7  Frame-Check Sequence</h4>
+
+The  frame-check sequence (FCS) is a  sixteen-bit  number
+calculated  by  both the sender and receiver of a frame.   It  is
+used  to  insure that the frame was not corrupted by  the  medium
+used to get the frame from the sender to the receiver.  It  shall
+be calculated in accordance with ISO 3309 (HDLC) Recommendations.
+
+<a name="2.2.8"></a>
+<h4>2.2.8  Order of Bit Transmission</h4>
+
+With  the  exception of the FCS field, all fields  of  an
+AX.25 frame shall be sent with each octet's least-significant bit
+first.  The FCS shall be sent most-significant bit first.
+
+<a name="2.2.9"></a>
+<h4>2.2.9  Invalid Frames</h4>
+
+Any frame consisting of less than 136 bits (including the
+opening  and closing flags), not bounded by opening  and  closing
+flags, or not octet aligned (an integral number of octets), shall
+be  considered  an  invalid frame by the link  layer.   See  also
+<a href="#2.4.4.4">2.4.4.4</a>, below.
+
+<a name="2.2.10"></a>
+<h4>2.2.10  Frame Abort</h4>
+
+If a frame must be prematurely aborted, at least  fifteen
+contiguous ones shall be sent with no bit stuffing added.
+
+<a name="2.2.11"></a>
+<h4>2.2.11  Interframe Time Fill</h4>
+
+Whenever   it  is  necessary  for  a  DXE  to  keep   its
+transmitter  on  while  not actually  sending  frames,  the  time
+between frames should be filled with contiguous flags.
+
+<a name="2.2.12"></a>
+<h4>2.2.12  Link Channel States</h4>
+
+Not applicable.
+
+<a name="2.2.13"></a>
+<h4>2.2.13  Address-Field Encoding</h4>
+
+The  address  field of all frames shall be  encoded  with
+both the destination and source amateur call signs for the frame.
+Except  for the Secondary Station Identifier (SSID), the  address
+field  should  be made up of upper-case alpha and  numeric  ASCII
+characters only.  If level 2 amateur "repeaters" are to be  used,
+their call signs shall also be in the address field.
+
+<p> The  HDLC address field is extended beyond one  octet  by
+assigning  the  least-significant  bit of each  octet  to  be  an
+"extension bit".  The extension bit of each octet is set to zero,
+to indicate the next octet contains more address information,  or
+one,  to  indicate  this is the last octet of  the  HDLC  address
+field.   To make room for this extension bit, the  amateur  Radio
+call sign information is shifted one bit left.
+
+<a name="2.2.13.1"></a>
+</p><h5>2.2.13.1  Nonrepeater Address-Field Encoding</h5>
+
+If  level  2 repeaters are not being  used,  the  address
+field is encoded as shown in Fig. 2.  The destination address  is
+the call sign and SSID of the amateur radio station to which  the
+frame is addressed, while the source address contains the amateur
+call  sign  and SSID of the station that sent the  frame.   These
+call signs are the call signs of the two ends of a level 2  AX.25
+link only.
+
+<p>
+</p><center>
+
+<a name="fig 2"></a>
+<table cellpadding="8" border="1">
+<tbody><tr><th colspan="14" align="left"> First Octet Sent</th></tr>
+<tr><th colspan="14" align="center"> Address Field of Frame</th></tr>
+<tr><th colspan="7" align="center"> Destination Address
+</th><th colspan="7" align="center"> Source Address</th></tr>
+<tr><td>A1 </td><td>A2 </td><td>A3 </td><td>A4 </td><td>A5 </td><td>A6 </td><td>A7
+</td><td>A8 </td><td>A9 </td><td>A10 </td><td>A11 </td><td>A12 </td><td>A13 </td><td>A14</td></tr>
+</tbody><caption> Fig. 2 -- Nonrepeater Address-Field Encoding </caption>
+</table>
+
+</center>
+
+<p> A1  through A14 are the fourteen octets that make up  the
+two  address  subfields of the address  field.   The  destination
+subaddress is seven octets long (A1 thru A7), and is sent  first.
+This  address sequence provides the receivers of frames  time  to
+check  the  destination address subfield to see if the  frame  is
+addressed to them while the rest of the frame is being  received.
+The  source  address subfield is then sent in octets  A8  through
+A14.   Both  of these subfields are encoded in the  same  manner,
+except  that  the last octet of the address field  has  the  HDLC
+address extension bit set.
+
+</p><p>There  is  an octet at the end of each  address  subfield
+that contains the Secondary Station Identifier (SSID).  The  SSID
+subfield  allows an Amateur Radio operator to have more than  one
+packet-radio station operating under the same call sign.  This is
+useful when an amateur wants to put up a repeater in addition  to
+a regular station, for example.  The C bits (see
+<a href="#2.4.1.2">2.4.1.2</a>, below)
+and H bit (see
+<a href="#2.2.13.2">2.2.13.2</a>, below)
+are also contained in this octet,
+along with two bits which are reserved for future use.
+
+</p><p>Fig.  3A shows a typical AX.25 frame in  the  nonrepeater
+mode of operation.
+
+</p><p></p><center>
+
+<a name="fig 3a"></a>
+<table cellpadding="8" border="1">
+<tbody><tr><th>Octet</th><th>ASCII</th><th>Bin.Data</th><th>Hex Data</th></tr>
+<tr><td>Flag</td><td></td><td><tt>01111110</tt></td><td>7E</td></tr>
+<tr><td>A1</td><td>K</td><td><tt>10010110</tt></td><td>96</td></tr>
+<tr><td>A2</td><td>8</td><td><tt>01110000</tt></td><td>70</td></tr>
+<tr><td>A3</td><td>M</td><td><tt>10011010</tt></td><td>9A</td></tr>
+<tr><td>A4</td><td>M</td><td><tt>10011010</tt></td><td>9A</td></tr>
+<tr><td>A5</td><td>O</td><td><tt>10011110</tt></td><td>9E</td></tr>
+<tr><td>A6</td><td>space</td><td><tt>01000000</tt></td><td>40</td></tr>
+<tr><td>A7</td><td>SSID</td><td><tt>11100000</tt></td><td>E0</td></tr>
+<tr><td>A8</td><td>W</td><td><tt>10101110</tt></td><td>AE</td></tr>
+<tr><td>A9</td><td>B</td><td><tt>10000100</tt></td><td>84</td></tr>
+<tr><td>A10</td><td>4</td><td><tt>01100100</tt></td><td>68</td></tr>
+<tr><td>A11</td><td>J</td><td><tt>10010100</tt></td><td>94</td></tr>
+<tr><td>A12</td><td>F</td><td><tt>10001100</tt></td><td>8C</td></tr>
+<tr><td>A13</td><td>I</td><td><tt>10010010</tt></td><td>92</td></tr>
+<tr><td>A14</td><td>SSID</td><td><tt>01100001</tt></td><td>61</td></tr>
+<tr><td>Control</td><td>I</td><td><tt>00111110</tt></td><td>3E</td></tr>
+<tr><td>PID</td><td>none</td><td><tt>11110000</tt></td><td>F0</td></tr>
+<tr><td>FCS</td><td>part 1</td><td><tt>xxxxxxxx</tt></td><td>HH</td></tr>
+<tr><td>FCS</td><td>part 2</td><td><tt>xxxxxxxx</tt></td><td>HH</td></tr>
+<tr><td>Flag</td><td></td><td><tt>01111110</tt></td><td>7E</td></tr>
+<tr><th colspan="2">Bit position</th><td><tt>76543210</tt></td><td></td></tr>
+</tbody><caption>Fig. 3A -- Nonrepeater AX.25 frame</caption>
+</table></center>
+
+<p> The frame shown is an I frame, not going through a  level
+2 repeater, from WB4JFI (SSID=0) to K8MMO (SSID=0), with no level
+3  protocol.   The P/F bit is set; the  receive  sequence  number
+(N(R)) is 1; the send sequence number (N(S)) is 7.
+
+<a name="2.2.13.1.1"></a>
+</p><h4>2.2.13.1.1  Destination Subfield Encoding</h4>
+
+Fig.  3 shows how an amateur call sign is placed  in  the
+destination address subfield, occupying octets A1 thru A7.
+
+<p></p><center>
+
+<a name="fig 3"></a>
+<table cellpadding="8" border="1">
+<tbody><tr><th>Octet</th><th>ASCII</th><th>Bin.Data</th><th>Hex Data</th></tr>
+<tr><td>A1</td><td>W</td><td><tt>10101110</tt></td><td>AE</td></tr>
+<tr><td>A2</td><td>B</td><td><tt>10000100</tt></td><td>84</td></tr>
+<tr><td>A3</td><td>4</td><td><tt>01101000</tt></td><td>68</td></tr>
+<tr><td>A4</td><td>J</td><td><tt>10010100</tt></td><td>94</td></tr>
+<tr><td>A5</td><td>F</td><td><tt>10001100</tt></td><td>8C</td></tr>
+<tr><td>A6</td><td>I</td><td><tt>10010010</tt></td><td>92</td></tr>
+<tr><td>A7</td><td>SSID</td><td><tt>CRRSSID0</tt></td><td></td></tr>
+<tr><th colspan="2">Bit Position</th><td><tt>76543210</tt></td><td></td></tr>
+</tbody><caption>Fig. 3 -- Destination Field Encoding</caption>
+</table>
+
+</center>
+
+<p><i>Where:</i></p><ol compact="compact">
+<li>The top octet (A1) is the first octet sent, with  bit
+    0  of each octet being the first bit sent, and bit  7
+    being the last bit sent.
+</li><li>The  first (low-order or bit 0) bit of each octet  is
+    the HDLC address extension bit, which is set to  zero
+    on all but the last octet in the address field, where
+    it is set to one.
+</li><li>The  bits marked "r" are reserved bits.  They may  be
+    used in an agreed-upon manner in individual networks.
+    When not implemented, they should be set to one.
+</li><li>The  bit marked "C" is used as  the  command/response
+    bit of an AX.25 frame, as outlined in
+<a href="#2.4.1.2">2.4.1.2</a> below.
+</li><li>The  characters of the call sign should  be  standard
+    seven-bit  ASCII  (upper  case only)  placed  in  the
+    leftmost seven bits of the octet to make room for the
+    address  extension  bit.  If the call  sign  contains
+    fewer  than six characters, it should be padded  with
+    ASCII spaces between the last call sign character and
+    the SSID octet.
+</li><li>The  0000  SSID is reserved for  the  first  personal
+    AX.25 station. This establishes one standard SSID for
+    "normal" stations to use for the first station.
+</li></ol>
+
+<a name="2.2.13.2"></a>
+<h5>2.2.13.2  Level 2 Repeater-Address Encoding</h5>
+
+If  a  frame  is to go through  level  2  amateur  packet
+repeater(s), there is an additional address subfield appended  to
+the end of the address field.  This additional subfield  contains
+the call sign(s) of the repeater(s) to be used.  This allows more
+than one repeater to share the same RF channel.  If this subfield
+exists,  the  last octet of the source subfield has  its  address
+extension  bit  set to zero, indicating that  more  address-field
+data  follows.  The repeater-address subfield is encoded  in  the
+same  manner  as the destination and  source  address  subfields,
+except for the most-significant bit in the last octet, called the
+"H" bit.  The H bit is used to indicate whether a frame has  been
+repeated or not.
+
+<p>In  order  to provide some method of  indicating  when  a
+frame has been repeated, the H bit is set to zero on frames going
+to  a repeater.  The repeater will set the H bit to one when  the
+frame  is retransmitted.  Stations should monitor the H bit,  and
+discard  any frames going to the repeater (uplink frames),  while
+operating  through  a repeater.  Fig. 4 shows how  the  repeater-
+address subfield is encoded.  Fig. 4A is an example of a complete
+frame after being repeated.
+
+</p><p>
+</p><center>
+
+<a name="fig 4"></a>
+<table cellpadding="8" border="1">
+<tbody><tr><th>Octet</th><th>ASCII</th><th>Bin.Data</th><th>Hex Data</th></tr>
+<tr><td>A15</td><td>W</td><td><tt>10101110</tt></td><td>AE</td></tr>
+<tr><td>A16</td><td>B</td><td><tt>10000100</tt></td><td>84</td></tr>
+<tr><td>A17</td><td>4</td><td><tt>01101000</tt></td><td>68</td></tr>
+<tr><td>A18</td><td>J</td><td><tt>10010100</tt></td><td>94</td></tr>
+<tr><td>A19</td><td>F</td><td><tt>10001100</tt></td><td>8C</td></tr>
+<tr><td>A20</td><td>I</td><td><tt>10010010</tt></td><td>92</td></tr>
+<tr><td>A21</td><td>SSID</td><td><tt>HRRSSID1</tt></td><td></td></tr>
+<tr><th colspan="2" align="right">Bit Order  --&gt;</th><td><tt>76543210</tt></td></tr>
+</tbody><caption>Fig. 4 -- Repeater-address encoding</caption>
+</table>
+</center>
+
+<i>Where</i>:
+<ol compact="compact">
+<li>The  top octet is the first octet sent, with bit  0  being
+sent first and bit 7 sent last of each octet.
+</li><li>As  with  the  source and  destination  address  subfields
+discussed  above, bit 0 of each octet is the HDLC  address
+extension  bit, which is set to zero on all but  the  last
+address octet, where it is set to one.
+</li><li>The  "R"  bits are reserved in the same manner as  in  the
+source and destination subfields.
+</li><li>The  "H" bit is the has-been-repeated bit.  It is  set  to
+zero  whenever a frame has not been repeated, and  set  to
+one by the repeater when the frame has been repeated.
+</li></ol>
+
+<p></p><center>
+
+<a name="fig 4a"></a>
+<table cellpadding="8" border="1">
+<tbody><tr><th>Octet</th><th>ASCII</th><th>Bin.Data</th><th>Hex Data</th></tr>
+<tr><td>Flag</td><td></td><td><tt>01111110</tt></td><td>7E</td></tr>
+<tr><td>A1</td><td>K</td><td><tt>10010110</tt></td><td>96</td></tr>
+<tr><td>A2</td><td>8</td><td><tt>01110000</tt></td><td>70</td></tr>
+<tr><td>A3</td><td>M</td><td><tt>10011010</tt></td><td>9A</td></tr>
+<tr><td>A4</td><td>M</td><td><tt>10011010</tt></td><td>9A</td></tr>
+<tr><td>A5</td><td>O</td><td><tt>10011110</tt></td><td>9E</td></tr>
+<tr><td>A6</td><td>space</td><td><tt>01000000</tt></td><td>40</td></tr>
+<tr><td>A7</td><td>SSID</td><td><tt>11100000</tt></td><td>E0</td></tr>
+<tr><td>A8</td><td>W</td><td><tt>10101110</tt></td><td>AE</td></tr>
+<tr><td>A9</td><td>B</td><td><tt>10000100</tt></td><td>84</td></tr>
+<tr><td>A10</td><td>4</td><td><tt>01100100</tt></td><td>68</td></tr>
+<tr><td>A11</td><td>J</td><td><tt>10010100</tt></td><td>94</td></tr>
+<tr><td>A12</td><td>F</td><td><tt>10001100</tt></td><td>8C</td></tr>
+<tr><td>A13</td><td>I</td><td><tt>10010010</tt></td><td>92</td></tr>
+<tr><td>A14</td><td>SSID</td><td><tt>01100000</tt></td><td>60</td></tr>
+<tr><td>A15</td><td>W</td><td><tt>10101110</tt></td><td>AE</td></tr>
+<tr><td>A16</td><td>B</td><td><tt>10000100</tt></td><td>84</td></tr>
+<tr><td>A17</td><td>4</td><td><tt>01101000</tt></td><td>68</td></tr>
+<tr><td>A18</td><td>J</td><td><tt>10010100</tt></td><td>94</td></tr>
+<tr><td>A19</td><td>F</td><td><tt>10001100</tt></td><td>8C</td></tr>
+<tr><td>A20</td><td>I</td><td><tt>10010010</tt></td><td>92</td></tr>
+<tr><td>A21</td><td>SSID</td><td><tt>11100011</tt></td><td>E3</td></tr>
+<tr><td>Control</td><td>I</td><td><tt>00111110</tt></td><td>3E</td></tr>
+<tr><td>PID</td><td>none</td><td><tt>11110000</tt></td><td>F0</td></tr>
+<tr><td>FCS</td><td>part 1</td><td><tt>xxxxxxxx</tt></td><td>HH</td></tr>
+<tr><td>FCS</td><td>part 2</td><td><tt>xxxxxxxx</tt></td><td>HH</td></tr>
+<tr><td>Flag</td><td></td><td><tt>01111110</tt></td><td>7E</td></tr>
+<tr><th colspan="2">Bit position</th><td><tt>76543210</tt></td><td></td></tr>
+</tbody><caption> Fig. 4A -- AX.25 frame in repeater mode </caption>
+</table>
+
+</center>
+
+<p>The  above frame is the same as <a href="#fig 3a">Fig. 3A</a>,
+except  for  the
+addition of a repeater-address subfield (WB4JFI, SSID=1).  The  H
+bit is set, indicating this is from the output of the repeater.
+
+<a name="2.2.13.3"></a>
+</p><h5>2.2.13.3 Multiple Repeater Operation</h5>
+
+The  link-layer AX.25 protocol allows  operation  through
+more  than  one  repeater, creating  a  primitive  frame  routing
+mechanism.   Up to eight repeaters may be used by  extending  the
+repeater-address subfield.  When there is more than one  repeater
+address,  the repeater address immediately following  the  source
+address  subfield  will be considered the address  of  the  first
+repeater  of  a multiple-repeater chain.  As a  frame  progresses
+through  a chain of repeaters, each successive repeater will  set
+the  H bit (has-been-repeated bit) in its SSID octet,  indicating
+that  the  frame has been successfully repeated through  it.   No
+other  changes  to the frame are made (except for  the  necessary
+recalculation of the FCS).  The destination station can determine
+the  route  the frame took to each it by  examining  the  address
+field.
+
+<p>The  number of repeater addresses is variable.   All  but
+the last repeater address will have the address extension bits of
+all  octets  set to zero, as will all but the  last  octet  (SSID
+octet) of the last repeater address.  The last octet of the  last
+repeater address will have the address extension bit set to  one,
+indicating the end of the address field.
+
+</p><p>It should be noted that various timers (see
+<a href="#2.4.7">2.4.7</a>, below)
+may  have  to be adjusted to accommodate  the  additional  delays
+encountered  when a frame must pass through  a  multiple-repeater
+chain,  and  the return acknowledgement must travel  through  the
+same path before reaching the source device.
+
+</p><p>It  is anticipated that multiple-repeater operation is  a
+temporary method of interconnecting stations over large distances
+until  such  time that a layer 3 protocol is in use.   Once  this
+layer 3 protocol becomes operational, repeater chaining should be
+phased out.
+
+<a name="2.3"></a>
+</p><h2>2.3  Elements of Procedure</h2>
+
+<a name="2.3.1"></a>
+<h4>2.3.1</h4>
+     The  elements of procedure are defined in terms  of  actions
+that occur on receipt of frames.
+
+<a name="2.3.2"></a>
+<h4>2.3.2  Control-Field Formats and State Variables</h4>
+
+<a name="2.3.2.1"></a>
+<h5>2.3.2.1  Control-Field Formats</h5>
+
+The control field is responsible for identifying the type
+of  frame  being sent, and is also used to  convey  commands  and
+responses  from  one  end of the link to the other  in  order  to
+maintain proper link control.
+
+<p>The  control  fields  used in AX.25 use  the  CCITT  X.25
+control fields for balanced operation (LAPB), with an  additional
+control field taken from ADCCP to allow connectionless and round-
+table operation.
+
+</p><p>There are three general types of AX.25 frames.  They  are
+the Information frame (I frame), the Supervisory frame (S frame),
+and  the  Unnumbered  frame (U frame).  Fig. 5  shows  the  basic
+format  of  the  control field associated  with  these  types  of
+frames.
+
+</p><p></p><center>
+
+<a name="fig 5"></a>
+<table cellpadding="8" border="1">
+<tbody><tr><th rowspan="2" align="center"> Control-Field<br>Type
+</th><th colspan="8" align="center">Control-Field Bits</th></tr>
+<tr><td align="center">7</td><td align="center">6</td><td align="center">5</td><td align="center">4
+</td><td align="center">3</td><td align="center">2</td><td align="center">1</td><td align="center">0</td></tr>
+<tr><th>I Frame</th><td colspan="3" align="center">N(R)</td><td align="center">P
+</td><td colspan="3" align="center">N(S)</td><td>0</td></tr>
+<tr><th>S Frame</th><td colspan="3" align="center">N(R)</td><td>P/F</td><td>S</td><td>S</td><td>0</td><td>1</td></tr>
+<tr><th>U Frame</th><td>M</td><td>M</td><td>M</td><td>P/F</td><td>M</td><td>M</td><td>1</td><td>1</td></tr>
+</tbody><caption>Fig. 5 -- Control-field formats</caption>
+</table></center>
+
+<p><i>Where</i>:
+</p><ol compact="compact">
+<li>Bit 0 is the first bit sent and bit 7 is the last bit
+    sent of the control field.
+</li><li>N(S) is the send sequence number (bit 1 is the LSB).
+</li><li>N(R)  is  the receive sequence number (bit 5  is  the
+    LSB).
+</li><li>The  "S" bits are the supervisory function bits,  and
+    their encoding is discussed in <a href="#2.3.4.2">2.3.4.2</a>.
+</li><li>The  "M" bits are the unnumbered frame modifier  bits
+    and their encoding is discussed in <a href="#2.3.4.3">2.3.4.3</a>.
+</li><li>The  P/F bit is the Poll/Final bit.  Its function  is
+    described in <a href="#2.3.3">2.3.3</a>.  The distinction between  command
+    and response, and therefore the distinction between P
+    bit and F bit, is made by addressing rules  discussed
+    in <a href="#2.4.1.2">2.4.1.2</a>.
+</li></ol>
+
+<a name="2.3.2.1.1"></a>
+<h4>2.3.2.1.1  Information-Transfer Format</h4>
+
+All I frames have bit 0 of the control field set to zero.
+N(S)  is  the sender's send sequence number  (the  send  sequence
+number  of  this frame).  N(R) is the sender's  receive  sequence
+number (the sequence number of the next expected received frame).
+These numbers are described in <a href="#2.3.2.4">2.3.2.4</a>.
+In addition, the P/F bit
+is to be used as described in <a href="#2.4.2">2.4.2</a>.
+
+
+<a name="2.3.2.1.2"></a>
+<h4>2.3.2.1.2  Supervisory Format</h4>
+
+Supervisory  frames  are denoted by having bit 0  of  the
+control  field set to one, and bit 1 of the control field set  to
+zero.   S  frames  provide  supervisory  link  control  such   as
+acknowledging or requesting retransmission of I frames, and link-
+level window control.  Since S frames do not have an  information
+field,  the  sender's send variable and  the  receiver's  receive
+variable are not incremented for S frames.  In addition, the  P/F
+bit is used as described in
+<a href="#2.4.2">2.4.2</a>.
+
+<a name="2.3.2.1.3"></a>
+<h4>2.3.2.1.3  Unnumbered Format</h4>
+
+Unnumbered frames are distinguished by having both bits 0
+and 1 of the control field set to one.  U frames are  responsible
+for  maintaining additional control over the link beyond what  is
+accomplished  with  S  frames.  They  are  also  responsible  for
+establishing  and  terminating link connections.  U  frames  also
+allow  for the transmission and reception of information  outside
+of   the  normal  flow  control.   Some  U  frames  may   contain
+information and PID fields.  The P/F bit is used as described  in
+<a href="#2.4.2">2.4.2</a>.
+
+<a name="2.3.2.2"></a>
+<h5>2.3.2.2  Control-Field Parameters</h5>
+
+<a name="2.3.2.3"></a>
+<h5>2.3.2.3  Sequence Numbers</h5>
+
+Every  AX.25  I  frame shall be  assigned,  modulo  8,  a
+sequential  number  from  0 to 7.  This will allow  up  to  seven
+outstanding I frames per level 2 connection at a time.
+
+<a name="2.3.2.4"></a>
+<h5>2.3.2.4  Frame Variables and Sequence Numbers</h5>
+
+<a name="2.3.2.4.1"></a>
+<h4>2.3.2.4.1  Send State Variable V(S)</h4>
+
+The send state variable is a variable that is internal to
+the  DXE  and  is never sent.  It contains  the  next  sequential
+number  to  be assigned to the next transmitted  I  frame.   This
+variable is updated upon the transmission of each I frame.
+
+<a name="2.3.2.4.1"></a>
+<h4>2.3.2.4.2  Send Sequence Number N(S)</h4>
+
+The send sequence number is found in the control field of
+all  I  frames.  It contains the sequence number of the  I  frame
+being sent.  Just prior to the transmission of the I frame,  N(S)
+is updated to equal the send state variable.
+
+
+<a name="2.3.2.4.3"></a>
+<h4>2.3.2.4.3 Receive State Variable V(R)</h4>
+
+The receive state variable is a variable that is internal
+to the DXE.  It contains the sequence number of the next expected
+received I frame.  This variable is updated upon the reception of
+an  error-free  I  frame whose send sequence  number  equals  the
+present received state variable value.
+
+<a name="2.3.2.4.4"></a>
+<h4>2.3.2.4.4  Received Sequence Number N(R)</h4>
+
+The  received sequence number is in both I and S  frames.
+Prior  to  sending an I or S frame, this variable is  updated  to
+equal  that  of  the received  state  variable,  thus  implicitly
+acknowledging  the  proper  reception of all  frames  up  to  and
+including N(R)-1.
+
+<a name="2.3.3"></a>
+<h4>2.3.3  Functions of Poll/Final (P/F) Bit</h4>
+
+The  P/F bit is used in all types of frames.  It is  used
+in  a  command  (poll) mode to request an immediate  reply  to  a
+frame.   The  reply  to this poll is  indicated  by  setting  the
+response  (final)  bit  in  the  appropriate  frame.   Only   one
+outstanding  poll condition per direction is allowed at  a  time.
+The procedure for P/F bit operation is described in
+<a href="#2.4.2">2.4.2</a>.
+
+<a name="2.3.4"></a>
+<h4>2.3.4  Control Field Coding for Commands and Responses</h4>
+
+The following commands and responses, indicated by  their
+control field encoding, are to be use by the DXE:
+
+<a name="2.3.4.1"></a>
+<h5>2.3.4.1  Information Command Frame Control Field</h5>
+
+The  function  of  the  information  (I)  command  is  to
+transfer   across  a  data  link  sequentially  numbered   frames
+containing an information field.
+
+<p>The  information-frame control field is encoded as  shown
+in  Fig. 6.  These frames are sequentially numbered by  the  N(S)
+subfield to maintain control of their passage over the link-layer
+connection.
+
+</p><p></p><center>
+
+<a name-"fig="" 6"=""><table cellpadding="8" border="1">
+<tbody><tr><th colspan="8" align="center">Control Field Bits</th></tr>
+<tr><th>7</th><th>6</th><th>5</th><th>4</th><th>3</th><th>2</th><th>1</th><th>0</th></tr>
+<tr><td colspan="3" align="center"> N(R)
+</td><td>P</td><td colspan="3" align="center"> N(S) </td><td>0</td></tr>
+</tbody><caption>Fig. 6 -- I frame control field</caption>
+</table>
+
+</a></center><a name-"fig="" 6"="">
+
+</a><a name="2.3.4.2"></a>
+<h5>2.3.4.2  Supervisory Frame Control Field</h5>
+
+The supervisory frame control fields are encoded as shown
+in Fig. 7.
+
+<p></p><center>
+
+<a name-"fig="" 7"=""><table cellpadding="8" border="1">
+<tbody><tr><th colspan="2" rowspan="2" align="center">Control Field Type
+</th><th colspan="8" align="center">Control Field Bits</th></tr>
+<tr><th>7</th><th>6</th><th>5</th><th>4</th><th>3</th><th>2</th><th>1</th><th>0</th></tr>
+<tr><td>Receive Ready</td><td>RR
+</td><td colspan="3" align="center">N(R)</td><td>P/F</td><td>0</td><td>0</td><td>0</td><td>1</td></tr>
+<tr><td>Receive Not Ready</td><td>RNR
+</td><td colspan="3" align="center">N(R)</td><td>P/F</td><td>0</td><td>1</td><td>0</td><td>1</td></tr>
+<tr><td>Reject</td><td>REJ
+</td><td colspan="3" align="center">N(R)</td><td>P/F</td><td>1</td><td>0</td><td>0</td><td>1</td></tr>
+</tbody><caption>
+Fig. 7 -- S frame control fields
+</caption>
+</table>
+
+<p>
+
+</p><table width="75%" border="1">
+<caption> The Frame identifiers: </caption>
+<tbody><tr><td width="25%">C or SABM</td><td>Layer 2 Connect Request</td></tr>
+<tr><td>D or DISC</td><td>Layer 2 Disconnect Request</td></tr>
+<tr><td>I </td><td>Information Frame</td></tr>
+<tr><td>RR </td><td>Receive Ready. System Ready To Receive</td></tr>
+<tr><td>RNR or NR</td><td>Receive Not Ready. TNC Buffer Full</td></tr>
+<tr><td>RJ or REJ</td><td>Reject Frame. Out of Sequence or Duplicate</td></tr>
+<tr><td>FRMR </td><td>Frame Reject. Fatal Error</td></tr>
+<tr><td>UI </td><td>Unnumbered Information Frame. "Unproto"</td></tr>
+<tr><td>DM </td><td>Disconnect Mode. System Busy or Disconnected.</td></tr>
+</tbody></table>
+
+</a></center><a name-"fig="" 7"="">
+
+</a><a name="2.3.4.2.1"></a>
+<h4>2.3.4.2.1  Receive Ready (RR) Command and Response</h4>
+
+Receive Ready is used to do the following:
+<ol compact="compact">
+<li>to  indicate  that the sender of the RR is  now  able  to
+       receive more I frames.
+</li><li>to  acknowledge  properly  received I frames  up  to,  and
+       including N(R)-1, and
+</li><li>to clear a previously set busy condition created by an RNR
+       command having been sent.
+</li></ol>
+<p>The status of the DXE at the other end of the link can be
+requested  by  sending a RR command frame with the P-bit  set  to
+one.
+
+<a name="2.3.4.2.2"></a>
+</p><h4>2.3.4.2.2  Receive Not Ready (RNR) Command and Response</h4>
+
+Receive Not Ready is used to indicate to the sender of  I
+frames  that  the receiving DXE is temporarily  busy  and  cannot
+accept any more I frames.  Frames up to N(R)-1 are  acknowledged.
+Any I frames numbered N(R) and higher that might have been caught
+between states and not acknowledged when the RNR command was sent
+are not acknowledged.
+
+<p>The RNR condition can be cleared by the sending of a  UA,
+RR, REJ, or SABM frame.
+
+</p><p>The status of the DXE at the other end of the link can be
+requested  by sending a RNR command frame with the P bit  set  to
+one.
+
+<a name="2.3.4.2.3"></a>
+</p><h4>2.3.4.2.3  Reject (REJ) Command and Response</h4>
+
+The  reject frame is used to request retransmission of  I
+frames  starting  with N(R).  Any frames that were  sent  with  a
+sequence number of N(R)-1 or less are acknowledged.  Additional I
+frames may be appended to the retransmission of the N(R) frame if
+there are any.
+
+<p>Only  one  reject  frame condition  is  allowed  in  each
+direction  at  a time.  The reject condition is  cleared  by  the
+proper  reception of I frames up to the I frame that  caused  the
+reject condition to be initiated.
+
+</p><p>The status of the DXE at the other end of the link can be
+requested  by sending a REJ command frame with the P bit  set  to
+one.
+
+<a name="2.3.4.3"></a>
+</p><h5>2.3.4.3  Unnumbered Frame Control Fields</h5>
+
+Unnumbered  frame control fields are either  commands  or
+responses.
+
+<p>Fig.  8 shows the layout of U frames  implemented  within
+this protocol.
+
+
+</p><p></p><center>
+
+<a name="fig 8"></a>
+<table cellpadding="8" border="1">
+<tbody><tr><th colspan="3" rowspan="2" align="center">Control Field Type
+</th><th colspan="8" align="center">Control Field Bits</th></tr>
+<tr><th>7</th><th>6</th><th>5</th><th>4</th><th>3</th><th>2</th><th>1</th><th>0</th></tr>
+<tr><td>Set Asynchronous Balanced Mode</td><td>SABM</td><td>Res
+</td><td>0</td><td>0</td><td>1</td><td>P</td><td>1</td><td>1</td><td>1</td><td>1</td></tr>
+<tr><td>Disconnect</td><td>DISC</td><td>Cmd
+</td><td>0</td><td>1</td><td>0</td><td>P</td><td>0</td><td>0</td><td>1</td><td>1</td></tr>
+<tr><td>Disconnected Mode</td><td>DM</td><td>Res
+</td><td>0</td><td>0</td><td>0</td><td>F</td><td>1</td><td>1</td><td>1</td><td>1</td></tr>
+<tr><td>Unnumbered Acknowledge</td><td>UA</td><td>Res
+</td><td>0</td><td>1</td><td>1</td><td>F</td><td>0</td><td>0</td><td>1</td><td>1</td></tr>
+<tr><td>Frame Reject</td><td>FRMR</td><td>Res
+</td><td>1</td><td>0</td><td>0</td><td>F</td><td>0</td><td>1</td><td>1</td><td>1</td></tr>
+<tr><td>Unnumbered Information</td><td>UI</td><td>Either
+</td><td>0</td><td>0</td><td>0</td><td>P/F</td><td>0</td><td>0</td><td>1</td><td>1</td></tr>
+</tbody><caption>
+Fig. 8 -- U frame control fields
+</caption>
+</table>
+
+</center>
+
+<a name="2.3.4.3.1"></a>
+<h4>2.3.4.3.1  Set Asynchronous Balanced Mode (SABM) Command</h4>
+
+The  SABM  command  is  used  to  place  2  DXEs  in  the
+asynchronous balanced mode.  This is a balanced mode of operation
+known as LAPB where both devices are treated as equals.
+
+<p>Information fields are not allowed in SABM commands.  Any
+outstanding  I frames left when the SABM command is  issued  will
+remain unacknowledged.
+
+</p><p>The  DXE  confirms  reception and acceptance  of  a  SABM
+command   by  sending  a  UA  response  frame  at  the   earliest
+opportunity.   If  the  DXE is not capable of  accepting  a  SABM
+command, it should respond with a DM frame if possible.
+
+<a name="2.3.4.3.2"></a>
+</p><h4>2.3.4.3.2  Disconnect (DISC) Command</h4>
+
+The  DISC  command is used to terminate  a  link  session
+between  two  stations.  No information field is permitted  in  a
+DISC command frame.
+
+<p>Prior  to  acting on the DISC frame,  the  receiving  DXE
+confirms acceptance of the DISC by issuing a UA response frame at
+its  earliest opportunity.  The DXE sending the DISC  enters  the
+disconnected state when it receives the UA response.
+
+</p><p>Any  unacknowledged  I frames left when this  command  is
+acted upon will remain unacknowledged.
+
+<a name="2.3.4.3.3"></a>
+</p><h4>2.3.4.3.3  Frame Reject (FRMR) Response</h4>
+
+<a name="2.3.4.3.3.1"></a>
+<h4>2.3.4.3.3.1</h4>
+
+<p>The FRMR response frame is sent to report that the  receiver
+of  a frame cannot successfully process that frame and  that  the
+error condition is not correctable by sending the offending frame
+again.  Typically this condition will appear when a frame without
+an  FCS  error  has  been received  with  one  of  the  following
+conditions:
+
+</p><ol compact="compact">
+<li>The  reception of an invalid or not implemented  command  or
+     response frame.
+</li><li>The reception of an I frame whose information field  exceeds
+     the agreed-upon length.
+(See <a href="#2.4.7.3">2.4.7.3</a>, below.)
+</li><li>The  reception  of an improper N(R).  This  usually  happens
+     when the N(R) frame has already been sent and  acknowledged,
+     or when N(R) is out of sequence with what was expected.
+</li><li>The reception of a frame with an information field where one
+     is  not  allowed, or the reception of a U or S  frame  whose
+     length is incorrect.  Bits W and Y described in
+<a href="#2.3.4.3.3.2">2.3.4.3.3.2</a>
+     should both be set to one to indicate this condition.
+</li><li>The reception of a supervisory frame with the F bit set
+     to  one,  except  during a  timer  recovery  condition  (see
+     <a href="#2.4.4.9">2.4.4.9</a>),
+or except as a reply to a command frame sent  with
+     the  P  bit  set to one. Bit W  (described  in
+<a href="#2.3.4.3.3.2">2.3.4.3.3.2</a>)
+     should be set to one.
+</li><li>The reception of an unexpected UA or DM response frame.  Bit
+     W should be set to one.
+</li><li>The reception of a frame with an invalid N(S).  Bit W should be
+     set to one.
+</li></ol>
+
+<p>An  invalid N(R) is defined as one which points to  an  I
+frame  that previously has been transmitted and acknowledged,  or
+an  I  frame which has not been transmitted and is not  the  next
+sequential I frame pending transmission.
+
+</p><p>An  invalid N(S) is defined as an N(S) that is  equal  to
+the  last transmitted N(R)+k and is equal to the  received  state
+variable  V(R),  where  k is the maximum  number  of  outstanding
+information frames as defined in <a href="#2.4.7.4">2.4.7.4</a> below.
+
+</p><p>An  invalid  or not implemented command  or  response  is
+defined  as a frame with a control field that is unknown  to  the
+receiver of this frame.
+
+<a name="2.3.4.3.3.2"></a>
+</p><h4>2.3.4.3.3.2</h4>
+When a FRMR frame is sent, an information field is added  to
+the  frame that contains additional information indicating  where
+the  problem  occurred.  This information field is  three  octets
+long and is shown in Fig. 9.
+
+<p></p><center>
+<table cellpadding="4" border="1">
+<tbody><tr><th colspan="24" align="center">Information Field Bits</th></tr>
+<tr><td>23</td><td>22</td><td>21</td><td>20</td><td>19</td><td>18</td><td>17</td><td>16
+</td><td>15</td><td>14</td><td>13</td><td>12</td><td>11</td><td>10</td><td>9</td><td>8
+</td><td>7</td><td>6</td><td>5</td><td>4</td><td>3</td><td>2</td><td>1</td><td>0</td></tr>
+<tr><td>0</td><td>0</td><td>0</td><td>0</td><td>Z</td><td>Y</td><td>X</td><td>W
+</td><td colspan="3" align="center">V(R)</td><td>C<br>R
+</td><td colspan="3" align="center">V(S)</td><td>0
+</td><td colspan="8" align="center">Rejected Frame<br>Control Field</td></tr>
+</tbody><caption>
+Fig. 9 -- FRMR frame information field
+</caption>
+</table>
+
+</center>
+
+<p><i>Where</i>:
+</p><ol compact="compact">
+<li>The rejected frame control field carries the control  field
+      of  the frame that caused the reject condition.  It  is  in
+      bits 0-7 of the information field.
+</li><li>V(S)  is  the  current send state variable  of  the  device
+      reporting the rejection (bit 9 is the low bit).
+</li><li>The  CR bit is set to zero to indicate the  rejected  frame
+      was a command, or one if it was a response.
+</li><li>V(R)  is the current receive state variable of  the  device
+      reporting rejection (bit 13 is the low bit).
+</li><li>If W is set to 1, the control field received was invalid or
+      not implemented.
+</li><li>If  X  is  set  to 1, the  frame  that  caused  the  reject
+      condition  was considered invalid because it was a U  or  S
+      frame  that had an information field that is  not  allowed.
+      Bit W must be set to 1 in addition to the X bit.
+</li><li>If  Y is set to 1, the control field received and  returned
+      in   bits   exceeded  the  maximum   allowed   under   this
+      recommendation in <a href="#2.4.7.3">2.4.7.3</a>, below.
+</li><li>If  A is set to 1, the control field received and  returned
+      in bits 1 to 8 contained an invalid N(R).
+</li><li>Bits 8, and 20 to 23 are set to 0.
+</li></ol>
+
+
+<a name="2.3.4.3.4"></a>
+<h4>2.3.4.3.4  Unnumbered Acknowledge (UA) Response</h4>
+
+The  UA  response  frame  is  sent  to  acknowledge   the
+reception  and  acceptance of a SABM or DISC  command  frame.   A
+received command is not actually processed until the UA  response
+frame  is  sent.  Information fields are not permitted  in  a  UA
+frame.
+
+
+<a name="2.3.4.3.5"></a>
+<h4>2.3.4.3.5  Disconnected Mode (DM) Response</h4>
+
+The  disconnected  mode response is sent whenever  a  DXE
+receives  a  frame  other  than a SABM or UI  frame  while  in  a
+disconnected  mode.   It  is  also sent to  request  a  set  mode
+command,  or  to indicate it cannot accept a  connection  at  the
+moment.  The DM response does not have an information field.
+
+<p>Whenever a SABM frame is a received, and it is determined
+that  a  connection is not possible, a DM frame  shall  be  sent.
+This  will  indicate  that the called  station  cannot  accept  a
+connection at that time.
+
+</p><p>While a DXE is in the disconnected mode, it will  respond
+to  any command other than a SABM or UI frame with a DM  response
+with the P/F bit set to 1.
+
+
+<a name="2.3.4.3.6"></a>
+</p><h4>2.3.4.3.6  Unnumbered Information (UI) Frame</h4>
+
+<p>The   Unnumbered  Information  frame  contains  PID   and
+information fields and is used to pass information along the link
+outside the normal information controls.  This allows information
+fields  to go back and forth on the link bypassing flow  control.
+Since   these  frames  are  not  acknowledgeable,  if  one   gets
+obliterated, there is no way to recover it.  A received UI  frame
+with  the  P bit set shall cause a response  to  be  transmitted.
+This response shall be a DM frame when in the disconnected  state
+or  a  RR  (or  RNR, if appropriate)  frame  in  the  information
+transfer state.
+
+<a name="2.3.5"></a>
+</p><h4>2.3.5  Link Error Reporting and Recovery</h4>
+
+There are several link-layer errors that are  recoverable
+without  terminating the connection.  These error situations  may
+occur  as  a  result  of  malfunctions  within  the  DXE,  or  if
+transmission errors occur.
+
+<a name="2.3.5.1"></a>
+<h5>2.3.5.1 DXE Busy Condition</h5>
+
+When  a  DXE  becomes temporarily  unable  to  receive  I
+frames,  such  as when receive buffers are full, it will  send  a
+Receive Not Ready (RNR) frame.  This informs the other DXE   that
+this  DXE  cannot handle any more I frames at the  moment.   This
+condition is usually cleared by the sending of a UA, RR, REJ,  or
+SABM command frame.
+
+
+<a name="2.3.5.2"></a>
+<h5>2.3.5.2 Send Sequence Number Error</h5>
+
+If the send sequence number, N(S), of an otherwise error-
+free  received frame does not match the receive  state  variable,
+V(R),  a  send sequence error has occurred, and  the  information
+field will be discarded.  The receiver will not acknowledge  this
+frame, or any other I frames, until N(S) matches V(R).
+
+<p>The  control  field of the erroneous I frame(s)  will  be
+accepted so that link supervisory functions such as checking  the
+P/F  bit can still be performed.  Because of this  updating,  the
+retransmitted I frame may have an updated P bit and N(R).
+
+
+<a name="2.3.5.3"></a>
+</p><h5>2.3.5.3 Reject (REJ) Recovery</h5>
+
+REJ  is  used  to request a retransmission  of  I  frames
+following  the  detection  of a N(S) sequence  error.   Only  one
+outstanding  "sent  REJ" condition is allowed at  a  time.   This
+condition  is  cleared  when  the  requested  I  frame  has  been
+received.
+
+<p>A DXE receiving the REJ command will clear the  condition
+by  resending all outstanding I frames (up to the  window  size),
+starting with the one indicated in N(R) of the REJ frame.
+
+
+<a name="2.3.5.4"></a>
+</p><h5>2.3.5.4 Time-out Error Recovery</h5>
+
+
+<a name="2.3.5.4.1"></a>
+<h4>2.3.5.4.1 T1 Timer Recovery</h4>
+
+If  a DXE, due to a transmission error, does not  receive
+(or  receives and discards) a single I frame or the last I  frame
+in  a sequence of I frames, it will not detect  a  send-sequence-
+number  error,  and therefore will not transmit a REJ.   The  DXE
+which transmitted the unacknowledged I frame(s) shall,  following
+the  completion of time-out period T1, take appropriate  recovery
+action  to determine when I frame retransmission should begin  as
+described  in <a href="#2.4.4.9">2.4.4.9</a>, below.  This condition is cleared  by  the
+reception of an acknowledgement for the sent frame(s), or by  the
+link being reset.  See <a href="#2.4.6">2.4.6</a>.
+
+
+<a name="2.3.5.4.2"></a>
+<h4>2.3.5.4.2 Timer T3 Recovery</h4>
+
+Timer  T3 is used to assure the link is still  functional
+during  periods of low information transfer.  Whenever T1 is  not
+running  (no  outstanding I frames), T3 is used  to  periodically
+poll  the  other DXE of a link.  When T3 times out, a RR  or  RNR
+frame  is transmitted as a command and with the P bit  set.   The
+waiting  acknowledgement  procedure  (<a href="#2.4.4.9">2.4.4.9</a>,  below)  is   then
+executed.
+
+
+<a name="2.3.5.5"></a>
+<h5>2.3.5.5 Invalid Frame or FCS Error</h5>
+
+If  an invalid frame is received, or a frame is  received
+with  an FCS error, that frame will be discarded with  no  action
+taken.
+
+
+<a name="2.3.5.6"></a>
+<h5>2.3.5.6 Frame Rejection Condition</h5>
+
+A  frame  rejection condition occurs  when  an  otherwise
+error-free  frame  has been received with one of  the  conditions
+listed in <a href="#2.3.4.3.3">2.3.4.3.3</a> above.
+
+<p>Once  a  rejection  error occurs, no more  I  frames  are
+accepted  (except for the examination of the P/F bit)  until  the
+error is resolved.  The error condition is reported to the  other
+DXE by sending a FRMR response frame.  See <a href="#2.4.5">2.4.5</a>.
+
+
+<a name="2.4"></a>
+</p><h2>2.4 Description of AX.25 Procedures</h2>
+
+The  following  describes the procedures used  to  setup,
+use, and disconnect a balanced link between two DXE stations.
+
+
+<a name="2.4.1"></a>
+<h4>2.4.1 Address Field Operation</h4>
+
+
+<a name="2.4.1.1"></a>
+<h5>2.4.1.1 Address Information</h5>
+
+All   transmitted  frames  shall  have   address   fields
+conforming  to  <a href="#2.2.13">2.2.13</a>, above.  All frames shall  have  both  the
+destination device and the source device addresses in the address
+field,  with the destination address coming first.   This  allows
+many links to share the same RF channel.  The destination address
+is  always  the address of the station(s) to receive  the  frame,
+while the source address contains the address of the device  that
+sent the frame.
+
+<p>The destination address can be a group name or club  call
+sign if the point-to-multipoint operation is allowed.   Operation
+with  destination addresses other than actual amateur call  signs
+is a subject for further study.
+
+
+<a name="2.4.1.2"></a>
+</p><h5>2.4.1.2 Command/Response Procedure</h5>
+
+AX.25  Version 2.0 has implemented  the  command/response
+information   in  the  address  field.   In  order  to   maintain
+compatibility    with   previous   versions   of    AX.25,    the
+command/response information is conveyed using two bits.
+
+<p>An  upward-compatible AX.25 DXE can determine whether  it
+is  communicating  with  a DXE using an  older  version  of  this
+protocol by testing the command/response bit information  located
+in  bit 7 of the SSID octets of both the destination  and  source
+address subfields.  If both C bits are set to zero, the device is
+using  the  older protocol.  The newer version  of  the  protocol
+always has one of these two bits set to one and the other set  to
+zero, depending on whether the frame is a command or a response.
+
+</p><p>The  command/response  information is  encoded  into  the
+address field as shown in Fig. 10.
+
+</p><p>
+</p><center>
+
+<a name="fig 10"></a>
+<table cellpadding="8" border="1">
+<tbody><tr><th>Frame Type</th><th>Dest. SSID C-Bit</th><th>Source SSID C-Bit</th></tr>
+<tr><td>Previous versions</td><td align="center">0</td><td align="center">0</td></tr>
+<tr><td>Command (V.2.0)</td><td align="center">1</td><td align="center">0</td></tr>
+<tr><td>Response (V.2.0)</td><td align="center">0</td><td align="center">1</td></tr>
+<tr><td>Previous versions</td><td align="center">1</td><td align="center">1</td></tr>
+</tbody><caption>Fig. 10 -- Command/Response encoding</caption>
+</table>
+
+</center>
+
+<p>Since  all  frames  are  considered  either  commands  or
+responses, a device shall always have one of the bits set to one,
+and the other bit set to zero.
+
+</p><p>The  use  of the command/response  information  in  AX.25
+allows  S frames to be either commands or responses.   This  aids
+maintenance   of  proper  control  over  the  link   during   the
+information transfer state.
+
+
+<a name="2.4.2"></a>
+</p><h4>2.4.2  P/F Bit Procedures</h4>
+
+The next response frame returned by the DXE to a SABM  or
+DISC command with the P bit set to 1 will be a UA or DM  response
+with the F bit set to 1.
+
+<p>The next response frame returned to an I frame with the P
+bit  set  to 1, received during the information  transfer  state,
+will be a RR, RNR, or REJ response with the F bit set to 1.
+
+</p><p>The next response frame returned to a supervisory command
+frame  with the P bit set to 1, received during  the  information
+transfer state, will be a RR, RNR, or REJ response frame with the
+F bit set to 1.
+
+</p><p>The  next  response frame returned to a S  or  I  command
+frame  with  the  P bit set to 1, received  in  the  disconnected
+state, will be a DM response frame with the F bit set to 1.
+
+</p><p>The  P  bit  is used in  conjunction  with  the  time-out
+recovery condition discussed in <a href="#2.3.5.4">2.3.5.4</a>, above.
+
+</p><p>When not used, the P/F bit is set to zero.
+
+
+<a name="2.4.3"></a>
+</p><h4>2.4.3 Procedures For Link Set-Up and Disconnection</h4>
+
+
+<a name="2.4.3.1"></a>
+<h5>2.4.3.1 LAPB Link Connection Establishment</h5>
+
+When  one DXE wishes to connect to another DXE,  it  will
+send  a SABM command frame to that device and start  timer  (T1).
+If  the other DXE is there and able to connect, it  will  respond
+with  a UA response frame, and reset both of its  internal  state
+variables  (V(S)  and V(R)).  The reception of  the  UA  response
+frame  at  the  other  end will  cause  the  DXE  requesting  the
+connection  to  cancel the T1 timer and set  its  internal  state
+variables to 0.
+
+<p>If the other DXE doesn't respond before T1 times out, the
+device requesting the connection will re-send the SABM frame, and
+start  T1  running  again.  The DXE should  continue  to  try  to
+establish  a  connection  until it has  tried  unsuccessfully  N2
+times.  N2 is defined in <a href="#2.4.7.2">2.4.7.2</a>, below.
+
+</p><p>If,  upon  reception of a SABM command, the  DXE  decides
+that  it  cannot enter the indicated state, it should send  a  DM
+frame.
+
+</p><p>When  receiving a DM response, the DXE sending  the  SABM
+should  cancel  its  T1 timer, and  not  enter  the  information-
+transfer state.
+
+</p><p>The  DXE sending a SABM command will ignore  and  discard
+any  frames except SABM, DISC, UA, and DM frames from  the  other
+DXE.
+
+</p><p>Frames  other  than UA and DM in response to  a  received
+SABM  will  be  sent  only after the link is set  up  and  if  no
+outstanding SABM exists.
+
+<a name="2.4.3.2"></a>
+</p><h5>2.4.3.2 Information-Transfer Phase</h5>
+
+After establishing a link connection, the DXE will  enter
+the  information-transfer  state.  In this state,  the  DXE  will
+accept  and  transmit I and S frames according to  the  procedure
+outlined in <a href="#2.4.4">2.4.4</a>, below.
+
+<p>When  receiving a SABM command while in the  information-
+transfer  state,  the  DXE will follow  the  resetting  procedure
+outlined in <a href="#2.4.6">2.4.6</a> below.
+
+<a name="2.4.3.3"></a>
+</p><h5>2.4.3.3 Link Disconnection</h5>
+
+<a name="2.4.3.3.1"></a>
+<h4>2.4.3.3.1 </h4>
+     While  in  the information-transfer state,  either  DXE  may
+indicate a request to disconnect the link by transmitting a  DISC
+command frame and starting timer T1 (see <a href="#2.4.7">2.4.7</a>).
+
+<a name="2.4.3.3.2"></a>
+<h4>2.4.3.3.2 </h4>
+     A DXE, upon receiving a valid DISC command, shall send a  UA
+response  frame  and enter the disconnected state.  A  DXE,  upon
+receiving  a  UA  or DM response to a sent  DISC  command,  shall
+cancel timer T1, and enter the disconnected state.
+
+<a name="2.4.3.3.3"></a>
+<h4>2.4.3.3.3 </h4>
+If  a UA or DM response is not correctly received before  T1
+times out, the DISC frame should be sent again and T1  restarted.
+If  this happens N2 times, the DXE should enter the  disconnected
+state.
+
+<a name="2.4.3.4"></a>
+<h5>2.4.3.4 Disconnected State</h5>
+
+<a name="2.4.3.4.1"></a>
+<h4>2.4.3.4.1 </h4>
+A  DXE  in  the disconnected state  shall  monitor  received
+commands  and react upon the reception of a SABM as described  in
+<a href="#2.4.3.1">2.4.3.1</a> above and will transmit a DM frame
+in response to a  DISC command.
+
+<a name="2.4.3.4.2"></a>
+<h4>2.4.3.4.2 </h4>
+In the disconnected state, a DXE may initiate a link  set-up
+as outlined in connection establishment above (<a href="#2.4.3.1">2.4.3.1</a>).  It  may
+also  respond  to  the  reception  of  a  SABM  and  establish  a
+connection, or it may ignore the SABM and send a DM instead.
+
+<a name="2.4.3.4.3"></a>
+<h4>2.4.3.4.3 </h4>
+Any  DXE receiving a command frame other than a SABM  or  UI
+frame  with the P bit set to one should respond with a  DM  frame
+with  the  F  bit  set to one.  The  offending  frame  should  be
+ignored.
+
+<a name="2.4.3.4.4"></a>
+<h4>2.4.3.4.4 </h4>
+When  the DXE enters the disconnected state after  an  error
+condition  or if an internal error has resulted in the DXE  being
+in  the  disconnected  state, the DXE  should  indicate  this  by
+sending  a  DM response rather than a DISC frame and  follow  the
+link  disconnection procedure outlined in <a href="#2.4.3.3.3">2.4.3.3.3</a>, above.   The
+DXE  may then try to re-establish the link using the link  set-up
+procedure outlined in <a href="#2.4.3.1">2.4.3.1</a>, above.
+
+<a name="2.4.3.5"></a>
+<h5>2.4.3.5 Collision Recovery</h5>
+
+<a name="2.4.3.5.1"></a>
+<h4>2.4.3.5.1 Collisions in a Half-Duplex Environment</h4>
+
+Collisions  of  frames in a half-duplex  environment  are
+taken  care  of  by  the  retry  nature  of  the  T1  timer   and
+retransmission count variable.  No other special action needs  to
+be taken.
+
+<a name="2.4.3.5.2"></a>
+<h4>2.4.3.5.2 Collisions of Unnumbered Commands</h4>
+
+If sent and received SABM or DISC command frames are  the
+same,  both  DXEs  should  send a UA  response  at  the  earliest
+opportunity, and both devices should enter the indicated state.
+
+<p>If sent and received SABM or DISC commands are different,
+both  DXEs should enter the disconnected state and transmit a  DM
+frame at the earliest opportunity.
+
+<a name="2.4.3.5.3"></a>
+</p><h4>2.4.3.5.3 Collision of a DM with a SABM or DISC</h4>
+
+When  an  unsolicited  DM  response  frame  is  sent,   a
+collision  between it and a SABM or DISC may occur.  In order  to
+prevent  this  DM from being misinterpreted, all  unsolicited  DM
+frames  should  be transmitted with the F bit set to  zero.   All
+SABM  and DISC frames should be sent with the P bit set  to  one.
+This will prevent any confusion when a DM frame is received.
+
+<a name="2.4.3.6"></a>
+<h5>2.4.3.6 Connectionless Operation</h5>
+
+In  Amateur  Radio,  there  is  an  additional  type   of
+operation  that is not feasible using level 2 connections.   This
+operation  is  the  round table, where several  amateurs  may  be
+engaged  in one conversation.  This type of operation  cannot  be
+accommodated by AX.25 link-layer connections.
+
+<p>The   way   round-table  activity   is   implemented   is
+technically  outside  the AX.25 connection, but still  using  the
+AX.25 frame structure.
+
+</p><p>AX.25 uses a special frame for this operation, called the
+Unnumbered  Information (UI) frame.  When this type of  operation
+is  used,  the  destination  address  should  have  a  code  word
+installed  in  it to prevent the users of that  particular  round
+table from seeing all frames going through the shared RF  medium.
+An example of this is if a group of amateurs are in a round-table
+discussion  about  packet  radio, they could put  PACKET  in  the
+destination  address,  so  they would receive  frames  only  from
+others in the same discussion.  An added advantage of the use  of
+AX.25  in this manner is that the source of each frame is in  the
+source  address  subfield,  so  software  could  be  written   to
+automatically display who is making what comments.
+
+</p><p>Since  this  mode  is connectionless, there  will  be  no
+requests for retransmissions of bad frames.  Collisions will also
+occur, with the potential of losing the frames that collided.
+
+<a name="2.4.4"></a>
+</p><h4>2.4.4 Procedures for Information Transfer</h4>
+
+Once  a  connection  has been  established,  as  outlined
+above, both devices are able to accept I, S, and U frames.
+
+<a name="2.4.4.1"></a>
+<h5>2.4.4.1 Sending I Frames</h5>
+
+Whenever  a DXE has an I frame to transmit, it will  send
+the  I frame with N(S) of the control field equal to its  current
+send  state  variable V(S).  Once the I frame is sent,  the  send
+state  variable  is  incremented  by one.  If  timer  T1  is  not
+running, it should be started.  If timer T1 is running, it should
+be restarted.
+
+<p>The DXE should not transmit any more I frames if its send
+state variable equals the last received N(R) from the other  side
+of  the link plus seven.  If it were to send more I  frames,  the
+flow control window would be exceed, and errors could result.
+
+</p><p>If  a  DXE is in a busy condition, it may  still  send  I
+frames as long as the other device is not also busy.
+
+</p><p>If  a  DXE is in the frame-rejection mode, it  will  stop
+sending I frames.
+
+<a name="2.4.4.2"></a>
+</p><h5>2.4.4.2 Receiving I Frames</h5>
+
+<a name="2.4.4.2.1"></a>
+<h4>2.4.4.2.1 </h4>
+If  a DXE receives a valid I frame (one with a  correct  FCS
+and  whose  send sequence number equals  the  receiver's  receive
+state variable) and is not in the busy condition, it will  accept
+the  received I frame, increment its receive state variable,  and
+act in one of the following manners:
+
+<ol compact="compact">
+<li> If it has an I frame to send, that I frame may be sent with the
+     transmitted N(R) equal to its receive state variable V(R) (thus
+     acknowledging the received frame).  Alternately, the device may
+     send a RR frame with N(R) equal to V(R), and then send the I
+     frame.
+</li><li> If there are no outstanding I frames, the receiving device will
+     send a RR frame with N(R) equal to V(R).  The receiving DXE may
+     wait a small period of time before sending the RR frame to be sure
+     additional I frames are not being transmitted.
+</li></ol>
+
+<a name="2.4.4.2.2"></a>
+<h4>2.4.4.2.2 </h4>
+If  the  DXE  is  in a busy condition,  it  may  ignore  any
+received  I  frames without reporting this condition  other  than
+repeating the indication of the busy condition.
+
+<p>If  a busy condition exists, the DXE receiving  the  busy
+condition   indication  should  poll  the  sender  of  the   busy
+indication periodically until the busy condition disappears.
+
+</p><p>A  DXE may poll the busy DXE periodically with RR or  RNR
+frames with the P bit set to one.
+
+</p><p>The  reception  of  I  frames  that  contain  zero-length
+information  fields  shall be reported to the next level  but  no
+information field will be transferred.
+
+<a name="2.4.4.3"></a>
+</p><h5>2.4.4.3 Reception of Out of Sequence Frames</h5>
+
+When  an I frame is received with a correct FCS, but  its
+send sequence number, N(S), does not match the current receiver's
+receive  state  variable, the frame should be discarded.   A  REJ
+frame  shall be sent with a receive sequence number equal to  one
+higher (modulo 8) than the last correctly received I frame if  an
+uncleared  N(S) sequence error condition has not been  previously
+established.   The  received state variable and poll bit  of  the
+discarded  frame should be checked and acted upon, if  necessary,
+before discarding the frame.
+
+<a name="2.4.4.4"></a>
+<h5>2.4.4.4 Reception of Incorrect Frames</h5>
+
+When  a  DXE receives a frame with an incorrect  FCS,  an
+invalid  frame, or a frame with an improper address,  that  frame
+shall be discarded.
+
+<a name="2.4.4.5"></a>
+<h5>2.4.4.5 Receiving Acknowledgement</h5>
+
+Whenever an I or S frame is correctly received, even in a
+busy condition, the N(R) of the received frame should be  checked
+to  see if it includes an acknowledgement of outstanding  sent  I
+frames.   The T1 timer should be cancelled if the received  frame
+actually  acknowledges previously unacknowledged frames.  If  the
+T1  timer is cancelled and there are still some frames that  have
+been sent that are not acknowledged, T1 should be started  again.
+If  the T1 timer runs out before an acknowledgement is  received,
+the  device  should proceed to the  retransmission  procedure  in
+<a href="#2.4.4.9">2.4.4.9</a>.
+
+<a name="2.4.4.6"></a>
+<h5>2.4.4.6 Receiving Reject</h5>
+
+Upon receiving a REJ frame, the transmitting DXE will set
+its  send  state variable to the same value as  the  REJ  frame's
+received sequence number in the control field.  The DXE will then
+retransmit  any  I  frame(s) outstanding at  the  next  available
+opportunity conforming to the following:
+
+<ol compact="compact">
+<li> If the DXE is not transmitting at the time, and the  channel
+     is  open,  the  device  may commence  to  retransmit  the  I
+     frame(s) immediately.
+</li><li>If   the   DXE  is  operating  on  a   full-duplex   channel
+     transmitting  a UI or S frame when it receives a REJ  frame,
+     it may finish sending the UI or S frame and then  retransmit
+     the I frame(s).
+ </li><li>If   the   DXE  is  operating  in  a   full-duplex   channel
+     transmitting  another I frame when it receives a REJ  frame,
+     it  may  abort  the  I  frame  it  was  sending  and   start
+     retransmission of the requested I frames immediately.
+</li><li> The DXE may send just the one I frame outstanding, or it may
+     send  more than the one indicated if more I frames  followed
+     the  first  one not acknowledged, provided the total  to  be
+     sent does not exceed the flow-control window (7 frames).
+</li></ol>
+
+<p>If the DXE receives a REJ frame with the poll bit set, it
+should  respond with either a RR or RNR frame with the final  bit
+set before retransmitting the outstanding I frame(s).
+
+<a name="2.4.4.7"></a>
+</p><h5>2.4.4.7 Receiving a RNR Frame</h5>
+
+Whenever  a  DXE  receives a RNR  frame,  it  shall  stop
+transmission  of  I  frames until the  busy  condition  has  been
+cleared.   If timer T1 runs out after the RNR was  received,  the
+waiting  acknowledgement  procedure  listed  in  <a href="#2.4.4.9">2.4.4.9</a>,  below,
+should  be  performed.  The poll bit may be used  in  conjunction
+with  S  frames  to test for a change in  the  condition  of  the
+busied-out DXE.
+
+<a name="2.4.4.8"></a>
+<h5>2.4.4.8 Sending a Busy Indication</h5>
+
+Whenever a DXE enters a busy condition, it will  indicate
+this  by sending a RNR response at the next  opportunity.   While
+the  DXE is in the busy condition, it may receive and  process  S
+frames,  and if a received S frame has the P bit set to one,  the
+DXE should send a RNR frame with the F bit set to one at the next
+possible  opportunity.   To  clear the busy  condition,  the  DXE
+should  send either a RR or REJ frame with the received  sequence
+number equal to the current receive state variable, depending  on
+whether the last received I frame was properly received or not.
+
+<a name="2.4.4.9"></a>
+<h5>2.4.4.9 Waiting Acknowledgement</h5>
+
+If timer T1 runs out waiting the acknowledgement from the
+other DXE for an I frame transmitted, the DXE will restart  timer
+T1  and transmit an appropriate supervisory command frame (RR  or
+RNR)  with  the  P  bit set.  If the  DXE  receives  correctly  a
+supervisory  response frame with the F bit set and with  an  N(R)
+within  the  range from the last N(R) received to the  last  N(S)
+sent  plus  one, the DXE will restart timer T1 and will  set  its
+send  state  variable  V(S) to the received N(R).   It  may  then
+resume   with   I  frame  transmission  or   retransmission,   as
+appropriate.  If, on the other hand, the DXE receives correctly a
+supervisory response frame with the F bit not set, or an I  frame
+or  supervisory command frame, and with an N(R) within the  range
+from  the last N(R) received to the last N(S) sent plus one,  the
+DXE will not restart timer T1, but will use the received N(R)  as
+an  indication of acknowledgement of transmitted I frames  up  to
+and including I frame numbered N(R)-1.
+
+<p>If timer T1 runs out before a supervisory response  frame
+with  the  F  bit set is received, the  DXE  will  retransmit  an
+appropriate supervisory command frame (RR or RNR) with the P  bit
+set.  After N2 attempts to get a supervisory response frame  with
+the  F bit set from the other DXE, the DXE will initiate  a  link
+resetting procedure as described in <a href="#2.4.6">2.4.6</a>, below.
+
+<a name="2.4.5"></a>
+</p><h4>2.4.5 Frame Rejection Conditions</h4>
+
+A  DXE  shall initiate the frame-reset procedure  when  a
+frame  is received with the correct FCS and address field  during
+the information-transfer state with one or more of the conditions
+in <a href="#2.3.4.3.3">2.3.4.3.3</a>, above.
+
+<p>Under these conditions, the DXE will ask the other DXE to
+reset  the  link by transmitting a FRMR response as  outlined  in
+<a href="#2.4.6.3">
+2.4.6.3</a>, below.
+
+</p><p>After sending the FRMR frame, the sending DXE will  enter
+the  frame reject condition.  This condition is cleared when  the
+DXE that sent the FRMR frame receives a SABM or DISC command,  or
+a DM response frame.  Any other command received while the DXE is
+in the frame reject state will cause another FRMR to be sent  out
+with the same information field as originally sent.
+
+</p><p>In  the  frame rejection condition, additional  I  frames
+will not be transmitted, and received I frames and S frames  will
+be discarded by the DXE.
+
+</p><p>The DXE that sent the FRMR frame shall start the T1 timer
+when  the  FRMR is sent.  If no SABM or DISC  frame  is  received
+before the timer runs out, the FRMR frame shall be retransmitted,
+and   the  T1  timer  restarted  as  described  in  the   waiting
+acknowledgement section (<a href="#2.4.4.9">2.4.4.9</a>) above.  If the FRMR is sent  N2
+times without success, the link shall be reset.
+
+<a name="2.4.6"></a>
+</p><h4>2.4.6 Resetting Procedure</h4>
+
+<a name="2.4.6.1"></a>
+<h5>2.4.6.1 </h5>
+The   resetting  procedure  is  used  to   initialize   both
+directions  of  data  flow  after  a  nonrecoverable  error   has
+occurred.   This resetting procedure is used in the  information-
+transfer state of an AX.25 link only.
+
+<a name="2.4.6.2"></a>
+<h5>2.4.6.2 </h5>
+A DXE shall initiate a reset procedure whenever it  receives
+an unexpected UA response frame or an unsolicited response  frame
+with  the  F bit set to one.  A DXE may also initiate  the  reset
+procedure  upon receipt of a FRMR frame.  Alternatively, the  DXE
+may  respond to a FRMR by terminating the connection with a  DISC
+frame.
+
+<a name="2.4.6.3"></a>
+<h5>2.4.6.3 </h5>
+A  DXE  shall  reset the link by sending a  SABM  frame  and
+starting  timer  T1.  Upon receiving a SABM frame  from  the  DXE
+previously connected to, the receiver of a SABM frame should send
+a  UA  frame back at the earliest opportunity, set its  send  and
+receive  state  variables,  V(S) and V(R), to zero  and  stop  T1
+unless it has sent a SABM or DISC itself.  If the UA is correctly
+received by the initial DXE, it resets its send and receive state
+variables, V(S) and V(R), and stops timer T1.  Any busy condition
+that previously existed will also be cleared.
+
+<p>If  a  DM response is received, the DXE  will  enter  the
+disconnected  state  and  stop timer T1.  If timer  T1  runs  out
+before  a UA or DM response frame is received, the SABM  will  be
+retransmitted  and timer T1 restarted.  If timer T1 runs  out  N2
+times,  the  DXE  will  enter the  disconnected  state,  and  any
+previously existing link conditions will be cleared.
+
+     </p><p>Other  commands  or  responses received by  the  DXE  before
+completion of the reset procedure will be discarded.
+
+<a name="2.4.6.4"></a>
+</p><h5>2.4.6.4 </h5>
+One  DXE  may request that the other DXE reset the  link  by
+sending  a  DM response frame.  After the DM frame is  sent,  the
+sending DXE will then enter the disconnected state.
+
+<a name="2.4.7"></a>
+<h4>2.4.7 List of System Defined Parameters</h4>
+
+<a name="2.4.7.1"></a>
+<h5>2.4.7.1 Timers</h5>
+
+To   maintain  the  integrity  of  the  AX.25   level   2
+connection, use of these timers is recommended.
+
+<a name="2.4.7.1.1"></a>
+<h4>2.4.7.1.1 Acknowledgement Timer T1</h4>
+
+The  first timer, T1, is used to make sure a DXE  doesn't
+wait  forever  for a response to a frame it  sends.   This  timer
+cannot be expressed in absolute time, since the time required  to
+send frames varies greatly with the signaling rate used at  level
+1.   T1  should take at least twice the amount of time  it  would
+take  to send maximum length frame to the other DXE, and get  the
+proper response frame back from the other DXE.  This would  allow
+time for the other DXE to do some processing before responding.
+
+<p>If  level  2 repeaters are to be used, the  value  of  T1
+should be adjusted according to the number of repeaters the frame
+is being transferred through.
+
+<a name="2.4.7.1.2"></a>
+</p><h4>2.4.7.1.2 Response Delay Timer T2</h4>
+
+The  second timer, T2, may be implemented by the  DXE  to
+specify  a maximum amount of delay to be introduced  between  the
+time an I frame is received, and the time the resulting  response
+frame is sent.  This delay may be introduced to allow a receiving
+DXE to wait a short period of time to determine if there is  more
+than  one frame being sent to it.  If more frames  are  received,
+the  DXE can acknowledge them at once (up to seven), rather  than
+acknowledge  each individual frame.  The use of timer T2  is  not
+mandatory,  but  is recommended to  improve  channel  efficiency.
+Note  that, on full-duplex channels, acknowledgements should  not
+be delayed beyond k/2 frames to achieve maximum throughput.   The
+k parameter is defined in <a href="#2.4.7.4">2.4.7.4</a>, below.
+
+<a name="2.4.7.1.3"></a>
+<h4>2.4.7.1.3 Inactive Link Timer T3</h4>
+
+The third timer, T3, is used whenever T1 isn't running to
+maintain  link integrity.  It is recommended that whenever  there
+are  no  outstanding  unacknowledged I  frames  or  P-bit  frames
+(during  the information-transfer state), a RR or RNR frame  with
+the  P  bit set to one be sent every T3 time units to  query  the
+status  of the other DXE.  The period of T3 is  locally  defined,
+and  depends greatly on level 1 operation.  T3 should be  greater
+than T1, and may be very large on channels of high integrity.
+
+<a name="2.4.7.2"></a>
+<h5>2.4.7.2 Maximum Number of Retries (N2)</h5>
+
+The maximum number of retries is used in conjunction with
+the T1 timer.
+
+<a name="2.4.7.3"></a>
+<h5>2.4.7.3 Maximum Number of Octets in an I Field (N1)</h5>
+
+The maximum number of octets allowed in the I field  will
+be 256.  There shall also be an integral number of octets.
+
+<a name="2.4.7.4"></a>
+<h5>2.4.7.4 Maximum Number of I Frames Outstanding (k)</h5>
+
+The  maximum number of outstanding I frames at a time  is
+seven.
+
+
+<hr>
+This document was originally downloaded from the
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/software_library.php">TAPR archives</a> and 
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/software_library.php">FTP site</a>,
+as <i>ax25.doc</i>.
+HTML markup was done by Bill Buthod, N5RRS.  Last updated: 27 Dec 1997
+
+
+
+  <!-- DO NOT EDIT BELOW THIS LINE -->    
+  </div> <!-- Feature --> 
+  </div> <!-- Content --> 
+
+<!-- INCLUDE FOOTER HTML -->
+    <div id="siteInfo">
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/index.html"><img src="index_files/tapr-80px-logo.gif" alt="" width="75" height="77" border="0"></a>
+<a href="http://web.archive.org/web/20190831185324/https://tapr.org/organization.html">About Us</a> | <a href="http://web.archive.org/web/20190831185324/https://tapr.org/privacy_policy.html">Privacy Policy</a> | <a href="http://web.archive.org/web/20190831185324/https://tapr.org/contact_us.html">Contact Us</a> | 
+©2005-2019 Tucson Amateur Packet Radio Corp unless otherwise noted.
+<p>
+</p><p>
+</p></div>
+
+<!-- (c) 2005, 2014. Authorize.Net is a registered trademark of CyberSource Corporation --> 
+<div style="margin:auto;" class="AuthorizeNetSeal">
+<script type="text/javascript" language="javascript">
+var ANS_customer_id="c4c5ba6c-c2cc-4249-80b1-77e341f57c1b";
+</script> 
+<script type="text/javascript" language="javascript" src="index_files/seal.js">
+</script><style type="text/css">
+div.AuthorizeNetSeal{text-align:center;margin:0;padding:0;width:90px;font:normal 9px arial,helvetica,san-serif;line-height:10px;}
+div.AuthorizeNetSeal a{text-decoration:none;color:black;}
+div.AuthorizeNetSeal a:visited{color:black;}
+div.AuthorizeNetSeal a:active{color:black;}
+div.AuthorizeNetSeal a:hover{text-decoration:underline;color:black;}
+div.AuthorizeNetSeal a img{border:0px;margin:0px;text-decoration:none;}
+</style>
+<a href="http://web.archive.org/web/20190831185324/https://verify.authorize.net/anetseal/?pid=c4c5ba6c-c2cc-4249-80b1-77e341f57c1b&amp;rurl=https%3A//tapr.org/pub_ax25.html" onmouseover="window.status='http://web.archive.org/web/20190831185324/http://www.authorize.net/'; return true;" onmouseout="window.status=''; return true;" onclick="window.open('http://web.archive.org/web/20190831185324/https://verify.authorize.net/anetseal/?pid=c4c5ba6c-c2cc-4249-80b1-77e341f57c1b&amp;rurl=https%3A//tapr.org/pub_ax25.html','AuthorizeNetVerification','width=600,height=430,dependent=yes,resizable=yes,scrollbars=yes,menubar=no,toolbar=no,status=no,directories=no,location=yes'); return false;" target="_blank">
+<img src="index_files/secure90x72.gif" alt="Authorize.Net Merchant - Click to Verify" width="90" height="72" border="0">
+</a>
+ 
+<a href="http://web.archive.org/web/20190831185324/http://www.authorize.net/" id="AuthorizeNetText" target="_blank">Accept Credit Cards</a> 
+
+</div> 
+<p>
+
+</p></div> 
+<!--end pagecell1--> 
+<br> 
+
+
+
+</body></html>
+<!--
+     FILE ARCHIVED ON 18:53:24 Aug 31, 2019 AND RETRIEVED FROM THE
+     INTERNET ARCHIVE ON 06:35:58 Oct 23, 2023.
+     JAVASCRIPT APPENDED BY WAYBACK MACHINE, COPYRIGHT INTERNET ARCHIVE.
+
+     ALL OTHER CONTENT MAY ALSO BE PROTECTED BY COPYRIGHT (17 U.S.C.
+     SECTION 108(a)(3)).
+-->
+<!--
+playback timings (ms):
+  captures_list: 71.788 (11)
+  exclusion.robots: 0.302
+  exclusion.robots.policy: 0.284
+  cdx.remote: 0.099
+  esindex: 0.013
+  LoadShardBlock: 40.059 (3)
+  PetaboxLoader3.datanode: 67.154 (4)
+  load_resource: 81.49
+  PetaboxLoader3.resolve: 24.029
+-->
diff --git a/doc/ax25-2p0/index_files/banner-styles.css b/doc/ax25-2p0/index_files/banner-styles.css
new file mode 100644
index 0000000..0ab5af9
--- /dev/null
+++ b/doc/ax25-2p0/index_files/banner-styles.css
@@ -0,0 +1,507 @@
+@import 'record.css'; /* for SPN1 */
+
+#wm-ipp-base {
+  height:65px;/* initial height just in case js code fails */
+  padding:0;
+  margin:0;
+  border:none;
+  background:none transparent;
+}
+#wm-ipp {
+  z-index: 2147483647;
+}
+#wm-ipp, #wm-ipp * {
+  font-family:Lucida Grande, Helvetica, Arial, sans-serif;
+  font-size:12px;
+  line-height:1.2;
+  letter-spacing:0;
+  width:auto;
+  height:auto;
+  max-width:none;
+  max-height:none;
+  min-width:0 !important;
+  min-height:0;
+  outline:none;
+  float:none;
+  text-align:left;
+  border:none;
+  color: #000;
+  text-indent: 0;
+  position: initial;
+  background: none;
+}
+#wm-ipp div, #wm-ipp canvas {
+  display: block;
+}
+#wm-ipp div, #wm-ipp tr, #wm-ipp td, #wm-ipp a, #wm-ipp form {
+  padding:0;
+  margin:0;
+  border:none;
+  border-radius:0;
+  background-color:transparent;
+  background-image:none;
+  /*z-index:2147483640;*/
+  height:auto;
+}
+#wm-ipp table {
+  border:none;
+  border-collapse:collapse;
+  margin:0;
+  padding:0;
+  width:auto;
+  font-size:inherit;
+}
+#wm-ipp form input {
+  padding:1px !important;
+  height:auto;
+  display:inline;
+  margin:0;
+  color: #000;
+  background: none #fff;
+  border: 1px solid #666;
+}
+#wm-ipp form input[type=submit] {
+  padding:0 8px !important;
+  margin:1px 0 1px 5px !important;
+  width:auto !important;
+  border: 1px solid #000 !important;
+  background: #fff !important;
+  color: #000 !important;
+}
+#wm-ipp form input[type=submit]:hover {
+  background: #eee !important;
+  cursor: pointer !important;
+}
+#wm-ipp form input[type=submit]:active {
+  transform: translateY(1px);
+}
+#wm-ipp a {
+  display: inline;
+}    
+#wm-ipp a:hover{
+  text-decoration:underline;
+}
+#wm-ipp a.wm-btn:hover {
+  text-decoration:none;
+  color:#ff0 !important;
+}
+#wm-ipp a.wm-btn:hover span {
+  color:#ff0 !important;
+}
+#wm-ipp #wm-ipp-inside {
+  margin: 0 6px;
+  border:5px solid #000;
+  border-top:none;
+  background-color:rgba(255,255,255,0.9);
+  -moz-box-shadow:1px 1px 4px #333;
+  -webkit-box-shadow:1px 1px 4px #333;
+  box-shadow:1px 1px 4px #333;
+  border-radius:0 0 8px 8px;
+}
+/* selectors are intentionally verbose to ensure priority */
+#wm-ipp #wm-logo {
+  padding:0 10px;
+  vertical-align:middle;
+  min-width:100px;
+  flex: 0 0 100px;
+}
+#wm-ipp .c {
+  padding-left: 4px;
+}
+#wm-ipp .c .u {
+    margin-top: 4px !important;
+}
+#wm-ipp .n {
+  padding:0 0 0 5px !important;
+  vertical-align: bottom;
+}
+#wm-ipp .n a {
+  text-decoration:none;
+  color:#33f;
+  font-weight:bold;
+}
+#wm-ipp .n .b {
+  padding:0 6px 0 0 !important;
+  text-align:right !important;
+  overflow:visible;
+  white-space:nowrap;
+  color:#99a;
+  vertical-align:middle;
+}
+#wm-ipp .n .y .b {
+  padding:0 6px 2px 0 !important;
+}
+#wm-ipp .n .c {
+  background:#000;
+  color:#ff0;
+  font-weight:bold;
+  padding:0 !important;
+  text-align:center;
+}
+#wm-ipp.hi .n td.c {
+  color:#ec008c;
+}
+#wm-ipp .n td.f {
+  padding:0 0 0 6px !important;
+  text-align:left !important;
+  overflow:visible;
+  white-space:nowrap;
+  color:#99a;
+  vertical-align:middle;
+}
+#wm-ipp .n tr.m td {
+  text-transform:uppercase;
+  white-space:nowrap;
+  padding:2px 0;
+}
+#wm-ipp .c .s {
+  padding:0 5px 0 0 !important;
+  vertical-align:bottom;
+}
+#wm-ipp #wm-nav-captures {
+  white-space: nowrap;
+}
+#wm-ipp .c .s a.t {
+  color:#33f;
+  font-weight:bold;
+  line-height: 1.8;
+}
+#wm-ipp .c .s div.r {
+  color: #666;
+  font-size:9px;
+  white-space:nowrap;
+}
+#wm-ipp .c .k {
+  padding-bottom:1px;
+}
+#wm-ipp .c .s {
+  padding:0 5px 2px 0 !important;
+}
+#wm-ipp td#displayMonthEl {
+  padding: 2px 0 !important;
+}
+#wm-ipp td#displayYearEl {
+  padding: 0 0 2px 0 !important;
+}
+
+div#wm-ipp-sparkline {
+  position:relative;/* for positioning markers */
+  white-space:nowrap;
+  background-color:#fff;
+  cursor:pointer;
+  line-height:0.9;
+}
+#sparklineImgId, #wm-sparkline-canvas {
+  position:relative;
+  z-index:9012;
+  max-width:none;
+}
+#wm-ipp-sparkline div.yt {
+  position:absolute;
+  z-index:9010 !important;
+  background-color:#ff0 !important;
+  top: 0;
+}
+#wm-ipp-sparkline div.mt {
+  position:absolute;
+  z-index:9013 !important;
+  background-color:#ec008c !important;
+  top: 0;
+}  
+#wm-ipp .r {
+    margin-left: 4px;
+}
+#wm-ipp .r a {
+  color:#33f;
+  border:none;
+  position:relative;
+  background-color:transparent;
+  background-repeat:no-repeat !important;
+  background-position:100% 100% !important;
+  text-decoration: none;
+}
+#wm-ipp #wm-capinfo {
+  /* prevents notice div background from sticking into round corners of
+     #wm-ipp-inside */
+  border-radius: 0 0 4px 4px;
+}
+#wm-ipp #wm-capinfo .c-logo {
+  display:block;
+  float:left;
+  margin-right:3px;
+  width:90px;
+  min-height:90px;
+  max-height: 290px;
+  border-radius:45px;
+  overflow:hidden;
+  background-position:50%;
+  background-size:auto 90px;
+  box-shadow: 0 0 2px 2px rgba(208,208,208,128) inset;
+}
+#wm-ipp #wm-capinfo .c-logo span {
+  display:inline-block;
+}
+#wm-ipp #wm-capinfo .c-logo img {
+  height:90px;
+  position:relative;
+  left:-50%;
+}
+#wm-ipp #wm-capinfo .wm-title {
+  font-size:130%;
+}
+#wm-ipp #wm-capinfo a.wm-selector {
+  display:inline-block;
+  color: #aaa;
+  text-decoration:none !important;
+  padding: 2px 8px;
+}
+#wm-ipp #wm-capinfo a.wm-selector.selected {
+  background-color:#666;
+}
+#wm-ipp #wm-capinfo a.wm-selector:hover {
+  color: #fff;
+}
+#wm-ipp #wm-capinfo.notice-only #wm-capinfo-collected-by,
+#wm-ipp #wm-capinfo.notice-only #wm-capinfo-timestamps {
+    display: none;
+}
+#wm-ipp #wm-capinfo #wm-capinfo-notice .wm-capinfo-content {
+    background-color:#ff0;
+    padding:5px;
+    font-size:14px;
+    text-align:center;
+}
+#wm-ipp #wm-capinfo #wm-capinfo-notice .wm-capinfo-content * {
+    font-size:14px;
+    text-align:center;
+}
+#wm-ipp #wm-expand {
+  right: 1px;
+  bottom: -1px;
+  color: #ffffff;
+  background-color: #666 !important;
+  padding:0 5px 0 3px !important;
+  border-radius: 3px 3px 0 0 !important;
+}
+#wm-ipp #wm-expand span {
+  color: #ffffff;
+}
+#wm-ipp #wm-expand #wm-expand-icon {
+  display: inline-block;
+  transition: transform 0.5s;
+  transform-origin: 50% 45%;
+}
+#wm-ipp #wm-expand.wm-open #wm-expand-icon {
+  transform: rotate(180deg);
+}
+#wm-ipp #wmtb {
+  text-align:right;
+}
+#wm-ipp #wmtb #wmtbURL {
+  width: calc(100% - 45px);
+}
+#wm-ipp #wm-graph-anchor {
+  border-right:1px solid #ccc;
+}
+/* time coherence */
+html.wb-highlight {
+  box-shadow: inset 0 0 0 3px #a50e3a !important;
+}    
+.wb-highlight {
+  outline: 3px solid #a50e3a !important;
+}
+#wm-ipp-print {
+  display:none !important;
+}
+@media print {
+#wm-ipp-base {
+  display:none !important;
+}
+#wm-ipp-print {
+  display:block !important;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+}
+@media (max-width:414px) {
+    #wm-ipp .xxs {
+	display:none !important;
+    }
+}
+@media (min-width:1055px) {
+#wm-ipp #wm-graph-anchor {
+  display:block !important;
+}
+}
+@media (max-width:1054px) {
+#wm-ipp #wm-graph-anchor {
+  display:none !important;
+}
+}
+@media (max-width:1163px) {
+#wm-logo {
+    display:none !important;
+}
+}
+
+#wm-btns {
+    white-space: nowrap;
+    margin-top: -2px;
+}
+
+#wm-btns #wm-save-snapshot-open {
+  margin-right: 7px;
+  top: -6px;
+}
+
+#wm-btns #wm-sign-in {
+  box-sizing: content-box;
+  display: none;
+  margin-right: 7px;
+  top: -8px;
+
+  /*
+  round border around sign in button
+  */
+  border: 2px #000 solid;
+  border-radius: 14px;
+  padding-right: 2px;
+  padding-bottom: 2px;
+  width: 11px;
+  height: 11px;
+}
+
+#wm-btns #wm-sign-in>.iconochive-person {
+  font-size: 12.5px;
+}
+
+#wm-save-snapshot-open > .iconochive-web {
+  color:#000;
+  font-size:160%;
+}
+
+#wm-ipp #wm-share {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+}
+
+#wm-share > #wm-screenshot {
+  display: inline-block;
+  margin-right: 3px;
+  visibility: hidden;
+}
+
+#wm-screenshot > .iconochive-image {
+  color:#000;
+  font-size:160%;
+}
+
+#wm-share > #wm-video {
+  display: inline-block;
+  margin-right: 3px;
+  visibility: hidden;
+}
+
+#wm-video > .iconochive-movies {
+  color: #000;
+  display: inline-block;
+  font-size: 150%;
+  margin-bottom: 2px;
+}
+
+#wm-btns #wm-save-snapshot-in-progress {
+  display: none;
+  font-size:160%;
+  opacity: 0.5;
+  position: relative;
+  margin-right: 7px;
+  top: -5px;
+}
+
+#wm-btns #wm-save-snapshot-success {
+  display: none;
+  color: green;
+  position: relative;
+  top: -7px;
+}
+
+#wm-btns #wm-save-snapshot-fail {
+  display: none;
+  color: red;
+  position: relative;
+  top: -7px;
+}
+
+.wm-icon-screen-shot {
+  background: url("../images/web-screenshot.svg") no-repeat !important;
+  background-size: contain !important;
+  width: 22px !important;
+  height: 19px !important;
+
+  display: inline-block;
+}
+#donato {
+    /* transition effect is disable so as to simplify height adjustment */
+    /*transition: height 0.5s;*/
+    height: 0;
+    margin: 0;
+    padding: 0;
+    border-bottom: 1px solid #999 !important;
+}
+body.wm-modal {
+    height: auto !important;
+    overflow: hidden !important;
+}
+#donato #donato-base {
+    width: 100%;
+    height: 100%;
+    /*bottom: 0;*/
+    margin: 0;
+    padding: 0;
+    position: absolute;
+    z-index: 2147483639;
+}
+body.wm-modal #donato #donato-base {
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    z-index: 2147483640;
+}
+
+.wb-autocomplete-suggestions {
+    font-family: Lucida Grande, Helvetica, Arial, sans-serif;
+    font-size: 12px;
+    text-align: left;
+    cursor: default;
+    border: 1px solid #ccc;
+    border-top: 0;
+    background: #fff;
+    box-shadow: -1px 1px 3px rgba(0,0,0,.1);
+    position: absolute;
+    display: none;
+    z-index: 2147483647;
+    max-height: 254px;
+    overflow: hidden;
+    overflow-y: auto;
+    box-sizing: border-box;
+}
+.wb-autocomplete-suggestion {
+    position: relative;
+    padding: 0 .6em;
+    line-height: 23px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    font-size: 1.02em;
+    color: #333;
+}
+.wb-autocomplete-suggestion b {
+    font-weight: bold;
+}
+.wb-autocomplete-suggestion.selected {
+    background: #f0f0f0;
+}
diff --git a/doc/ax25-2p0/index_files/iconochive.css b/doc/ax25-2p0/index_files/iconochive.css
new file mode 100644
index 0000000..7a95ea7
--- /dev/null
+++ b/doc/ax25-2p0/index_files/iconochive.css
@@ -0,0 +1,116 @@
+@font-face{font-family:'Iconochive-Regular';src:url('https://archive.org/includes/fonts/Iconochive-Regular.eot?-ccsheb');src:url('https://archive.org/includes/fonts/Iconochive-Regular.eot?#iefix-ccsheb') format('embedded-opentype'),url('https://archive.org/includes/fonts/Iconochive-Regular.woff?-ccsheb') format('woff'),url('https://archive.org/includes/fonts/Iconochive-Regular.ttf?-ccsheb') format('truetype'),url('https://archive.org/includes/fonts/Iconochive-Regular.svg?-ccsheb#Iconochive-Regular') format('svg');font-weight:normal;font-style:normal}
+[class^="iconochive-"],[class*=" iconochive-"]{font-family:'Iconochive-Regular'!important;speak:none;font-style:normal;font-weight:normal;font-variant:normal;text-transform:none;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
+.iconochive-Uplevel:before{content:"\21b5"}
+.iconochive-exit:before{content:"\1f6a3"}
+.iconochive-beta:before{content:"\3b2"}
+.iconochive-logo:before{content:"\1f3db"}
+.iconochive-audio:before{content:"\1f568"}
+.iconochive-movies:before{content:"\1f39e"}
+.iconochive-software:before{content:"\1f4be"}
+.iconochive-texts:before{content:"\1f56e"}
+.iconochive-etree:before{content:"\1f3a4"}
+.iconochive-image:before{content:"\1f5bc"}
+.iconochive-web:before{content:"\1f5d4"}
+.iconochive-collection:before{content:"\2211"}
+.iconochive-folder:before{content:"\1f4c2"}
+.iconochive-data:before{content:"\1f5c3"}
+.iconochive-tv:before{content:"\1f4fa"}
+.iconochive-article:before{content:"\1f5cf"}
+.iconochive-question:before{content:"\2370"}
+.iconochive-question-dark:before{content:"\3f"}
+.iconochive-info:before{content:"\69"}
+.iconochive-info-small:before{content:"\24d8"}
+.iconochive-comment:before{content:"\1f5e9"}
+.iconochive-comments:before{content:"\1f5ea"}
+.iconochive-person:before{content:"\1f464"}
+.iconochive-people:before{content:"\1f465"}
+.iconochive-eye:before{content:"\1f441"}
+.iconochive-rss:before{content:"\221e"}
+.iconochive-time:before{content:"\1f551"}
+.iconochive-quote:before{content:"\275d"}
+.iconochive-disc:before{content:"\1f4bf"}
+.iconochive-tv-commercial:before{content:"\1f4b0"}
+.iconochive-search:before{content:"\1f50d"}
+.iconochive-search-star:before{content:"\273d"}
+.iconochive-tiles:before{content:"\229e"}
+.iconochive-list:before{content:"\21f6"}
+.iconochive-list-bulleted:before{content:"\2317"}
+.iconochive-latest:before{content:"\2208"}
+.iconochive-left:before{content:"\2c2"}
+.iconochive-right:before{content:"\2c3"}
+.iconochive-left-solid:before{content:"\25c2"}
+.iconochive-right-solid:before{content:"\25b8"}
+.iconochive-up-solid:before{content:"\25b4"}
+.iconochive-down-solid:before{content:"\25be"}
+.iconochive-dot:before{content:"\23e4"}
+.iconochive-dots:before{content:"\25a6"}
+.iconochive-columns:before{content:"\25af"}
+.iconochive-sort:before{content:"\21d5"}
+.iconochive-atoz:before{content:"\1f524"}
+.iconochive-ztoa:before{content:"\1f525"}
+.iconochive-upload:before{content:"\1f4e4"}
+.iconochive-download:before{content:"\1f4e5"}
+.iconochive-favorite:before{content:"\2605"}
+.iconochive-heart:before{content:"\2665"}
+.iconochive-play:before{content:"\25b6"}
+.iconochive-play-framed:before{content:"\1f3ac"}
+.iconochive-fullscreen:before{content:"\26f6"}
+.iconochive-mute:before{content:"\1f507"}
+.iconochive-unmute:before{content:"\1f50a"}
+.iconochive-share:before{content:"\1f381"}
+.iconochive-edit:before{content:"\270e"}
+.iconochive-reedit:before{content:"\2710"}
+.iconochive-gear:before{content:"\2699"}
+.iconochive-remove-circle:before{content:"\274e"}
+.iconochive-plus-circle:before{content:"\1f5d6"}
+.iconochive-minus-circle:before{content:"\1f5d5"}
+.iconochive-x:before{content:"\1f5d9"}
+.iconochive-fork:before{content:"\22d4"}
+.iconochive-trash:before{content:"\1f5d1"}
+.iconochive-warning:before{content:"\26a0"}
+.iconochive-flash:before{content:"\1f5f2"}
+.iconochive-world:before{content:"\1f5fa"}
+.iconochive-lock:before{content:"\1f512"}
+.iconochive-unlock:before{content:"\1f513"}
+.iconochive-twitter:before{content:"\1f426"}
+.iconochive-facebook:before{content:"\66"}
+.iconochive-googleplus:before{content:"\67"}
+.iconochive-reddit:before{content:"\1f47d"}
+.iconochive-tumblr:before{content:"\54"}
+.iconochive-pinterest:before{content:"\1d4df"}
+.iconochive-popcorn:before{content:"\1f4a5"}
+.iconochive-email:before{content:"\1f4e7"}
+.iconochive-embed:before{content:"\1f517"}
+.iconochive-gamepad:before{content:"\1f579"}
+.iconochive-Zoom_In:before{content:"\2b"}
+.iconochive-Zoom_Out:before{content:"\2d"}
+.iconochive-RSS:before{content:"\1f4e8"}
+.iconochive-Light_Bulb:before{content:"\1f4a1"}
+.iconochive-Add:before{content:"\2295"}
+.iconochive-Tab_Activity:before{content:"\2318"}
+.iconochive-Forward:before{content:"\23e9"}
+.iconochive-Backward:before{content:"\23ea"}
+.iconochive-No_Audio:before{content:"\1f508"}
+.iconochive-Pause:before{content:"\23f8"}
+.iconochive-No_Favorite:before{content:"\2606"}
+.iconochive-Unike:before{content:"\2661"}
+.iconochive-Song:before{content:"\266b"}
+.iconochive-No_Flag:before{content:"\2690"}
+.iconochive-Flag:before{content:"\2691"}
+.iconochive-Done:before{content:"\2713"}
+.iconochive-Check:before{content:"\2714"}
+.iconochive-Refresh:before{content:"\27f3"}
+.iconochive-Headphones:before{content:"\1f3a7"}
+.iconochive-Chart:before{content:"\1f4c8"}
+.iconochive-Bookmark:before{content:"\1f4d1"}
+.iconochive-Documents:before{content:"\1f4da"}
+.iconochive-Newspaper:before{content:"\1f4f0"}
+.iconochive-Podcast:before{content:"\1f4f6"}
+.iconochive-Radio:before{content:"\1f4fb"}
+.iconochive-Cassette:before{content:"\1f4fc"}
+.iconochive-Shuffle:before{content:"\1f500"}
+.iconochive-Loop:before{content:"\1f501"}
+.iconochive-Low_Audio:before{content:"\1f509"}
+.iconochive-First:before{content:"\1f396"}
+.iconochive-Invisible:before{content:"\1f576"}
+.iconochive-Computer:before{content:"\1f5b3"}
diff --git a/doc/ax25-2p0/index_files/pdf2.gif b/doc/ax25-2p0/index_files/pdf2.gif
new file mode 100644
index 0000000..2d238de
Binary files /dev/null and b/doc/ax25-2p0/index_files/pdf2.gif differ
diff --git a/doc/ax25-2p0/index_files/secure90x72.gif b/doc/ax25-2p0/index_files/secure90x72.gif
new file mode 100644
index 0000000..d6fffed
Binary files /dev/null and b/doc/ax25-2p0/index_files/secure90x72.gif differ
diff --git a/doc/ax25-2p0/index_files/tapr-80px-logo.gif b/doc/ax25-2p0/index_files/tapr-80px-logo.gif
new file mode 100644
index 0000000..a469e52
Binary files /dev/null and b/doc/ax25-2p0/index_files/tapr-80px-logo.gif differ
diff --git a/doc/ax25-2p0/index_files/tapr-logo.png b/doc/ax25-2p0/index_files/tapr-logo.png
new file mode 100644
index 0000000..d1c606b
Binary files /dev/null and b/doc/ax25-2p0/index_files/tapr-logo.png differ
diff --git a/doc/ax25-2p0/index_files/tapr_sub.css b/doc/ax25-2p0/index_files/tapr_sub.css
new file mode 100644
index 0000000..eba14b2
--- /dev/null
+++ b/doc/ax25-2p0/index_files/tapr_sub.css
@@ -0,0 +1,587 @@
+/***********************************************/
+/* emx_nav_left.css                            */
+/* Use with template Halo_leftNav.html         */
+/***********************************************/
+
+/***********************************************/
+/* HTML tag styles                             */
+/***********************************************/
+body{
+	font-family: Arial,sans-serif;
+	color: #333333;
+	line-height: 1.166;	
+	margin: 0px;
+	padding: 0px;
+	background: #666EAF url(/web/20190831185324im_/https://tapr.org/images/home-page-top-graphic.gif) repeat-x left top;
+}
+
+/******* hyperlink and anchor tag styles *******/
+
+a:link, a:visited{
+	color: #005FA9;
+	text-decoration: none;
+}
+
+a:hover{
+	text-decoration: underline;
+	color: #800000;
+}
+
+/************** header tag styles **************/
+
+h1{
+ color: #006699;
+ margin: 0px;
+ padding: 0px;
+	font: bold 150% Arial,sans-serif;
+}
+
+h2{
+ font: bold 114% Arial,sans-serif;
+ color: #006699;
+ margin: 0px;
+ padding: 0px;
+}
+
+h3{
+ font: bold 100% Arial,sans-serif;
+ color: #334d55;
+ margin: 0px;
+ padding: 0px;
+}
+
+h4{
+ font: 100% Arial,sans-serif;
+ color: #333333;
+ margin: 0px;
+ padding: 0px;
+}
+
+h5{
+ font: 100% Arial,sans-serif;
+ color: #334d55;
+ margin: 0px;
+ padding: 0px;
+}
+
+h6 {
+ font: bold 130% Arial,sans-serif;
+ color: #FF0000;
+ margin: 0px;
+ padding: 0px;
+}
+
+p.address_block{
+	color: #000000;
+}
+
+p.photo{
+	color: #000000;
+	font-size: 80%;
+	display: inline;
+}
+
+/*************** list tag styles ***************/
+
+ul{
+ list-style-type: square;
+}
+
+ul ul{
+ list-style-type: disc;
+}
+
+ul ul ul{
+ list-style-type: none;
+}
+
+/********* form and related tag styles *********/
+
+form {
+	margin: 0;
+	padding: 0;
+}
+
+label{
+ font: bold 1em Arial,sans-serif;
+ color: #334d55;
+}
+				
+input{
+font-family: Arial,sans-serif;
+}
+
+/***********************************************/
+/* Layout Divs                                 */
+/***********************************************/
+#pagecell1{
+	position:absolute;
+	top: 110px;
+	left: 2%;
+	right: 2%;
+	width:95.6%;
+	background-color: #ffffff;
+}
+
+#tl {
+	position:absolute;
+	top: -1px;
+	left: -1px;
+	margin: 0px;
+	padding: 0px;
+	z-index: 100;
+}
+
+#tr {
+	position:absolute;
+	top: -1px;
+	right: -1px;
+	margin: 0px;
+	padding: 0px;
+	z-index: 100;
+}
+
+#masthead{
+	position: absolute;
+	top: 0px;
+	left: 2%;
+	right: 2%;
+	width:95.6%;
+	
+}
+
+img.logo {
+	margin-left: 15px;
+}
+
+#pageNav{
+	float: left;
+	width:178px;
+	padding: 0px;
+	background-color: #F5f7f7;
+	border-right: 1px solid #cccccc;
+	border-bottom: 1px solid #cccccc;
+	font: small Verdana,sans-serif;
+}
+
+#content{
+	padding: 0px 10px 0px 0px;
+	margin:0px 0px 0px 178px;
+	border-left: 1px solid #ccd2d2;
+}
+
+
+/***********************************************/
+/* Component Divs                              */
+/***********************************************/
+#siteName{
+	margin: 0px;
+	padding: 10px 0px 8px 0px;
+	color: #FFFF00;
+	font-weight: bold;
+	font-size: 150%;
+}
+
+/************** utility styles *****************/
+
+#utility{
+	font: 75% Verdana,sans-serif;
+	position: absolute;
+	top: 5px;
+	right: 0%;
+}
+
+#utility a{
+	color: #FFFFFF;
+}
+
+#utility a:hover{
+	text-decoration: underline;
+	color: #800000;
+}
+
+/************** pageName styles ****************/
+
+#pageName{
+	padding: 0px 0px 14px 10px;
+	margin: 0px;
+	font-size: 80%;
+	color: #004080;
+	border-bottom:1px solid #ccd2d2;
+}
+
+#pageName h2{
+	font: italic bold 170% Arial,sans-serif;
+	color: #191919;
+	margin:0px;
+	padding: 0px;
+	margin-top: 5px;
+	display: inline;
+}
+
+#pageName h3{
+	font: italic bold 140% Arial,sans-serif;
+	color: #191919;
+	margin:0px;
+	padding: 0px;
+	margin-top: 5px;
+	display: inline;
+}
+
+#pageName img{
+	position: absolute;
+	top: 0px;
+	right: 6px;
+	padding: 0px;
+	margin: 0px;
+}
+
+#pageName a:link, #pageName a:visited {
+	font: italic bold 170% Arial,sans-serif;
+	color: #004080;
+	margin:0px;
+	padding: 0px;
+	margin-top: 5px;
+	display: inline;
+}
+
+#pageName a:hover{
+	font: italic bold 170% Arial,sans-serif;
+	color: #800000;
+	margin:0px;
+	padding: 0px;
+	margin-top: 5px;
+	display: inline;
+}
+/************* globalNav styles ****************/
+
+#globalLink{
+	position: absolute;
+	top: 35px;
+	margin: 0px;
+	right: 0%;
+	z-index: 100;
+	padding-left: 100px;
+}
+
+
+a.glink, a.glink:visited{
+  	font-size: small;
+  	color: #000000;
+	font-weight: bold;
+	padding: 5px;
+}
+
+a.glink:hover{
+  	font-size: small;
+  	color: #800000;
+	font-weight: bold;
+	padding: 5px;
+}
+
+.skipLinks {display: none;}
+
+/************ subglobalNav styles **************/
+
+.subglobalNav{
+	position: absolute;
+	top: 84px;
+	left: 0px;
+	height: 20px;
+	padding: 0px 0px 0px 10px;
+	visibility: hidden;
+	color: #ffffff;
+	font-weight: bold;
+}
+
+.subglobalNav a:link, .subglobalNav a:visited {
+	font-size: 80%;
+	color: #ffffff;
+	font-weight: bold;
+}
+
+.subglobalNav a:hover{
+	color: #cccccc;
+}
+
+/*************** search styles *****************/
+
+#search{
+	position: absolute;
+	z-index: 101;
+	top: 80px;
+	border-top-style: solid;
+	border-top-width: 1px;
+	right: 0%;
+}
+
+#search input{
+  font-size: 70%;
+  margin: 0px  0px 0px 10px;
+ }
+ 
+#search a:link, #search a:visited {
+	font-size: 80%;
+	color: #FFFFFF;
+}
+
+#search a:hover{
+	margin: 0px;
+	color: #800000;
+}
+
+
+/************* breadCrumb styles ***************/
+
+#breadCrumb{
+	padding: 5px 0px 5px 10px;
+	font: small Verdana,sans-serif;
+	color: #AAAAAA;
+}
+
+#breadCrumb a{
+	color: #AAAAAA;
+}
+
+#breadCrumb a:hover{
+	color: #005FA9;
+	text-decoration: underline;
+}
+
+
+/************** feature styles *****************/
+
+.feature {
+	padding: 0px 0px 10px 10px;
+	font-size: 90%;
+	min-height: 200px;
+	height: 200px;
+}
+
+html>body .feature {
+	height: auto;
+}
+
+.feature h1 {
+	font: bold 175% Arial,sans-serif;
+	color: #000000;
+	padding: 10px 0px 0px 0px;
+	border-bottom: 2px double #000000;
+}
+
+.feature h2 {
+	font: bold 150% Arial,sans-serif;
+	color: #000000;
+	padding: 10px 0px 0px 0px;
+}
+
+.feature h3 {
+	font: bold 150% Arial,sans-serif;
+	color: #006699;
+	padding: 10px 0px 0px 0px;
+}
+
+.feature h4 {
+	font: bold 125% Arial,sans-serif;
+	color: #000000;
+	padding: 10px 0px 0px 0px;
+}
+
+.feature h5 {
+	font: bold 110% Arial,sans-serif;
+	color: #000000;
+	padding: 10px 0px 0px 0px;
+}
+
+.feature img {
+	/* float: left; */
+	/* padding: 5px 5px 5px 5px; */
+	margin: 5px;
+}
+
+
+/*************** story styles ******************/
+
+.story {
+	padding: 10px 0px 0px 10px;
+	font-size: 90%;
+}
+
+.story h3{
+	font: bold 125% Arial,sans-serif;
+	color: #000000;
+}
+
+.story p {
+	padding: 0px 0px 5px 0px;
+}
+
+.story h4 {
+	font: bold 110% Arial,sans-serif;
+	color: #000000;
+	padding: 10px 0px 0px 0px;
+}
+
+.story a.capsule{
+	font: bold 1em Arial,sans-serif;
+	color: #005FA9;
+	display:block;
+	padding-bottom: 5px;
+	border-bottom: 2px solid #000000;
+}
+
+.story a.capsule:hover{
+	text-decoration: underline;
+}
+
+td.storyLeft{
+	padding-right: 12px;
+}
+
+.story img{
+	/* float: right; */
+	/* padding: 0px 10px 0px 0px; */
+	margin: 5px;
+}
+
+td.caption {
+	color: #000000;
+	font-weight: bold;
+	text-decoration: underline;
+	font-size: 90%;
+}
+
+
+/************** siteInfo styles ****************/
+
+#siteInfo{
+	clear: both;
+	border-top: 1px solid #cccccc;
+	font-size: small;
+	color: #a3a3a3;
+	padding: 10px 10px 10px 10px;
+	margin-top: 0px;
+}
+
+#siteInfo img{
+	padding: 4px 4px 4px 0px;
+	vertical-align: middle;
+}
+
+
+/************ sectionLinks styles **************/
+
+#sectionLinks{
+	margin: 0px;
+	padding: 0px;
+
+}
+
+#sectionLinks h2{
+	padding: 2px 0px 2px 10px;
+	border-bottom: 1px solid #cccccc;
+	color: #FF0000;
+	margin-left: 3px;
+	font-weight: bold;
+	font-size: 120%;
+}
+
+#sectionLinks h3{
+	padding: 10px 0px 2px 10px;
+	border-bottom: 1px solid #cccccc;
+}
+
+#sectionLinks a:link, #sectionLinks a:visited {
+	display: block;
+	border-top: 1px solid #ffffff;
+	border-bottom: 1px solid #cccccc;
+	background-image:  url("/web/20190831185324im_/https://tapr.org/images/bg_nav.jpg");
+	font-weight: bold;
+	padding: 3px 0px 3px 10px;
+	color: #004080;
+}
+
+#sectionLinks a:hover{
+	border-top: 1px solid #cccccc;
+	background-color: #FFFFFF;
+	background-image: none;
+	font-weight: bold;
+	text-decoration: none;
+	color: #800000;
+}
+
+#sectionLinks a.new:link, #sectionLinks a.new:visited {
+	display: block;
+	border-top: 1px solid #ffffff;
+	border-bottom: 1px solid #cccccc;
+	font-weight: bold;
+	padding: 3px 0px 3px 10px;
+	color: #00254A;
+	background: no-repeat #FF9696;
+}
+
+#sectionLinks a.new:hover{
+	border-top: 1px solid #cccccc;
+	background-color: #FFFFFF;
+	background-image: none;
+	font-weight: bold;
+	text-decoration: none;
+	color: #800000;
+}
+
+#sectionLinks img {
+	padding-right: 5px;
+}
+
+/************* relatedLinks styles **************/
+
+.relatedLinks{
+	margin: 0px;
+	padding: 0px 0px 10px 10px;
+	border-bottom: 1px solid #cccccc;
+}
+
+.relatedLinks h3{
+	padding: 10px 0px 2px 0px;
+}
+
+.relatedLinks a{
+	display: block; 
+}
+
+
+/**************** advert styles *****************/
+
+#advert{
+	padding: 10px;
+}
+
+#advert img{
+	display: block;
+}
+
+div.archive{
+	background-color: lightgrey;
+|
+/********************* end **********************/
+
+/*
+     FILE ARCHIVED ON 18:53:24 Aug 31, 2019 AND RETRIEVED FROM THE
+     INTERNET ARCHIVE ON 06:35:59 Oct 23, 2023.
+     JAVASCRIPT APPENDED BY WAYBACK MACHINE, COPYRIGHT INTERNET ARCHIVE.
+
+     ALL OTHER CONTENT MAY ALSO BE PROTECTED BY COPYRIGHT (17 U.S.C.
+     SECTION 108(a)(3)).
+*/
+/*
+playback timings (ms):
+  captures_list: 90.861
+  exclusion.robots: 0.066
+  exclusion.robots.policy: 0.057
+  cdx.remote: 0.052
+  esindex: 0.008
+  LoadShardBlock: 42.583 (3)
+  PetaboxLoader3.datanode: 50.291 (4)
+  load_resource: 134.36
+  PetaboxLoader3.resolve: 120.716
+*/
\ No newline at end of file
diff --git a/doc/ax25-2p0/index_files/tl_curve_white.gif b/doc/ax25-2p0/index_files/tl_curve_white.gif
new file mode 100644
index 0000000..9cba4c7
Binary files /dev/null and b/doc/ax25-2p0/index_files/tl_curve_white.gif differ
diff --git a/doc/ax25-2p0/index_files/tr_curve_white.gif b/doc/ax25-2p0/index_files/tr_curve_white.gif
new file mode 100644
index 0000000..6287358
Binary files /dev/null and b/doc/ax25-2p0/index_files/tr_curve_white.gif differ
diff --git a/doc/ax25-2p2/ax25-2p2.pdf b/doc/ax25-2p2/ax25-2p2.pdf
new file mode 100644
index 0000000..e31e0e1
Binary files /dev/null and b/doc/ax25-2p2/ax25-2p2.pdf differ
diff --git a/pyproject.toml b/pyproject.toml
index 547b0a1..8f8ecb7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,11 @@ dependencies = [
     "signalslot"
 ]
 
+[project.optional-dependencies]
+call = [
+    "prompt_toolkit"
+]
+
 [project.readme]
 file = "README.md"
 content-type = "text/markdown"
@@ -37,7 +42,7 @@ email = "me@vk4msl.id.au"
 line-length = 78
 
 [tool.pytest.ini_options]
-log_cli=true
+log_cli = true
 
 [tool.setuptools.dynamic]
 version = {attr = "aioax25.__version__"}


Test patch:
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..6c2449b
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+addopts = --log-level=DEBUG --cov=aioax25 --cov-report=term --cov-report=html --cov-branch
diff --git a/tests/mocks.py b/tests/mocks.py
new file mode 100644
index 0000000..93eef60
--- /dev/null
+++ b/tests/mocks.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+
+import time
+import logging
+from signalslot import Signal
+from aioax25.version import AX25Version
+
+
+class DummyInterface(object):
+    def __init__(self):
+        self.bind_calls = []
+        self.unbind_calls = []
+        self.transmit_calls = []
+
+    def bind(self, *args, **kwargs):
+        self.bind_calls.append((args, kwargs))
+
+    def unbind(self, *args, **kwargs):
+        self.unbind_calls.append((args, kwargs))
+
+    def transmit(self, *args, **kwargs):
+        self.transmit_calls.append((args, kwargs))
+
+
+class DummyLogger(object):
+    def __init__(self, name, parent=None):
+        self.parent = parent
+        self.name = name
+        self.logs = []
+
+    def _log(self, name, level, msg, *args, **kwargs):
+        if self.parent is not None:
+            self.parent._log(self.name, level, msg, *args, **kwargs)
+        self.logs.append((self.name, level, msg, args, kwargs))
+
+    def log(self, level, msg, *args, **kwargs):
+        self._log(self.name, level, msg, *args, **kwargs)
+
+    def critical(self, msg, *args, **kwargs):
+        self.log(logging.CRITICAL, msg, *args, **kwargs)
+
+    def debug(self, msg, *args, **kwargs):
+        self.log(logging.DEBUG, msg, *args, **kwargs)
+
+    def error(self, msg, *args, **kwargs):
+        self.log(logging.ERROR, msg, *args, **kwargs)
+
+    def info(self, msg, *args, **kwargs):
+        self.log(logging.INFO, msg, *args, **kwargs)
+
+    def warn(self, msg, *args, **kwargs):
+        self.log(logging.WARNING, msg, *args, **kwargs)
+
+    def warning(self, msg, *args, **kwargs):
+        self.log(logging.WARNING, msg, *args, **kwargs)
+
+    def getChild(self, name):
+        return DummyLogger(self.name + "." + name, parent=self)
+
+    def isEnabledFor(self, level):
+        return True
+
+
+class DummyTimeout(object):
+    def __init__(self, delay, callback, *args, **kwargs):
+        self.args = args
+        self.kwargs = kwargs
+        self.callback = callback
+        self.delay = delay
+
+        self.cancelled = False
+
+    def cancel(self):
+        self.cancelled = True
+
+
+class DummyIOLoop(object):
+    def __init__(self):
+        self.call_soon_list = []
+        self.call_later_list = []
+
+    def time(self):
+        return time.monotonic()
+
+    def call_soon(self, callback, *args, **kwargs):
+        self.call_soon_list.append((callback, args, kwargs))
+
+    def call_later(self, delay, callback, *args, **kwargs):
+        timeout = DummyTimeout(delay, callback, *args, **kwargs)
+        self.call_later_list.append(timeout)
+        return timeout
+
+
+class DummyStation(object):
+    def __init__(self, address, reply_path=None):
+        self._interface_ref = DummyInterface()
+        self.address = address
+        self.reply_path = reply_path or []
+        self._full_duplex = False
+        self._protocol = AX25Version.AX25_22
+        self.connection_request = Signal()
+
+    def _interface(self):
+        return self._interface_ref
+
+
+class DummyPeer(object):
+    def __init__(self, station, address):
+        self._station_ref = station
+        self._log = DummyLogger("peer")
+        self._loop = DummyIOLoop()
+
+        self._max_retries = 2
+        self._ack_timeout = 0.1
+
+        self.address_read = False
+        self._address = address
+
+        self._negotiate_calls = []
+        self.transmit_calls = []
+        self.on_receive_calls = []
+
+        self._testframe_handler = None
+        self._uaframe_handler = None
+        self._frmrframe_handler = None
+        self._dmframe_handler = None
+        self._sabmframe_handler = None
+        self._xidframe_handler = None
+
+        self._negotiated = False
+        self._protocol = AX25Version.UNKNOWN
+
+        self._modulo128 = False
+        self._init_connection_modulo = None
+
+    # Our fake weakref
+    def _station(self):
+        return self._station_ref
+
+    @property
+    def address(self):
+        self.address_read = True
+        return self._address
+
+    def _init_connection(self, extended):
+        if extended is True:
+            self._init_connection_modulo = 128
+        elif extended is False:
+            self._init_connection_modulo = 8
+        else:
+            raise ValueError("Invalid extended value %r" % extended)
+
+    def _negotiate(self, callback):
+        self._negotiate_calls.append(callback)
+
+    def _on_receive(self, *args, **kwargs):
+        self.on_receive_calls.append((args, kwargs))
+
+    def _transmit_frame(self, frame, callback=None):
+        self.transmit_calls.append((frame, callback))
+
+    def _send_sabm(self):
+        self._transmit_frame("sabm")
+
+    def _send_dm(self):
+        self._transmit_frame("dm")
+
+    def _send_xid(self, cr=False):
+        self._transmit_frame("xid:cr=%s" % cr)
diff --git a/tests/test_frame/test_ax25address.py b/tests/test_frame/test_ax25address.py
index a2b2968..82702cd 100644
--- a/tests/test_frame/test_ax25address.py
+++ b/tests/test_frame/test_ax25address.py
@@ -109,6 +109,18 @@ def test_decode_str_ssid():
     assert addr._ssid == 12
 
 
+def test_decode_str_override_ssid():
+    """
+    Test that we can override the SSID in a string.
+    """
+    addr = AX25Address.decode(
+        "VK4MSL-12",
+        # This will override the -12 above
+        ssid=9,
+    )
+    assert addr._ssid == 9
+
+
 def test_decode_str_ch():
     """
     Test that we can decode the C/H bit in a string.
@@ -326,6 +338,18 @@ def test_copy():
         assert getattr(a, field) == getattr(b, field)
 
 
+def test_normcopy():
+    """
+    Test we can get normalised copies with specific bits set.
+    """
+    a = AX25Address("VK4MSL", 15, ch=True, res0=False, res1=False)
+    b = a.normcopy(res0=False, ch=True)
+
+    assert b._ch is True
+    assert b._res0 is False
+    assert b._res1 is True
+
+
 def test_normalised():
     """
     Test we can get normalised copies for comparison.
diff --git a/tests/test_frame/test_ax25frame.py b/tests/test_frame/test_ax25frame.py
index 6814c08..2c4b3ef 100644
--- a/tests/test_frame/test_ax25frame.py
+++ b/tests/test_frame/test_ax25frame.py
@@ -4,11 +4,18 @@
     AX25Frame,
     AX25RawFrame,
     AX25UnnumberedInformationFrame,
-    AX25FrameRejectFrame,
-    AX25UnnumberedFrame,
+    AX258BitReceiveReadyFrame,
+    AX2516BitReceiveReadyFrame,
+    AX258BitRejectFrame,
+    AX2516BitRejectFrame,
+    AX258BitInformationFrame,
+    AX2516BitInformationFrame,
 )
+
 from ..hex import from_hex, hex_cmp
 
+# Basic frame operations
+
 
 def test_decode_incomplete():
     """
@@ -34,13 +41,11 @@ def test_decode_iframe():
         from_hex(
             "ac 96 68 84 ae 92 e0"  # Destination
             "ac 96 68 9a a6 98 61"  # Source
-            "00"  # Control byte
-            "11 22 33 44 55 66 77"  # Payload
+            "00 11 22 33 44 55 66 77"  # Payload
         )
     )
     assert isinstance(frame, AX25RawFrame), "Did not decode to raw frame"
-    assert frame.control == 0x00
-    hex_cmp(frame.frame_payload, "11 22 33 44 55 66 77")
+    hex_cmp(frame.frame_payload, "00 11 22 33 44 55 66 77")
 
 
 def test_decode_sframe():
@@ -51,129 +56,91 @@ def test_decode_sframe():
         from_hex(
             "ac 96 68 84 ae 92 e0"  # Destination
             "ac 96 68 9a a6 98 61"  # Source
-            "01"  # Control byte
-            "11 22 33 44 55 66 77"  # Payload
+            "01 11 22 33 44 55 66 77"  # Payload
         )
     )
     assert isinstance(frame, AX25RawFrame), "Did not decode to raw frame"
-    assert frame.control == 0x01
-    hex_cmp(frame.frame_payload, "11 22 33 44 55 66 77")
+    hex_cmp(frame.frame_payload, "01 11 22 33 44 55 66 77")
 
 
-def test_decode_uframe():
+def test_decode_rawframe():
     """
-    Test that a U-frame gets decoded to an unnumbered frame.
+    Test that we can decode an AX25RawFrame.
     """
-    frame = AX25Frame.decode(
-        from_hex(
-            "ac 96 68 84 ae 92 e0"  # Destination
-            "ac 96 68 9a a6 98 61"  # Source
-            "c3"  # Control byte
-        )
+    rawframe = AX25RawFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        cr=True,
+        payload=b"\x03\xf0This is a test",
     )
-    assert isinstance(
-        frame, AX25UnnumberedFrame
-    ), "Did not decode to unnumbered frame"
-    assert frame.modifier == 0xC3
-    hex_cmp(frame.frame_payload, "")
+    frame = AX25Frame.decode(rawframe)
+    assert isinstance(frame, AX25UnnumberedInformationFrame)
+    assert frame.pid == 0xF0
+    assert frame.payload == b"This is a test"
 
 
-def test_decode_uframe_payload():
+def test_frame_timestamp():
     """
-    Test that U-frames other than FRMR and UI are forbidden to have payloads.
+    Test that the timestamp property is set from constructor.
     """
-    try:
-        AX25Frame.decode(
-            from_hex(
-                "ac 96 68 84 ae 92 e0"  # Destination
-                "ac 96 68 9a a6 98 61"  # Source
-                "c3 11 22 33"  # Control byte
-            )
-        )
-        assert False, "Should not have worked"
-    except ValueError as e:
-        assert str(e) == (
-            "Unnumbered frames (other than UI and "
-            "FRMR) do not have payloads"
-        )
+    frame = AX25RawFrame(
+        destination="VK4BWI", source="VK4MSL", timestamp=11223344
+    )
+    assert frame.timestamp == 11223344
 
 
-def test_decode_frmr():
+def test_frame_deadline():
     """
-    Test that a FRMR gets decoded to a frame reject frame.
+    Test that the deadline property is set from constructor.
     """
-    frame = AX25Frame.decode(
-        from_hex(
-            "ac 96 68 84 ae 92 e0"  # Destination
-            "ac 96 68 9a a6 98 61"  # Source
-            "87"  # Control byte
-            "11 22 33"  # Payload
-        )
+    frame = AX25RawFrame(
+        destination="VK4BWI", source="VK4MSL", deadline=11223344
     )
-    assert isinstance(
-        frame, AX25FrameRejectFrame
-    ), "Did not decode to FRMR frame"
-    assert frame.modifier == 0x87
-    assert frame.w == True
-    assert frame.x == False
-    assert frame.y == False
-    assert frame.z == False
-    assert frame.vr == 1
-    assert frame.frmr_cr == False
-    assert frame.vs == 1
+    assert frame.deadline == 11223344
 
 
-def test_decode_frmr_len():
+def test_frame_deadline_ro_if_set_constructor():
     """
-    Test that a FRMR must have 3 byte payload.
+    Test that the deadline property is read-only once set by contructor
     """
+    frame = AX25RawFrame(
+        destination="VK4BWI", source="VK4MSL", deadline=11223344
+    )
     try:
-        AX25Frame.decode(
-            from_hex(
-                "ac 96 68 84 ae 92 e0"  # Destination
-                "ac 96 68 9a a6 98 61"  # Source
-                "87"  # Control byte
-                "11 22"  # Payload
-            )
-        )
-        assert False, "Should not have worked"
+        frame.deadline = 99887766
     except ValueError as e:
-        assert str(e) == "Payload of FRMR must be 3 bytes"
+        assert str(e) == "Deadline may not be changed after being set"
 
+    assert frame.deadline == 11223344
 
-def test_decode_ui():
+
+def test_frame_deadline_ro_if_set():
     """
-    Test that a UI gets decoded to an unnumbered information frame.
+    Test that the deadline property is read-only once set after constructor
     """
-    frame = AX25Frame.decode(
-        from_hex(
-            "ac 96 68 84 ae 92 e0"  # Destination
-            "ac 96 68 9a a6 98 61"  # Source
-            "03 11 22 33"  # Control byte
-        )
+    frame = AX25RawFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
     )
-    assert isinstance(
-        frame, AX25UnnumberedInformationFrame
-    ), "Did not decode to UI frame"
-    assert frame.pid == 0x11
-    hex_cmp(frame.payload, "22 33")
 
+    frame.deadline = 44556677
 
-def test_decode_ui_len():
-    """
-    Test that a UI must have at least one byte payload.
-    """
     try:
-        AX25Frame.decode(
-            from_hex(
-                "ac 96 68 84 ae 92 e0"  # Destination
-                "ac 96 68 9a a6 98 61"  # Source
-                "03"  # Control byte
-            )
-        )
-        assert False, "Should not have worked"
+        frame.deadline = 99887766
     except ValueError as e:
-        assert str(e) == "Payload of UI must be at least one byte"
+        assert str(e) == "Deadline may not be changed after being set"
+
+    assert frame.deadline == 44556677
+
+
+def test_frame_tnc2():
+    """
+    Test that we can get a TNC2-compatible frame string.
+    """
+    frame = AX25RawFrame(
+        destination="VK4BWI", source="VK4MSL", deadline=11223344
+    )
+    assert frame.tnc2 == "VK4MSL>VK4BWI"
 
 
 def test_encode_raw():
@@ -185,8 +152,7 @@ def test_encode_raw():
         destination="VK4BWI",
         source="VK4MSL",
         cr=True,
-        control=0x03,
-        payload=b"\xf0This is a test",
+        payload=b"\x03\xf0This is a test",
     )
     hex_cmp(
         bytes(frame),
@@ -197,52 +163,40 @@ def test_encode_raw():
     )
 
 
-def test_encode_uframe():
+def test_raw_copy():
     """
-    Test that we can encode a U-frame.
+    Test we can make a copy of a raw frame.
     """
-    frame = AX25UnnumberedFrame(
-        destination="VK4BWI", source="VK4MSL", modifier=0xE7, cr=True
+    frame = AX25RawFrame(
+        destination="VK4BWI", source="VK4MSL", payload=b"\xabThis is a test"
     )
+    framecopy = frame.copy()
+    assert framecopy is not frame
+
     hex_cmp(
-        bytes(frame),
-        "ac 96 68 84 ae 92 e0"  # Destination
-        "ac 96 68 9a a6 98 61"  # Source
-        "e7",  # Control
+        bytes(framecopy),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "ab"  # Control
+        "54 68 69 73 20 69 73 20 61 20 74 65 73 74",  # Payload
     )
 
 
-def test_encode_frmr():
+def test_raw_str():
     """
-    Test that we can encode a FRMR.
+    Test we can get a string representation of a raw frame.
     """
-    frame = AX25FrameRejectFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        w=True,
-        x=False,
-        y=True,
-        z=False,
-        vr=1,
-        frmr_cr=False,
-        vs=2,
-        frmr_control=0xAA,
-        cr=True,
+    frame = AX25RawFrame(
+        destination="VK4BWI", source="VK4MSL", payload=b"\xabThis is a test"
     )
-    hex_cmp(
-        bytes(frame),
-        "ac 96 68 84 ae 92 e0"  # Destination
-        "ac 96 68 9a a6 98 61"  # Source
-        "87"  # Control
-        "05"  # W/X/Y/Z
-        "24"  # VR/CR/VS
-        "aa",  # FRMR Control
+    assert str(frame) == (
+        "AX25RawFrame VK4MSL>VK4BWI:\n" "Payload=b'\\xabThis is a test'"
     )
 
 
-def test_encode_ui():
+def test_ui_str():
     """
-    Test that we can encode a UI frame.
+    Test we can get a string representation of a UI frame.
     """
     frame = AX25UnnumberedInformationFrame(
         destination="VK4BWI",
@@ -251,366 +205,279 @@ def test_encode_ui():
         pid=0xF0,
         payload=b"This is a test",
     )
-    hex_cmp(
-        bytes(frame),
-        "ac 96 68 84 ae 92 e0"  # Destination
-        "ac 96 68 9a a6 98 61"  # Source
-        "03"  # Control
-        "f0"  # PID
-        "54 68 69 73 20 69 73 20 61 20 74 65 73 74",  # Payload
+    assert str(frame) == (
+        "AX25UnnumberedInformationFrame VK4MSL>VK4BWI: "
+        "Control=0x03 P/F=False Modifier=0x03 PID=0xf0\n"
+        "Payload=b'This is a test'"
     )
 
 
-def test_encode_pf():
+def test_ui_tnc2():
     """
-    Test we can set the PF bit on a frame.
+    Test we can get a TNC2 string representation of a UI frame.
     """
     frame = AX25UnnumberedInformationFrame(
-        destination="VK4BWI",
+        destination="VK4BWI*",
         source="VK4MSL",
         cr=True,
-        pf=True,
         pid=0xF0,
         payload=b"This is a test",
     )
-    hex_cmp(
-        bytes(frame),
-        "ac 96 68 84 ae 92 e0"  # Destination
-        "ac 96 68 9a a6 98 61"  # Source
-        "13"  # Control
-        "f0"  # PID
-        "54 68 69 73 20 69 73 20 61 20 74 65 73 74",  # Payload
-    )
+    assert frame.tnc2 == "VK4MSL>VK4BWI:This is a test"
 
 
-def test_encode_frmr_w():
-    """
-    Test we can set the W bit on a FRMR frame.
-    """
-    frame = AX25FrameRejectFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        w=True,
-        x=False,
-        y=False,
-        z=False,
-        vr=0,
-        vs=0,
-        frmr_control=0,
-        frmr_cr=False,
-    )
-    hex_cmp(
-        bytes(frame),
-        "ac 96 68 84 ae 92 60"  # Destination
-        "ac 96 68 9a a6 98 e1"  # Source
-        "87"  # Control
-        "01 00 00",  # FRMR data
-    )
+# Supervisory frame tests
 
 
-def test_encode_frmr_x():
+def test_sframe_payload_reject():
     """
-    Test we can set the X bit on a FRMR frame.
+    Test payloads are forbidden for S-frames
     """
-    frame = AX25FrameRejectFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        w=False,
-        x=True,
-        y=False,
-        z=False,
-        vr=0,
-        vs=0,
-        frmr_control=0,
-        frmr_cr=False,
-    )
-    hex_cmp(
-        bytes(frame),
-        "ac 96 68 84 ae 92 60"  # Destination
-        "ac 96 68 9a a6 98 e1"  # Source
-        "87"  # Control
-        "02 00 00",  # FRMR data
-    )
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 60"  # Destination
+                "ac 96 68 9a a6 98 e1"  # Source
+                "41"  # Control
+                "31 32 33 34 35"  # Payload
+            ),
+            modulo128=False,
+        )
+        assert False, "Should not have worked"
+    except ValueError as e:
+        assert str(e) == "Supervisory frames do not support payloads."
 
 
-def test_encode_frmr_y():
+def test_16bs_truncated_reject():
     """
-    Test we can set the Y bit on a FRMR frame.
+    Test that 16-bit S-frames with truncated control fields are rejected.
     """
-    frame = AX25FrameRejectFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        w=False,
-        x=False,
-        y=True,
-        z=False,
-        vr=0,
-        vs=0,
-        frmr_control=0,
-        frmr_cr=False,
-    )
-    hex_cmp(
-        bytes(frame),
-        "ac 96 68 84 ae 92 60"  # Destination
-        "ac 96 68 9a a6 98 e1"  # Source
-        "87"  # Control
-        "04 00 00",  # FRMR data
-    )
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 60"  # Destination
+                "ac 96 68 9a a6 98 e1"  # Source
+                "01"  # Control (LSB only)
+            ),
+            modulo128=True,
+        )
+        assert False, "Should not have worked"
+    except ValueError as e:
+        assert str(e) == "Insufficient packet data"
 
 
-def test_encode_frmr_z():
+def test_8bs_rr_frame():
     """
-    Test we can set the Z bit on a FRMR frame.
+    Test we can generate a 8-bit RR supervisory frame
     """
-    frame = AX25FrameRejectFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        w=False,
-        x=False,
-        y=False,
-        z=True,
-        vr=0,
-        vs=0,
-        frmr_control=0,
-        frmr_cr=False,
-    )
-    hex_cmp(
-        bytes(frame),
-        "ac 96 68 84 ae 92 60"  # Destination
-        "ac 96 68 9a a6 98 e1"  # Source
-        "87"  # Control
-        "08 00 00",  # FRMR data
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "41"  # Control
+        ),
+        modulo128=False,
     )
+    assert isinstance(frame, AX258BitReceiveReadyFrame)
+    assert frame.nr == 2
 
 
-def test_encode_frmr_cr():
+def test_16bs_rr_frame():
     """
-    Test we can set the CR bit on a FRMR frame.
+    Test we can generate a 16-bit RR supervisory frame
     """
-    frame = AX25FrameRejectFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        w=False,
-        x=False,
-        y=False,
-        z=False,
-        vr=0,
-        vs=0,
-        frmr_control=0,
-        frmr_cr=True,
-    )
-    hex_cmp(
-        bytes(frame),
-        "ac 96 68 84 ae 92 60"  # Destination
-        "ac 96 68 9a a6 98 e1"  # Source
-        "87"  # Control
-        "00 10 00",  # FRMR data
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "01 5c"  # Control
+        ),
+        modulo128=True,
     )
+    assert isinstance(frame, AX2516BitReceiveReadyFrame)
+    assert frame.nr == 46
 
 
-def test_encode_frmr_vr():
+def test_16bs_rr_encode():
     """
-    Test we can set the V(R) field on a FRMR frame.
+    Test we can encode a 16-bit RR supervisory frame
     """
-    frame = AX25FrameRejectFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        w=False,
-        x=False,
-        y=False,
-        z=False,
-        vr=5,
-        vs=0,
-        frmr_control=0,
-        frmr_cr=False,
+    frame = AX2516BitReceiveReadyFrame(
+        destination="VK4BWI", source="VK4MSL", nr=46, pf=True
     )
     hex_cmp(
         bytes(frame),
         "ac 96 68 84 ae 92 60"  # Destination
         "ac 96 68 9a a6 98 e1"  # Source
-        "87"  # Control
-        "00 a0 00",  # FRMR data
+        "01 5d",  # Control
     )
+    assert frame.control == 0x5D01
 
 
-def test_encode_frmr_vs():
+def test_8bs_rej_decode_frame():
     """
-    Test we can set the V(S) field on a FRMR frame.
+    Test we can decode a 8-bit REJ supervisory frame
     """
-    frame = AX25FrameRejectFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        w=False,
-        x=False,
-        y=False,
-        z=False,
-        vr=0,
-        vs=5,
-        frmr_control=0,
-        frmr_cr=False,
-    )
-    hex_cmp(
-        bytes(frame),
-        "ac 96 68 84 ae 92 60"  # Destination
-        "ac 96 68 9a a6 98 e1"  # Source
-        "87"  # Control
-        "00 0a 00",  # FRMR data
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "09"  # Control byte
+        ),
+        modulo128=False,
     )
+    assert isinstance(
+        frame, AX258BitRejectFrame
+    ), "Did not decode to REJ frame"
+    assert frame.nr == 0
+    assert frame.pf == False
 
 
-def test_encode_frmr_frmr_ctrl():
+def test_16bs_rej_decode_frame():
     """
-    Test we can set the FRMR Control field on a FRMR frame.
+    Test we can decode a 16-bit REJ supervisory frame
     """
-    frame = AX25FrameRejectFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        w=False,
-        x=False,
-        y=False,
-        z=False,
-        vr=0,
-        vs=0,
-        frmr_control=0x55,
-        frmr_cr=False,
-    )
-    hex_cmp(
-        bytes(frame),
-        "ac 96 68 84 ae 92 60"  # Destination
-        "ac 96 68 9a a6 98 e1"  # Source
-        "87"  # Control
-        "00 00 55",  # FRMR data
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "09 00"  # Control bytes
+        ),
+        modulo128=True,
     )
+    assert isinstance(
+        frame, AX2516BitRejectFrame
+    ), "Did not decode to REJ frame"
+    assert frame.nr == 0
+    assert frame.pf == False
 
 
-def test_raw_copy():
+def test_rr_frame_str():
     """
-    Test we can make a copy of a raw frame.
+    Test we can get the string representation of a RR frame.
     """
-    frame = AX25RawFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        control=0xAB,
-        payload=b"This is a test",
+    frame = AX258BitReceiveReadyFrame(
+        destination="VK4BWI", source="VK4MSL", nr=6
     )
-    framecopy = frame.copy()
-    assert framecopy is not frame
 
-    hex_cmp(
-        bytes(framecopy),
-        "ac 96 68 84 ae 92 60"  # Destination
-        "ac 96 68 9a a6 98 e1"  # Source
-        "ab"  # Control
-        "54 68 69 73 20 69 73 20 61 20 74 65 73 74",  # Payload
+    assert str(frame) == (
+        "AX258BitReceiveReadyFrame VK4MSL>VK4BWI: N(R)=6 P/F=False "
+        "Code=0x00"
     )
 
 
-def test_u_copy():
+def test_rr_frame_copy():
     """
-    Test we can make a copy of a unnumbered frame.
+    Test we can get the string representation of a RR frame.
     """
-    frame = AX25UnnumberedFrame(
-        destination="VK4BWI", source="VK4MSL", modifier=0x43  # Disconnect
+    frame = AX258BitReceiveReadyFrame(
+        destination="VK4BWI", source="VK4MSL", nr=6
     )
     framecopy = frame.copy()
-    assert framecopy is not frame
 
+    assert framecopy is not frame
     hex_cmp(
         bytes(framecopy),
         "ac 96 68 84 ae 92 60"  # Destination
         "ac 96 68 9a a6 98 e1"  # Source
-        "43",  # Control
+        "c1",  # Control byte
     )
 
 
-def test_ui_copy():
+# Information frames
+
+
+def test_8bit_iframe_decode():
     """
-    Test we can make a copy of a unnumbered information frame.
+    Test we can decode an 8-bit information frame.
     """
-    frame = AX25UnnumberedInformationFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        cr=True,
-        pid=0xF0,
-        payload=b"This is a test",
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "d4"  # Control
+            "ff"  # PID
+            "54 68 69 73 20 69 73 20 61 20 74 65 73 74"  # Payload
+        ),
+        modulo128=False,
     )
-    framecopy = frame.copy()
-    assert framecopy is not frame
 
-    hex_cmp(
-        bytes(framecopy),
-        "ac 96 68 84 ae 92 e0"  # Destination
-        "ac 96 68 9a a6 98 61"  # Source
-        "03"  # Control
-        "f0"  # PID
-        "54 68 69 73 20 69 73 20 61 20 74 65 73 74",  # Payload
-    )
+    assert isinstance(
+        frame, AX258BitInformationFrame
+    ), "Did not decode to 8-bit I-Frame"
+    assert frame.nr == 6
+    assert frame.ns == 2
+    assert frame.pid == 0xFF
+    assert frame.payload == b"This is a test"
 
 
-def test_frmr_copy():
+def test_16bit_iframe_decode():
     """
-    Test we can copy a FRMR frame.
+    Test we can decode an 16-bit information frame.
     """
-    frame = AX25FrameRejectFrame(
-        destination="VK4BWI",
-        source="VK4MSL",
-        w=False,
-        x=False,
-        y=False,
-        z=False,
-        vr=0,
-        vs=0,
-        frmr_control=0x55,
-        frmr_cr=False,
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "04 0d"  # Control
+            "ff"  # PID
+            "54 68 69 73 20 69 73 20 61 20 74 65 73 74"  # Payload
+        ),
+        modulo128=True,
     )
-    framecopy = frame.copy()
 
-    assert framecopy is not frame
-    hex_cmp(
-        bytes(framecopy),
-        "ac 96 68 84 ae 92 60"  # Destination
-        "ac 96 68 9a a6 98 e1"  # Source
-        "87"  # Control
-        "00 00 55",  # FRMR data
-    )
+    assert isinstance(
+        frame, AX2516BitInformationFrame
+    ), "Did not decode to 16-bit I-Frame"
+    assert frame.nr == 6
+    assert frame.ns == 2
+    assert frame.pid == 0xFF
+    assert frame.payload == b"This is a test"
 
 
-def test_raw_str():
+def test_iframe_str():
     """
-    Test we can get a string representation of a raw frame.
+    Test we can get the string representation of an information frame.
     """
-    frame = AX25RawFrame(
+    frame = AX258BitInformationFrame(
         destination="VK4BWI",
         source="VK4MSL",
-        control=0xAB,
-        payload=b"This is a test",
+        nr=6,
+        ns=2,
+        pid=0xFF,
+        pf=True,
+        payload=b"Testing 1 2 3",
+    )
+
+    assert str(frame) == (
+        "AX258BitInformationFrame VK4MSL>VK4BWI: "
+        "N(R)=6 P/F=True N(S)=2 PID=0xff\n"
+        "Payload=b'Testing 1 2 3'"
     )
-    assert str(frame) == "VK4MSL>VK4BWI"
 
 
-def test_ui_str():
+def test_iframe_copy():
     """
-    Test we can get a string representation of a UI frame.
+    Test we can get the string representation of an information frame.
     """
-    frame = AX25UnnumberedInformationFrame(
+    frame = AX258BitInformationFrame(
         destination="VK4BWI",
         source="VK4MSL",
-        cr=True,
-        pid=0xF0,
-        payload=b"This is a test",
+        nr=6,
+        ns=2,
+        pid=0xFF,
+        pf=True,
+        payload=b"Testing 1 2 3",
     )
-    assert str(frame) == "VK4MSL>VK4BWI: PID=0xf0 Payload=b'This is a test'"
-
+    framecopy = frame.copy()
 
-def test_ui_tnc2():
-    """
-    Test we can get a TNC2 string representation of a UI frame.
-    """
-    frame = AX25UnnumberedInformationFrame(
-        destination="VK4BWI*",
-        source="VK4MSL",
-        cr=True,
-        pid=0xF0,
-        payload=b"This is a test",
+    assert framecopy is not frame
+    hex_cmp(
+        bytes(framecopy),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "d4"  # Control byte
+        "ff"  # PID
+        "54 65 73 74 69 6e 67 20"
+        "31 20 32 20 33",  # Payload
     )
-    assert frame.tnc2 == "VK4MSL>VK4BWI:This is a test"
diff --git a/tests/test_frame/test_ax25frameheader.py b/tests/test_frame/test_ax25frameheader.py
index b99e3e0..1723486 100644
--- a/tests/test_frame/test_ax25frameheader.py
+++ b/tests/test_frame/test_ax25frameheader.py
@@ -34,6 +34,39 @@ def test_decode_no_digis():
     assert data == b"frame data goes here"
 
 
+def test_decode_legacy():
+    """
+    Test we can decode an AX.25 1.x frame.
+    """
+    (header, data) = AX25FrameHeader.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 e0"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+        )
+        + b"frame data goes here"  # Frame data
+    )
+    assert header.legacy
+    assert header.cr
+    assert header.destination == AX25Address("VK4BWI", ch=True)
+    assert header.source == AX25Address("VK4MSL", extension=True, ch=True)
+    assert len(header.repeaters) == 0
+    assert data == b"frame data goes here"
+
+
+def test_encode_legacy():
+    """
+    Test we can encode an AX.25 1.x frame.
+    """
+    header = AX25FrameHeader(
+        destination="VK4BWI", source="VK4MSL", cr=False, legacy=True
+    )
+    hex_cmp(
+        bytes(header),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 61",  # Source
+    )
+
+
 def test_decode_with_1digi():
     """
     Test we can decode an AX.25 frame with one digipeater.
diff --git a/tests/test_frame/test_iframe.py b/tests/test_frame/test_iframe.py
new file mode 100644
index 0000000..5839e27
--- /dev/null
+++ b/tests/test_frame/test_iframe.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+
+from aioax25.frame import (
+    AX25Frame,
+    AX258BitInformationFrame,
+    AX2516BitInformationFrame,
+)
+
+from ..hex import from_hex, hex_cmp
+
+
+def test_8bit_iframe_decode():
+    """
+    Test we can decode an 8-bit information frame.
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "d4"  # Control
+            "ff"  # PID
+            "54 68 69 73 20 69 73 20 61 20 74 65 73 74"  # Payload
+        ),
+        modulo128=False,
+    )
+
+    assert isinstance(
+        frame, AX258BitInformationFrame
+    ), "Did not decode to 8-bit I-Frame"
+    assert frame.nr == 6
+    assert frame.ns == 2
+    assert frame.pid == 0xFF
+    assert frame.payload == b"This is a test"
+
+
+def test_16bit_iframe_decode():
+    """
+    Test we can decode an 16-bit information frame.
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "04 0d"  # Control
+            "ff"  # PID
+            "54 68 69 73 20 69 73 20 61 20 74 65 73 74"  # Payload
+        ),
+        modulo128=True,
+    )
+
+    assert isinstance(
+        frame, AX2516BitInformationFrame
+    ), "Did not decode to 16-bit I-Frame"
+    assert frame.nr == 6
+    assert frame.ns == 2
+    assert frame.pid == 0xFF
+    assert frame.payload == b"This is a test"
+
+
+def test_iframe_str():
+    """
+    Test we can get the string representation of an information frame.
+    """
+    frame = AX258BitInformationFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        nr=6,
+        ns=2,
+        pid=0xFF,
+        pf=True,
+        payload=b"Testing 1 2 3",
+    )
+
+    assert str(frame) == (
+        "AX258BitInformationFrame VK4MSL>VK4BWI: "
+        "N(R)=6 P/F=True N(S)=2 PID=0xff\n"
+        "Payload=b'Testing 1 2 3'"
+    )
+
+
+def test_iframe_copy():
+    """
+    Test we can get the string representation of an information frame.
+    """
+    frame = AX258BitInformationFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        nr=6,
+        ns=2,
+        pid=0xFF,
+        pf=True,
+        payload=b"Testing 1 2 3",
+    )
+    framecopy = frame.copy()
+
+    assert framecopy is not frame
+    hex_cmp(
+        bytes(framecopy),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "d4"  # Control byte
+        "ff"  # PID
+        "54 65 73 74 69 6e 67 20"
+        "31 20 32 20 33",  # Payload
+    )
diff --git a/tests/test_frame/test_sframe.py b/tests/test_frame/test_sframe.py
new file mode 100644
index 0000000..fc34865
--- /dev/null
+++ b/tests/test_frame/test_sframe.py
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+
+from aioax25.frame import (
+    AX25Frame,
+    AX258BitReceiveReadyFrame,
+    AX2516BitReceiveReadyFrame,
+    AX258BitRejectFrame,
+    AX2516BitRejectFrame,
+)
+
+from ..hex import from_hex, hex_cmp
+
+
+def test_sframe_payload_reject():
+    """
+    Test payloads are forbidden for S-frames
+    """
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 60"  # Destination
+                "ac 96 68 9a a6 98 e1"  # Source
+                "41"  # Control
+                "31 32 33 34 35"  # Payload
+            ),
+            modulo128=False,
+        )
+        assert False, "Should not have worked"
+    except ValueError as e:
+        assert str(e) == "Supervisory frames do not support payloads."
+
+
+def test_16bs_truncated_reject():
+    """
+    Test that 16-bit S-frames with truncated control fields are rejected.
+    """
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 60"  # Destination
+                "ac 96 68 9a a6 98 e1"  # Source
+                "01"  # Control (LSB only)
+            ),
+            modulo128=True,
+        )
+        assert False, "Should not have worked"
+    except ValueError as e:
+        assert str(e) == "Insufficient packet data"
+
+
+def test_8bs_rr_frame():
+    """
+    Test we can generate a 8-bit RR supervisory frame
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "41"  # Control
+        ),
+        modulo128=False,
+    )
+    assert isinstance(frame, AX258BitReceiveReadyFrame)
+    assert frame.nr == 2
+
+
+def test_16bs_rr_frame():
+    """
+    Test we can generate a 16-bit RR supervisory frame
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "01 5c"  # Control
+        ),
+        modulo128=True,
+    )
+    assert isinstance(frame, AX2516BitReceiveReadyFrame)
+    assert frame.nr == 46
+
+
+def test_16bs_rr_encode():
+    """
+    Test we can encode a 16-bit RR supervisory frame
+    """
+    frame = AX2516BitReceiveReadyFrame(
+        destination="VK4BWI", source="VK4MSL", nr=46, pf=True
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "01 5d",  # Control
+    )
+    assert frame.control == 0x5D01
+
+
+def test_8bs_rej_decode_frame():
+    """
+    Test we can decode a 8-bit REJ supervisory frame
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "09"  # Control byte
+        ),
+        modulo128=False,
+    )
+    assert isinstance(
+        frame, AX258BitRejectFrame
+    ), "Did not decode to REJ frame"
+    assert frame.nr == 0
+    assert frame.pf == False
+
+
+def test_16bs_rej_decode_frame():
+    """
+    Test we can decode a 16-bit REJ supervisory frame
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 60"  # Destination
+            "ac 96 68 9a a6 98 e1"  # Source
+            "09 00"  # Control bytes
+        ),
+        modulo128=True,
+    )
+    assert isinstance(
+        frame, AX2516BitRejectFrame
+    ), "Did not decode to REJ frame"
+    assert frame.nr == 0
+    assert frame.pf == False
+
+
+def test_rr_frame_str():
+    """
+    Test we can get the string representation of a RR frame.
+    """
+    frame = AX258BitReceiveReadyFrame(
+        destination="VK4BWI", source="VK4MSL", nr=6
+    )
+
+    assert str(frame) == (
+        "AX258BitReceiveReadyFrame VK4MSL>VK4BWI: N(R)=6 P/F=False "
+        "Code=0x00"
+    )
+
+
+def test_rr_frame_copy():
+    """
+    Test we can get the string representation of a RR frame.
+    """
+    frame = AX258BitReceiveReadyFrame(
+        destination="VK4BWI", source="VK4MSL", nr=6
+    )
+    framecopy = frame.copy()
+
+    assert framecopy is not frame
+    hex_cmp(
+        bytes(framecopy),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "c1",  # Control byte
+    )
diff --git a/tests/test_frame/test_uframe.py b/tests/test_frame/test_uframe.py
new file mode 100644
index 0000000..98933ac
--- /dev/null
+++ b/tests/test_frame/test_uframe.py
@@ -0,0 +1,712 @@
+#!/usr/bin/env python3
+
+from aioax25.frame import (
+    AX25DisconnectModeFrame,
+    AX25Frame,
+    AX25FrameRejectFrame,
+    AX25SetAsyncBalancedModeFrame,
+    AX25SetAsyncBalancedModeExtendedFrame,
+    AX25TestFrame,
+    AX25UnnumberedAcknowledgeFrame,
+    AX25UnnumberedFrame,
+    AX25UnnumberedInformationFrame,
+)
+
+from ..hex import from_hex, hex_cmp
+
+
+def test_decode_uframe():
+    """
+    Test that a U-frame gets decoded to an unnumbered frame.
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 e0"  # Destination
+            "ac 96 68 9a a6 98 61"  # Source
+            "c3"  # Control byte
+        )
+    )
+    assert isinstance(
+        frame, AX25UnnumberedFrame
+    ), "Did not decode to unnumbered frame"
+    assert frame.modifier == 0xC3
+
+    # We should see the control byte as our payload
+    hex_cmp(frame.frame_payload, "c3")
+
+
+def test_decode_sabm():
+    """
+    Test that a SABM frame is recognised and decoded.
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 e0"  # Destination
+            "ac 96 68 9a a6 98 61"  # Source
+            "3f"  # Control byte
+        )
+    )
+    assert isinstance(
+        frame, AX25SetAsyncBalancedModeFrame
+    ), "Did not decode to SABM frame"
+
+
+def test_decode_sabm_payload():
+    """
+    Test that a SABM frame forbids payload.
+    """
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 e0"  # Destination
+                "ac 96 68 9a a6 98 61"  # Source
+                "3f"  # Control byte
+                "11 22 33 44 55"  # Payload
+            )
+        )
+        assert False, "This should not have worked"
+    except ValueError as e:
+        assert str(e).startswith("Frame does not support payload")
+
+
+def test_decode_sabme():
+    """
+    Test that a SABME frame is recognised and decoded.
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 e0"  # Destination
+            "ac 96 68 9a a6 98 61"  # Source
+            "7f"  # Control byte
+        )
+    )
+    assert isinstance(
+        frame, AX25SetAsyncBalancedModeExtendedFrame
+    ), "Did not decode to SABME frame"
+
+
+def test_decode_sabme_payload():
+    """
+    Test that a SABME frame forbids payload.
+    """
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 e0"  # Destination
+                "ac 96 68 9a a6 98 61"  # Source
+                "6f"  # Control byte
+                "11 22 33 44 55"  # Payload
+            )
+        )
+        assert False, "This should not have worked"
+    except ValueError as e:
+        assert str(e).startswith("Frame does not support payload")
+
+
+def test_decode_uframe_payload():
+    """
+    Test that U-frames other than FRMR and UI are forbidden to have payloads.
+    """
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 e0"  # Destination
+                "ac 96 68 9a a6 98 61"  # Source
+                "c3 11 22 33"  # Control byte
+            )
+        )
+        assert False, "Should not have worked"
+    except ValueError as e:
+        assert str(e) == (
+            "Unnumbered frames (other than UI and "
+            "FRMR) do not have payloads"
+        )
+
+
+def test_decode_frmr():
+    """
+    Test that a FRMR gets decoded to a frame reject frame.
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 e0"  # Destination
+            "ac 96 68 9a a6 98 61"  # Source
+            "87"  # Control byte
+            "11 22 33"  # Payload
+        )
+    )
+    assert isinstance(
+        frame, AX25FrameRejectFrame
+    ), "Did not decode to FRMR frame"
+    assert frame.modifier == 0x87
+    assert frame.w == True
+    assert frame.x == False
+    assert frame.y == False
+    assert frame.z == False
+    assert frame.vr == 1
+    assert frame.frmr_cr == False
+    assert frame.vs == 1
+
+
+def test_decode_frmr_len():
+    """
+    Test that a FRMR must have 3 byte payload.
+    """
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 e0"  # Destination
+                "ac 96 68 9a a6 98 61"  # Source
+                "87"  # Control byte
+                "11 22"  # Payload
+            )
+        )
+        assert False, "Should not have worked"
+    except ValueError as e:
+        assert str(e) == "Payload of FRMR must be 3 bytes"
+
+
+def test_decode_ui():
+    """
+    Test that a UI gets decoded to an unnumbered information frame.
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 e0"  # Destination
+            "ac 96 68 9a a6 98 61"  # Source
+            "03 11 22 33"  # Control byte
+        )
+    )
+    assert isinstance(
+        frame, AX25UnnumberedInformationFrame
+    ), "Did not decode to UI frame"
+    assert frame.pid == 0x11
+    hex_cmp(frame.payload, "22 33")
+
+
+def test_decode_ui_len():
+    """
+    Test that a UI must have at least one byte payload.
+    """
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 e0"  # Destination
+                "ac 96 68 9a a6 98 61"  # Source
+                "03"  # Control byte
+            )
+        )
+        assert False, "Should not have worked"
+    except ValueError as e:
+        assert str(e) == "Payload of UI must be at least one byte"
+
+
+def test_encode_uframe():
+    """
+    Test that we can encode a U-frame.
+    """
+    frame = AX25UnnumberedFrame(
+        destination="VK4BWI", source="VK4MSL", modifier=0xE7, cr=True
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 e0"  # Destination
+        "ac 96 68 9a a6 98 61"  # Source
+        "e7",  # Control
+    )
+
+
+def test_encode_frmr():
+    """
+    Test that we can encode a FRMR.
+    """
+    frame = AX25FrameRejectFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        w=True,
+        x=False,
+        y=True,
+        z=False,
+        vr=1,
+        frmr_cr=False,
+        vs=2,
+        frmr_control=0xAA,
+        cr=True,
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 e0"  # Destination
+        "ac 96 68 9a a6 98 61"  # Source
+        "87"  # Control
+        "05"  # W/X/Y/Z
+        "24"  # VR/CR/VS
+        "aa",  # FRMR Control
+    )
+
+
+def test_encode_ui():
+    """
+    Test that we can encode a UI frame.
+    """
+    frame = AX25UnnumberedInformationFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        cr=True,
+        pid=0xF0,
+        payload=b"This is a test",
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 e0"  # Destination
+        "ac 96 68 9a a6 98 61"  # Source
+        "03"  # Control
+        "f0"  # PID
+        "54 68 69 73 20 69 73 20 61 20 74 65 73 74",  # Payload
+    )
+
+
+def test_encode_test():
+    """
+    Test that we can encode a TEST frame.
+    """
+    frame = AX25TestFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        cr=True,
+        payload=b"This is a test",
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 e0"  # Destination
+        "ac 96 68 9a a6 98 61"  # Source
+        "e3"  # Control
+        "54 68 69 73 20 69 73 20 61 20 74 65 73 74",  # Payload
+    )
+
+
+def test_decode_test():
+    """
+    Test that we can decode a TEST frame.
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 e0"  # Destination
+            "ac 96 68 9a a6 98 61"  # Source
+            "e3"  # Control
+            "31 32 33 34 35 36 37 38 39 2e 2e 2e"  # Payload
+        )
+    )
+    assert isinstance(frame, AX25TestFrame)
+    assert frame.payload == b"123456789..."
+
+
+def test_copy_test():
+    """
+    Test that we can copy a TEST frame.
+    """
+    frame = AX25TestFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        cr=True,
+        payload=b"This is a test",
+    )
+    framecopy = frame.copy()
+    assert framecopy is not frame
+    hex_cmp(
+        bytes(framecopy),
+        "ac 96 68 84 ae 92 e0"  # Destination
+        "ac 96 68 9a a6 98 61"  # Source
+        "e3"  # Control
+        "54 68 69 73 20 69 73 20 61 20 74 65 73 74",  # Payload
+    )
+
+
+def test_encode_pf():
+    """
+    Test we can set the PF bit on a frame.
+    """
+    frame = AX25UnnumberedInformationFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        cr=True,
+        pf=True,
+        pid=0xF0,
+        payload=b"This is a test",
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 e0"  # Destination
+        "ac 96 68 9a a6 98 61"  # Source
+        "13"  # Control
+        "f0"  # PID
+        "54 68 69 73 20 69 73 20 61 20 74 65 73 74",  # Payload
+    )
+    assert frame.control == 0x13
+
+
+def test_encode_sabm():
+    """
+    Test we can encode a SABM frame.
+    """
+    frame = AX25SetAsyncBalancedModeFrame(
+        destination="VK4BWI", source="VK4MSL"
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 e0"  # Destination
+        "ac 96 68 9a a6 98 61"  # Source
+        "3f",  # Control byte
+    )
+
+
+def test_encode_sabme():
+    """
+    Test we can encode a SABME frame.
+    """
+    frame = AX25SetAsyncBalancedModeExtendedFrame(
+        destination="VK4BWI", source="VK4MSL"
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 e0"  # Destination
+        "ac 96 68 9a a6 98 61"  # Source
+        "7f",  # Control byte
+    )
+
+
+def test_encode_frmr_w():
+    """
+    Test we can set the W bit on a FRMR frame.
+    """
+    frame = AX25FrameRejectFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        w=True,
+        x=False,
+        y=False,
+        z=False,
+        vr=0,
+        vs=0,
+        frmr_control=0,
+        frmr_cr=False,
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "87"  # Control
+        "01 00 00",  # FRMR data
+    )
+
+
+def test_encode_frmr_x():
+    """
+    Test we can set the X bit on a FRMR frame.
+    """
+    frame = AX25FrameRejectFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        w=False,
+        x=True,
+        y=False,
+        z=False,
+        vr=0,
+        vs=0,
+        frmr_control=0,
+        frmr_cr=False,
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "87"  # Control
+        "02 00 00",  # FRMR data
+    )
+
+
+def test_encode_frmr_y():
+    """
+    Test we can set the Y bit on a FRMR frame.
+    """
+    frame = AX25FrameRejectFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        w=False,
+        x=False,
+        y=True,
+        z=False,
+        vr=0,
+        vs=0,
+        frmr_control=0,
+        frmr_cr=False,
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "87"  # Control
+        "04 00 00",  # FRMR data
+    )
+
+
+def test_encode_frmr_z():
+    """
+    Test we can set the Z bit on a FRMR frame.
+    """
+    frame = AX25FrameRejectFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        w=False,
+        x=False,
+        y=False,
+        z=True,
+        vr=0,
+        vs=0,
+        frmr_control=0,
+        frmr_cr=False,
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "87"  # Control
+        "08 00 00",  # FRMR data
+    )
+
+
+def test_encode_frmr_cr():
+    """
+    Test we can set the CR bit on a FRMR frame.
+    """
+    frame = AX25FrameRejectFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        w=False,
+        x=False,
+        y=False,
+        z=False,
+        vr=0,
+        vs=0,
+        frmr_control=0,
+        frmr_cr=True,
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "87"  # Control
+        "00 10 00",  # FRMR data
+    )
+
+
+def test_encode_frmr_vr():
+    """
+    Test we can set the V(R) field on a FRMR frame.
+    """
+    frame = AX25FrameRejectFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        w=False,
+        x=False,
+        y=False,
+        z=False,
+        vr=5,
+        vs=0,
+        frmr_control=0,
+        frmr_cr=False,
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "87"  # Control
+        "00 a0 00",  # FRMR data
+    )
+
+
+def test_encode_frmr_vs():
+    """
+    Test we can set the V(S) field on a FRMR frame.
+    """
+    frame = AX25FrameRejectFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        w=False,
+        x=False,
+        y=False,
+        z=False,
+        vr=0,
+        vs=5,
+        frmr_control=0,
+        frmr_cr=False,
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "87"  # Control
+        "00 0a 00",  # FRMR data
+    )
+
+
+def test_encode_frmr_frmr_ctrl():
+    """
+    Test we can set the FRMR Control field on a FRMR frame.
+    """
+    frame = AX25FrameRejectFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        w=False,
+        x=False,
+        y=False,
+        z=False,
+        vr=0,
+        vs=0,
+        frmr_control=0x55,
+        frmr_cr=False,
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "87"  # Control
+        "00 00 55",  # FRMR data
+    )
+
+
+def test_encode_dm_frame():
+    """
+    Test we can encode a Disconnect Mode frame.
+    """
+    frame = AX25DisconnectModeFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "1f",  # Control
+    )
+
+
+def test_u_copy():
+    """
+    Test we can make a copy of a unnumbered frame.
+    """
+    frame = AX25UnnumberedFrame(
+        destination="VK4BWI", source="VK4MSL", modifier=0x43  # Disconnect
+    )
+    framecopy = frame.copy()
+    assert framecopy is not frame
+
+    hex_cmp(
+        bytes(framecopy),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "43",  # Control
+    )
+
+
+def test_dm_copy():
+    """
+    Test we can make a copy of a Disconnect Mode frame.
+    """
+    frame = AX25DisconnectModeFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+    )
+    framecopy = frame.copy()
+    assert framecopy is not frame
+
+    hex_cmp(
+        bytes(framecopy),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "1f",  # Control
+    )
+
+
+def test_ui_copy():
+    """
+    Test we can make a copy of a unnumbered information frame.
+    """
+    frame = AX25UnnumberedInformationFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        cr=True,
+        pid=0xF0,
+        payload=b"This is a test",
+    )
+    framecopy = frame.copy()
+    assert framecopy is not frame
+
+    hex_cmp(
+        bytes(framecopy),
+        "ac 96 68 84 ae 92 e0"  # Destination
+        "ac 96 68 9a a6 98 61"  # Source
+        "03"  # Control
+        "f0"  # PID
+        "54 68 69 73 20 69 73 20 61 20 74 65 73 74",  # Payload
+    )
+
+
+def test_frmr_copy():
+    """
+    Test we can copy a FRMR frame.
+    """
+    frame = AX25FrameRejectFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        w=False,
+        x=False,
+        y=False,
+        z=False,
+        vr=0,
+        vs=0,
+        frmr_control=0x55,
+        frmr_cr=False,
+    )
+    framecopy = frame.copy()
+
+    assert framecopy is not frame
+    hex_cmp(
+        bytes(framecopy),
+        "ac 96 68 84 ae 92 60"  # Destination
+        "ac 96 68 9a a6 98 e1"  # Source
+        "87"  # Control
+        "00 00 55",  # FRMR data
+    )
+
+
+def test_ua_str():
+    """
+    Test we can get a string representation of a UA frame.
+    """
+    frame = AX25UnnumberedAcknowledgeFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        cr=True,
+    )
+    assert str(frame) == (
+        "AX25UnnumberedAcknowledgeFrame VK4MSL>VK4BWI: "
+        "Control=0x73 P/F=True Modifier=0x63"
+    )
+
+
+def test_ui_str():
+    """
+    Test we can get a string representation of a UI frame.
+    """
+    frame = AX25UnnumberedInformationFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        cr=True,
+        pid=0xF0,
+        payload=b"This is a test",
+    )
+    assert str(frame) == (
+        "AX25UnnumberedInformationFrame VK4MSL>VK4BWI: "
+        "Control=0x03 P/F=False Modifier=0x03 PID=0xf0\n"
+        "Payload=b'This is a test'"
+    )
diff --git a/tests/test_frame/test_xid.py b/tests/test_frame/test_xid.py
new file mode 100644
index 0000000..2e31895
--- /dev/null
+++ b/tests/test_frame/test_xid.py
@@ -0,0 +1,462 @@
+#!/usr/bin/env python3
+
+from aioax25.frame import (
+    AX25Frame,
+    AX25ExchangeIdentificationFrame,
+    AX25XIDRawParameter,
+    AX25XIDParameterIdentifier,
+    AX25XIDClassOfProceduresParameter,
+    AX25XIDHDLCOptionalFunctionsParameter,
+    AX25XIDIFieldLengthReceiveParameter,
+    AX25XIDRetriesParameter,
+)
+
+from ..hex import from_hex, hex_cmp
+
+from pytest import mark
+
+
+def test_encode_xid():
+    """
+    Test that we can encode a XID frame.
+    """
+    frame = AX25ExchangeIdentificationFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        cr=True,
+        fi=0x82,
+        gi=0x80,
+        parameters=[
+            # Typical parameters we'd expect to see
+            AX25XIDClassOfProceduresParameter(half_duplex=True),
+            AX25XIDHDLCOptionalFunctionsParameter(
+                srej=True, rej=True, modulo128=True
+            ),
+            AX25XIDIFieldLengthReceiveParameter(1024),
+            AX25XIDRetriesParameter(5),
+            # Arbitrary parameters for testing
+            AX25XIDRawParameter(pi=0x12, pv=bytes([0x34, 0x56])),
+            AX25XIDRawParameter(pi=0x34, pv=None),
+        ],
+    )
+    hex_cmp(
+        bytes(frame),
+        "ac 96 68 84 ae 92 e0"  # Destination
+        "ac 96 68 9a a6 98 61"  # Source
+        "af"  # Control
+        "82"  # Format indicator
+        "80"  # Group Ident
+        "00 16"  # Group length
+        # First parameter: CoP
+        "02"
+        "02"
+        "00 21"  # Parameter ID  # Length  # Value
+        # Second parameter: HDLC Optional Functions
+        "03"
+        "03"
+        "86 a8 02"  # Parameter ID  # Length  # Value
+        # Third parameter: I field receive size
+        "06" "02" "04 00"
+        # Fourth parameter: retries
+        "0a" "01" "05"
+        # Fifth parameter: custom
+        "12" "02" "34 56"
+        # Sixth parameter: custom, no length set
+        "34" "00",
+    )
+
+
+def test_decode_xid():
+    """
+    Test that we can decode a XID frame.
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "ac 96 68 84 ae 92 e0"  # Destination
+            "ac 96 68 9a a6 98 61"  # Source
+            "af"  # Control
+            "82"  # FI
+            "80"  # GI
+            "00 0c"  # GL
+            # Some parameters
+            "11 01 aa"
+            "12 01 bb"
+            "13 02 11 22"
+            "14 00"
+        )
+    )
+    assert isinstance(frame, AX25ExchangeIdentificationFrame)
+    assert frame.fi == 0x82
+    assert frame.gi == 0x80
+    assert len(frame.parameters) == 4
+
+    param = frame.parameters[0]
+    assert param.pi == 0x11
+    assert param.pv == b"\xaa"
+
+    param = frame.parameters[1]
+    assert param.pi == 0x12
+    assert param.pv == b"\xbb"
+
+    param = frame.parameters[2]
+    assert param.pi == 0x13
+    assert param.pv == b"\x11\x22"
+
+    param = frame.parameters[3]
+    assert param.pi == 0x14
+    assert param.pv is None
+
+
+def test_decode_xid_fig46():
+    """
+    Test that we can decode the XID example from AX.25 2.2 figure 4.6.
+    """
+    frame = AX25Frame.decode(
+        from_hex(
+            "9c 94 6e a0 40 40 e0"  # Destination
+            "9c 6e 98 8a 9a 40 61"  # Source
+            "af"  # Control
+            "82"  # FI
+            "80"  # GI
+            "00 17"  # GL
+            "02 02 00 20"
+            "03 03 86 a8 02"
+            "06 02 04 00"
+            "08 01 02"
+            "09 02 10 00"
+            "0a 01 03"
+        )
+    )
+    assert len(frame.parameters) == 6
+
+    param = frame.parameters[0]
+    assert param.pi == AX25XIDParameterIdentifier.ClassesOfProcedure
+    assert param.pv == b"\x00\x20"
+
+    param = frame.parameters[1]
+    assert param.pi == AX25XIDParameterIdentifier.HDLCOptionalFunctions
+    assert param.pv == b"\x86\xa8\x02"
+
+    param = frame.parameters[2]
+    assert param.pi == AX25XIDParameterIdentifier.IFieldLengthReceive
+    assert param.pv == b"\x04\x00"
+
+    param = frame.parameters[3]
+    assert param.pi == AX25XIDParameterIdentifier.WindowSizeReceive
+    assert param.pv == b"\x02"
+
+    param = frame.parameters[4]
+    assert param.pi == AX25XIDParameterIdentifier.AcknowledgeTimer
+    assert param.pv == b"\x10\x00"
+
+    param = frame.parameters[5]
+    assert param.pi == AX25XIDParameterIdentifier.Retries
+    assert param.pv == b"\x03"
+
+
+def test_decode_xid_truncated_header():
+    """
+    Test that decoding a XID with truncated header fails.
+    """
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 e0"  # Destination
+                "ac 96 68 9a a6 98 61"  # Source
+                "af"  # Control
+                "82"  # FI
+                "80"  # GI
+                "00"  # Incomplete GL
+            )
+        )
+        assert False, "This should not have worked"
+    except ValueError as e:
+        assert str(e) == "Truncated XID header"
+
+
+def test_decode_xid_truncated_payload():
+    """
+    Test that decoding a XID with truncated payload fails.
+    """
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 e0"  # Destination
+                "ac 96 68 9a a6 98 61"  # Source
+                "af"  # Control
+                "82"  # FI
+                "80"  # GI
+                "00 05"  # GL
+                "11"  # Incomplete payload
+            )
+        )
+        assert False, "This should not have worked"
+    except ValueError as e:
+        assert str(e) == "Truncated XID data"
+
+
+def test_decode_xid_truncated_param_header():
+    """
+    Test that decoding a XID with truncated parameter header fails.
+    """
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 e0"  # Destination
+                "ac 96 68 9a a6 98 61"  # Source
+                "af"  # Control
+                "82"  # FI
+                "80"  # GI
+                "00 01"  # GL
+                "11"  # Incomplete payload
+            )
+        )
+        assert False, "This should not have worked"
+    except ValueError as e:
+        assert str(e) == "Insufficient data for parameter"
+
+
+def test_decode_xid_truncated_param_value():
+    """
+    Test that decoding a XID with truncated parameter value fails.
+    """
+    try:
+        AX25Frame.decode(
+            from_hex(
+                "ac 96 68 84 ae 92 e0"  # Destination
+                "ac 96 68 9a a6 98 61"  # Source
+                "af"  # Control
+                "82"  # FI
+                "80"  # GI
+                "00 04"  # GL
+                "11 06 22 33"  # Incomplete payload
+            )
+        )
+        assert False, "This should not have worked"
+    except ValueError as e:
+        assert str(e) == "Parameter is truncated"
+
+
+def test_copy_xid():
+    """
+    Test that we can copy a XID frame.
+    """
+    frame = AX25ExchangeIdentificationFrame(
+        destination="VK4BWI",
+        source="VK4MSL",
+        cr=True,
+        fi=0x82,
+        gi=0x80,
+        parameters=[
+            AX25XIDRawParameter(pi=0x12, pv=bytes([0x34, 0x56])),
+            AX25XIDRawParameter(pi=0x34, pv=None),
+        ],
+    )
+    framecopy = frame.copy()
+    assert framecopy is not frame
+    hex_cmp(
+        bytes(framecopy),
+        "ac 96 68 84 ae 92 e0"  # Destination
+        "ac 96 68 9a a6 98 61"  # Source
+        "af"  # Control
+        "82"  # Format indicator
+        "80"  # Group Ident
+        "00 06"  # Group length
+        # First parameter
+        "12"
+        "02"
+        "34 56"  # Parameter ID  # Length  # Value
+        # Second parameter
+        "34" "00",  # Parameter ID  # Length (no value)
+    )
+
+
+def test_decode_cop_param():
+    """
+    Test we can decode a Class Of Procedures parameter.
+    """
+    param = AX25XIDClassOfProceduresParameter.decode(from_hex("80 20"))
+    assert param.half_duplex == True
+    assert param.full_duplex == False
+    assert param.unbalanced_nrm_pri == False
+    assert param.unbalanced_nrm_sec == False
+    assert param.unbalanced_arm_pri == False
+    assert param.unbalanced_arm_sec == False
+    assert param.reserved == 256
+
+
+def test_copy_cop_param():
+    """
+    Test we can copy a Class Of Procedures parameter.
+    """
+    param = AX25XIDClassOfProceduresParameter(
+        full_duplex=False,
+        half_duplex=True,
+        reserved=193,  # See that it is preserved
+    )
+    copyparam = param.copy()
+    assert param is not copyparam
+
+    # Ensure all parameters match
+    assert param.full_duplex == copyparam.full_duplex
+    assert param.half_duplex == copyparam.half_duplex
+    assert param.unbalanced_nrm_pri == copyparam.unbalanced_nrm_pri
+    assert param.unbalanced_nrm_sec == copyparam.unbalanced_nrm_sec
+    assert param.unbalanced_arm_pri == copyparam.unbalanced_arm_pri
+    assert param.unbalanced_arm_sec == copyparam.unbalanced_arm_sec
+    assert param.balanced_abm == copyparam.balanced_abm
+    assert param.reserved == copyparam.reserved
+
+
+def test_encode_cop_param():
+    """
+    Test we can encode a Class Of Procedures parameter.
+    """
+    param = AX25XIDClassOfProceduresParameter(
+        reserved=232,  # 15-7 = 232
+        full_duplex=True,  # 6 = 1
+        half_duplex=False,  # 5 = 0
+        unbalanced_nrm_pri=True,  # 1 = 1
+    )
+
+    # Expecting:
+    #   0111 0100 0100 0011
+
+    hex_cmp(param.pv, from_hex("74 43"))
+
+
+def test_decode_hdlcfunc_param():
+    """
+    Test we can decode a HDLC Optional Functions parameter.
+    """
+    param = AX25XIDHDLCOptionalFunctionsParameter.decode(from_hex("86 a8 82"))
+    # Specifically called out in the example (AX.25 2.2 spec Figure 4.6)
+    assert param.srej == True
+    assert param.rej == True
+    assert param.extd_addr == True
+    assert param.fcs16 == True
+    assert param.modulo128 == True
+    assert param.sync_tx == True
+    # Changed by us to test round-tripping
+    assert param.reserved2 == 2
+    # Modulo128 is on, so we expect this off
+    assert param.modulo8 == False
+    # Expected defaults
+    assert param.srej_multiframe == False
+    assert param.start_stop_transp == False
+    assert param.start_stop_flow_ctl == False
+    assert param.sync_tx == True
+    assert param.fcs32 == False
+    assert param.rd == False
+    assert param.test == True
+    assert param.rset == False
+    assert param.delete_i_cmd == False
+    assert param.delete_i_resp == False
+    assert param.basic_addr == False
+    assert param.up == False
+    assert param.sim_rim == False
+    assert param.ui == False
+    assert param.reserved1 == False
+
+
+def test_copy_hdlcfunc_param():
+    """
+    Test we can copy a HDLC Optional Functions parameter.
+    """
+    param = AX25XIDHDLCOptionalFunctionsParameter(
+        modulo128=False,
+        modulo8=True,
+        rej=True,
+        srej=False,
+        rset=True,
+        test=False,
+        fcs32=True,
+        reserved1=True,
+        reserved2=1,
+    )
+    copyparam = param.copy()
+    assert param is not copyparam
+
+    # Ensure all parameters match
+    assert param.modulo128 == copyparam.modulo128
+    assert param.modulo8 == copyparam.modulo8
+    assert param.srej == copyparam.srej
+    assert param.rej == copyparam.rej
+    assert param.srej_multiframe == copyparam.srej_multiframe
+    assert param.start_stop_transp == copyparam.start_stop_transp
+    assert param.start_stop_flow_ctl == copyparam.start_stop_flow_ctl
+    assert param.start_stop_tx == copyparam.start_stop_tx
+    assert param.sync_tx == copyparam.sync_tx
+    assert param.fcs32 == copyparam.fcs32
+    assert param.fcs16 == copyparam.fcs16
+    assert param.rd == copyparam.rd
+    assert param.test == copyparam.test
+    assert param.rset == copyparam.rset
+    assert param.delete_i_cmd == copyparam.delete_i_cmd
+    assert param.delete_i_resp == copyparam.delete_i_resp
+    assert param.extd_addr == copyparam.extd_addr
+    assert param.basic_addr == copyparam.basic_addr
+    assert param.up == copyparam.up
+    assert param.sim_rim == copyparam.sim_rim
+    assert param.ui == copyparam.ui
+    assert param.reserved2 == copyparam.reserved2
+    assert param.reserved1 == copyparam.reserved1
+
+
+def test_encode_hdlcfunc_param():
+    """
+    Test we can encode a HDLC Optional Functions parameter.
+    """
+    param = AX25XIDHDLCOptionalFunctionsParameter(
+        modulo128=True,
+        modulo8=False,
+        srej=True,
+        rej=False,
+        # Some atypical values
+        ui=True,
+        fcs32=True,
+        reserved2=2,
+    )
+
+    hex_cmp(param.pv, from_hex("8c a8 83"))
+
+
+@mark.parametrize("value", [("96", None)])
+def test_encode_badtype(value):
+    """
+    Test that AX25XIDBigEndianParameter refuses bad input types.
+    """
+    try:
+        AX25XIDRetriesParameter(value)
+        assert False, "Should not have been accepted"
+    except TypeError as e:
+        assert str(e) == "value must be an integer or boolean"
+
+
+def test_encode_retries_param():
+    """
+    Test we can encode a Retries parameter.
+    """
+    param = AX25XIDRetriesParameter(96)
+
+    hex_cmp(param.pv, from_hex("60"))
+
+
+def test_decode_retries_param():
+    """
+    Test we can decode a Retries parameter.
+    """
+    param = AX25XIDRetriesParameter.decode(from_hex("10"))
+    assert param.value == 16
+
+
+def test_copy_retries_param():
+    """
+    Test we can copy a Retries parameter.
+    """
+    param = AX25XIDRetriesParameter(38)
+    copyparam = param.copy()
+    assert param is not copyparam
+
+    # Ensure all parameters match
+    assert param.value == copyparam.value
diff --git a/tests/test_kiss/test_base.py b/tests/test_kiss/test_base.py
index d256e10..15ad7ff 100644
--- a/tests/test_kiss/test_base.py
+++ b/tests/test_kiss/test_base.py
@@ -142,6 +142,22 @@ def test_receive_frame_garbage_start():
     assert len(loop.calls) == 0
 
 
+def test_receive_frame_single_fend():
+    """
+    Test _receive_frame does nothing if there's only a FEND byte.
+    """
+    loop = DummyLoop()
+    kissdev = DummyKISSDevice(loop=loop, reset_on_close=True)
+    kissdev._rx_buffer += b"\xc0"
+    kissdev._receive_frame()
+
+    # We should just have the last FEND
+    assert bytes(kissdev._rx_buffer) == b"\xc0"
+
+    # It should leave the last FEND there and wait for more data.
+    assert len(loop.calls) == 0
+
+
 def test_receive_frame_empty():
     """
     Test _receive_frame discards empty frames.
diff --git a/tests/test_kiss/test_protocol.py b/tests/test_kiss/test_protocol.py
index 9ab2816..b6146cf 100644
--- a/tests/test_kiss/test_protocol.py
+++ b/tests/test_kiss/test_protocol.py
@@ -12,6 +12,9 @@ class DummyTransport(object):
     def __init__(self):
         self.closed = False
 
+    def __repr__(self):
+        return "<DummyTransport>"
+
     def close(self):
         assert self.closed is False, "Already closed"
         self.closed = True
@@ -48,9 +51,16 @@ def on_close(*args, **kwargs):
 
     assert on_connect_calls == [((transport,), {})]
     assert transport.closed is False
-    assert logger.logrecords == []
+    assert logger.logrecords[1:] == []
     assert logger.children == {}
 
+    log = logger.logrecords[0]
+    assert log["ex_tb"] is None
+    assert log["ex_type"] is None
+    assert log["ex_val"] is None
+    assert log["args"][0] == "Announcing connection: %r"
+    assert log["args"][1] is transport
+
 
 def test_protocol_connection_made_err(logger):
     """
@@ -80,9 +90,16 @@ def on_close(*args, **kwargs):
 
     assert logger.children == {}
     assert len(logger.logrecords) > 0
-    assert logger.logrecords[1:] == []
+    assert logger.logrecords[2:] == []
 
     log = logger.logrecords[0]
+    assert log["ex_tb"] is None
+    assert log["ex_type"] is None
+    assert log["ex_val"] is None
+    assert log["args"][0] == "Announcing connection: %r"
+    assert log["args"][1] is transport
+
+    log = logger.logrecords[1]
     assert log.pop("ex_type", None) == TestError
     log.pop("ex_val", None)
     log.pop("ex_tb", None)
@@ -279,9 +296,16 @@ def on_close(*args, **kwargs):
 
     assert on_connect_calls == [((transport,), {})]
     assert transport.closed is False
-    assert logger.logrecords == []
+    assert logger.logrecords[1:] == []
     assert logger.children == {}
 
+    log = logger.logrecords[0]
+    assert log["ex_tb"] is None
+    assert log["ex_type"] is None
+    assert log["ex_val"] is None
+    assert log["args"][0] == "Announcing connection: %r"
+    assert log["args"][1] is transport
+
 
 def test_subproc_protocol_connection_made_err(logger):
     """
@@ -313,9 +337,16 @@ def on_close(*args, **kwargs):
 
     assert logger.children == {}
     assert len(logger.logrecords) > 0
-    assert logger.logrecords[1:] == []
+    assert logger.logrecords[2:] == []
 
     log = logger.logrecords[0]
+    assert log["ex_tb"] is None
+    assert log["ex_type"] is None
+    assert log["ex_val"] is None
+    assert log["args"][0] == "Announcing connection: %r"
+    assert log["args"][1] is transport
+
+    log = logger.logrecords[1]
     assert log.pop("ex_type", None) == TestError
     log.pop("ex_val", None)
     log.pop("ex_tb", None)
diff --git a/tests/test_kiss/test_serial.py b/tests/test_kiss/test_serial.py
index 961e6c1..952c5ee 100644
--- a/tests/test_kiss/test_serial.py
+++ b/tests/test_kiss/test_serial.py
@@ -27,7 +27,6 @@ def __init__(
         dsrdtr,
         inter_byte_timeout,
     ):
-
         assert port == "/dev/ttyS0"
         assert baudrate == 9600
         assert bytesize == EIGHTBITS
diff --git a/tests/test_peer/__init__.py b/tests/test_peer/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_peer/peer.py b/tests/test_peer/peer.py
new file mode 100644
index 0000000..6ab0f68
--- /dev/null
+++ b/tests/test_peer/peer.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+
+"""
+Fixture for initialising an AX25 Peer
+"""
+
+from aioax25.peer import AX25Peer, AX25RejectMode
+from aioax25.version import AX25Version
+from ..mocks import DummyIOLoop, DummyLogger
+
+
+class TestingAX25Peer(AX25Peer):
+    def __init__(
+        self,
+        station,
+        address,
+        repeaters,
+        max_ifield=256,
+        max_ifield_rx=256,
+        max_retries=10,
+        max_outstanding_mod8=7,
+        max_outstanding_mod128=127,
+        rr_delay=10.0,
+        rr_interval=30.0,
+        rnr_interval=10.0,
+        ack_timeout=3.0,
+        idle_timeout=900.0,
+        protocol=AX25Version.UNKNOWN,
+        modulo128=False,
+        reject_mode=AX25RejectMode.SELECTIVE_RR,
+        full_duplex=False,
+        reply_path=None,
+        locked_path=False,
+        paclen=128,
+    ):
+        super(TestingAX25Peer, self).__init__(
+            station,
+            address,
+            repeaters,
+            max_ifield,
+            max_ifield_rx,
+            max_retries,
+            max_outstanding_mod8,
+            max_outstanding_mod128,
+            rr_delay,
+            rr_interval,
+            rnr_interval,
+            ack_timeout,
+            idle_timeout,
+            protocol,
+            modulo128,
+            DummyLogger("peer"),
+            DummyIOLoop(),
+            reject_mode,
+            full_duplex,
+            reply_path,
+            locked_path,
+            paclen,
+        )
diff --git a/tests/test_peer/test_cleanup.py b/tests/test_peer/test_cleanup.py
new file mode 100644
index 0000000..86998a9
--- /dev/null
+++ b/tests/test_peer/test_cleanup.py
@@ -0,0 +1,251 @@
+#!/usr/bin/env python3
+
+"""
+Test handling of clean-up logic
+"""
+
+from aioax25.frame import AX25Address, AX25Path
+from aioax25.peer import AX25PeerState
+from .peer import TestingAX25Peer
+from ..mocks import DummyStation, DummyTimeout
+
+# Idle time-out cancellation
+
+
+def test_cancel_idle_timeout_inactive():
+    """
+    Test that calling _cancel_idle_timeout with no time-out is a no-op.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Constructor resets the timer, so discard that time-out handle
+    # This is safe because TestingAX25Peer does not use a real IOLoop
+    peer._idle_timeout_handle = None
+
+    peer._cancel_idle_timeout()
+
+
+def test_cancel_idle_timeout_active():
+    """
+    Test that calling _cancel_idle_timeout active time-out cancels it.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    timeout = DummyTimeout(0, lambda: None)
+    peer._idle_timeout_handle = timeout
+
+    peer._cancel_idle_timeout()
+
+    assert peer._idle_timeout_handle is None
+    assert timeout.cancelled is True
+
+
+# Idle time-out reset
+
+
+def test_reset_idle_timeout():
+    """
+    Test that calling _reset_idle_timeout re-creates a time-out object
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Grab the original time-out created by the constructor
+    orig_timeout = peer._idle_timeout_handle
+    assert orig_timeout is not None
+
+    # Reset the time-out
+    peer._reset_idle_timeout()
+
+    assert peer._idle_timeout_handle is not orig_timeout
+    assert orig_timeout.cancelled is True
+
+    # New time-out should call the appropriate routine at the right time
+    assert peer._idle_timeout_handle.delay == peer._idle_timeout
+    assert peer._idle_timeout_handle.callback == peer._cleanup
+
+
+# Clean-up steps
+
+
+def test_cleanup_disconnected():
+    """
+    Test that clean-up whilst disconnect just cancels RR notifications
+    """
+    # Most of the time, there will be no pending RR notifications, so
+    # _cancel_rr_notification will be a no-op in this case.
+
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub methods
+
+    actions = []
+
+    def _cancel_rr_notification():
+        actions.append("cancel-rr")
+
+    peer._cancel_rr_notification = _cancel_rr_notification
+
+    def disconnect():
+        assert False, "Should not call disconnect"
+
+    peer.disconnect = disconnect
+
+    def _send_dm():
+        assert False, "Should not send DM"
+
+    peer._send_dm = _send_dm
+
+    # Set state
+    peer._state = AX25PeerState.DISCONNECTED
+
+    # Do clean-up
+    peer._cleanup()
+
+    assert actions == ["cancel-rr"]
+
+
+def test_cleanup_disconnecting():
+    """
+    Test that clean-up whilst disconnecting cancels RR notification
+    """
+    # Most of the time, there will be no pending RR notifications, so
+    # _cancel_rr_notification will be a no-op in this case.
+
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub methods
+
+    actions = []
+
+    def _cancel_rr_notification():
+        actions.append("cancel-rr")
+
+    peer._cancel_rr_notification = _cancel_rr_notification
+
+    def disconnect():
+        assert False, "Should not call disconnect"
+
+    peer.disconnect = disconnect
+
+    def _send_dm():
+        assert False, "Should not send DM"
+
+    peer._send_dm = _send_dm
+
+    # Set state
+    peer._state = AX25PeerState.DISCONNECTING
+
+    # Do clean-up
+    peer._cleanup()
+
+    assert actions == ["cancel-rr"]
+
+
+def test_cleanup_connecting():
+    """
+    Test that clean-up whilst connecting sends DM then cancels RR notifications
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub methods
+
+    actions = []
+
+    def _cancel_rr_notification():
+        actions.append("cancel-rr")
+
+    peer._cancel_rr_notification = _cancel_rr_notification
+
+    def disconnect():
+        assert False, "Should not call disconnect"
+
+    peer.disconnect = disconnect
+
+    def _send_dm():
+        actions.append("sent-dm")
+
+    peer._send_dm = _send_dm
+
+    # Set state
+    peer._state = AX25PeerState.CONNECTING
+
+    # Do clean-up
+    peer._cleanup()
+
+    assert actions == ["sent-dm", "cancel-rr"]
+
+
+def test_cleanup_connected():
+    """
+    Test that clean-up whilst connected sends DISC then cancels RR notifications
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub methods
+
+    actions = []
+
+    def _cancel_rr_notification():
+        actions.append("cancel-rr")
+
+    peer._cancel_rr_notification = _cancel_rr_notification
+
+    def disconnect():
+        actions.append("disconnect")
+
+    peer.disconnect = disconnect
+
+    def _send_dm():
+        assert False, "Should not send DM"
+
+    peer._send_dm = _send_dm
+
+    # Set state
+    peer._state = AX25PeerState.CONNECTED
+
+    # Do clean-up
+    peer._cleanup()
+
+    assert actions == ["disconnect", "cancel-rr"]
diff --git a/tests/test_peer/test_connection.py b/tests/test_peer/test_connection.py
new file mode 100644
index 0000000..cde9769
--- /dev/null
+++ b/tests/test_peer/test_connection.py
@@ -0,0 +1,3464 @@
+#!/usr/bin/env python3
+
+"""
+Test handling of incoming and outgoing connection logic
+"""
+
+from aioax25.version import AX25Version
+from aioax25.frame import (
+    AX25Address,
+    AX25Path,
+    AX25DisconnectFrame,
+    AX25DisconnectModeFrame,
+    AX25FrameRejectFrame,
+    AX25UnnumberedAcknowledgeFrame,
+    AX25UnnumberedInformationFrame,
+    AX25RawFrame,
+    AX25TestFrame,
+    AX25SetAsyncBalancedModeFrame,
+    AX25SetAsyncBalancedModeExtendedFrame,
+    AX258BitInformationFrame,
+    AX258BitReceiveReadyFrame,
+    AX258BitReceiveNotReadyFrame,
+    AX258BitRejectFrame,
+    AX258BitSelectiveRejectFrame,
+    AX2516BitInformationFrame,
+    AX2516BitReceiveReadyFrame,
+    AX2516BitReceiveNotReadyFrame,
+    AX2516BitRejectFrame,
+    AX2516BitSelectiveRejectFrame,
+)
+from aioax25.peer import AX25PeerState
+from .peer import TestingAX25Peer
+from ..mocks import DummyStation, DummyTimeout
+from functools import partial
+
+from pytest import mark
+
+# Connection establishment
+
+
+def test_connect_not_disconnected():
+    """
+    Test that calling peer.connect() when not disconnected does nothing.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub negotiation, this should not get called
+    def _negotiate(*args, **kwargs):
+        assert False, "Should not have been called"
+
+    peer._negotiate = _negotiate
+
+    # Ensure _negotiate() gets called if we try to connect
+    peer._negotiated = False
+
+    # Override the state to ensure connection attempt never happens
+    peer._state = AX25PeerState.CONNECTED
+
+    # Now try connecting
+    peer.connect()
+
+
+def test_connect_when_disconnected():
+    """
+    Test that calling peer.connect() when disconnected initiates connection
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub negotiation, we'll just throw an error to see if it gets called
+    class ConnectionStarted(Exception):
+        pass
+
+    def _negotiate(*args, **kwargs):
+        raise ConnectionStarted()
+
+    peer._negotiate = _negotiate
+
+    # Ensure _negotiate() gets called if we try to connect
+    peer._negotiated = False
+
+    # Ensure disconnected state
+    peer._state = AX25PeerState.DISCONNECTED
+
+    # Now try connecting
+    try:
+        peer.connect()
+        assert False, "Did not call _negotiate"
+    except ConnectionStarted:
+        pass
+
+
+def test_on_incoming_connect_timeout_incoming():
+    """
+    Test if the application does not accept within the time-out, we reject the
+    connection.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+    )
+
+    count = dict(reject=0)
+
+    def _reject():
+        count["reject"] += 1
+
+    peer.reject = _reject
+
+    peer._state = AX25PeerState.INCOMING_CONNECTION
+    peer._ack_timeout_handle = DummyTimeout(None, None)
+
+    peer._on_incoming_connect_timeout()
+
+    assert peer._ack_timeout_handle is None
+    assert count == dict(reject=1)
+
+
+def test_on_incoming_connect_timeout_otherstate():
+    """
+    Test if the incoming connection time-out fires whilst in another state, it
+    is ignored
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+    )
+
+    count = dict(reject=0)
+
+    def _reject():
+        count["reject"] += 1
+
+    peer.reject = _reject
+
+    peer._state = AX25PeerState.CONNECTED
+    peer._ack_timeout_handle = DummyTimeout(None, None)
+
+    peer._on_incoming_connect_timeout()
+
+    assert peer._ack_timeout_handle is not None
+    assert count == dict(reject=0)
+
+
+def test_on_connect_response_ack():
+    """
+    Test if _on_connect_response receives an ACK, we enter the connected
+    state.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+    )
+
+    peer._state = AX25PeerState.CONNECTING
+
+    peer._on_connect_response(response="ack")
+
+    assert peer._state == AX25PeerState.CONNECTED
+
+
+def test_on_connect_response_other():
+    """
+    Test if _on_connect_response receives another response, we enter the
+    disconnected state.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+    )
+
+    peer._state = AX25PeerState.CONNECTING
+
+    peer._on_connect_response(response="nope")
+
+    assert peer._state == AX25PeerState.DISCONNECTED
+
+
+# SABM(E) transmission
+
+
+def test_send_sabm():
+    """
+    Test we can send a SABM (modulo-8)
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub _transmit_frame
+    sent = []
+
+    def _transmit_frame(frame):
+        sent.append(frame)
+
+    peer._transmit_frame = _transmit_frame
+
+    peer._send_sabm()
+
+    try:
+        frame = sent.pop(0)
+    except IndexError:
+        assert False, "No frames were sent"
+
+    assert isinstance(frame, AX25SetAsyncBalancedModeFrame)
+    assert str(frame.header.destination) == "VK4MSL*"  # CONTROL set
+    assert str(frame.header.source) == "VK4MSL-1"  # CONTROL clear
+    assert str(frame.header.repeaters) == "VK4RZB"
+    assert len(sent) == 0
+
+    assert peer._state == AX25PeerState.CONNECTING
+
+
+def test_send_sabme():
+    """
+    Test we can send a SABM (modulo-128)
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+    peer._modulo128 = True
+
+    # Stub _transmit_frame
+    sent = []
+
+    def _transmit_frame(frame):
+        sent.append(frame)
+
+    peer._transmit_frame = _transmit_frame
+
+    peer._send_sabm()
+
+    try:
+        frame = sent.pop(0)
+    except IndexError:
+        assert False, "No frames were sent"
+
+    assert isinstance(frame, AX25SetAsyncBalancedModeExtendedFrame)
+    assert str(frame.header.destination) == "VK4MSL*"  # CONTROL set
+    assert str(frame.header.source) == "VK4MSL-1"  # CONTROL clear
+    assert str(frame.header.repeaters) == "VK4RZB"
+    assert len(sent) == 0
+
+    assert peer._state == AX25PeerState.CONNECTING
+
+
+# SABM response handling
+
+
+def test_recv_ignore_frmr():
+    """
+    Test that we ignore FRMR from peer when connecting.
+
+    (AX.25 2.2 sect 6.3.1)
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub FRMR handling
+    def _on_receive_frmr():
+        assert False, "Should not have been called"
+
+    peer._on_receive_frmr = _on_receive_frmr
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTING
+
+    # Inject a frame
+    peer._on_receive(
+        AX25FrameRejectFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            w=False,
+            x=False,
+            y=False,
+            z=False,
+            frmr_cr=False,
+            vs=0,
+            vr=0,
+            frmr_control=0,
+        )
+    )
+
+
+def test_recv_ignore_test():
+    """
+    Test that we ignore TEST from peer when connecting.
+
+    (AX.25 2.2 sect 6.3.1)
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub TEST handling
+    def _on_receive_test():
+        assert False, "Should not have been called"
+
+    peer._on_receive_test = _on_receive_test
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTING
+
+    # Inject a frame
+    peer._on_receive(
+        AX25TestFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"Frame to be ignored",
+        )
+    )
+
+
+def test_recv_ua():
+    """
+    Test that UA is handled.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Create a handler for receiving the UA
+    count = dict(ua=0)
+
+    def _on_receive_ua():
+        count["ua"] += 1
+
+    peer._uaframe_handler = _on_receive_ua
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTING
+
+    # Inject a frame
+    peer._on_receive(
+        AX25UnnumberedAcknowledgeFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+        )
+    )
+
+    # Our handler should have been called
+    assert count == dict(ua=1)
+
+
+def test_recv_ui():
+    """
+    Test that UI is emitted by the received frame signal.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Create a handler for receiving the UI
+    rx_frames = []
+
+    def _on_receive_frame(frame, **kwargs):
+        assert "peer" in kwargs
+        assert kwargs.pop("peer") is peer
+        assert kwargs == {}
+        rx_frames.append(frame)
+
+    peer.received_frame.connect(_on_receive_frame)
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+
+    # Inject a frame
+    frame = AX25UnnumberedInformationFrame(
+        destination=AX25Address("VK4MSL-1"),
+        source=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        pid=0xF0,
+        payload=b"Testing 1 2 3 4",
+    )
+
+    peer._on_receive(frame)
+
+    # Our handler should have been called
+    assert len(rx_frames) == 1
+    assert rx_frames[0] is frame
+
+
+def test_recv_raw_noconn():
+    """
+    Test that a raw frame without a connection triggers a DM frame.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub _send_dm
+    count = dict(send_dm=0)
+
+    def _send_dm():
+        count["send_dm"] += 1
+
+    peer._send_dm = _send_dm
+
+    # Set the state
+    peer._state = AX25PeerState.DISCONNECTED
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x00\x00Testing 1 2 3 4",
+        )
+    )
+
+
+def test_recv_raw_mod8_iframe():
+    """
+    Test that a I-frame with Mod8 connection is handled.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub _on_receive_iframe
+    iframes = []
+
+    def _on_receive_iframe(frame):
+        iframes.append(frame)
+
+    peer._on_receive_iframe = _on_receive_iframe
+
+    # Stub _on_receive_sframe
+    sframes = []
+
+    def _on_receive_sframe(frame):
+        sframes.append(frame)
+
+    peer._on_receive_sframe = _on_receive_sframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 8
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\xd4\xf0Testing 1 2 3 4",
+        )
+    )
+
+    # Our I-frame handler should have been called
+    assert len(iframes) == 1
+    assert isinstance(iframes[0], AX258BitInformationFrame)
+    assert iframes[0].pid == 0xF0
+    assert iframes[0].payload == b"Testing 1 2 3 4"
+
+    # Our S-frame handler should NOT have been called
+    assert sframes == []
+
+
+def test_recv_raw_mod128_iframe():
+    """
+    Test that a I-frame with Mod128 connection is handled.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub _on_receive_iframe
+    iframes = []
+
+    def _on_receive_iframe(frame):
+        iframes.append(frame)
+
+    peer._on_receive_iframe = _on_receive_iframe
+
+    # Stub _on_receive_sframe
+    sframes = []
+
+    def _on_receive_sframe(frame):
+        sframes.append(frame)
+
+    peer._on_receive_sframe = _on_receive_sframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 128
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x04\x0d\xf0Testing 1 2 3 4",
+        )
+    )
+
+    # Our I-frame handler should have been called
+    assert len(iframes) == 1
+    assert isinstance(iframes[0], AX2516BitInformationFrame)
+    assert iframes[0].pid == 0xF0
+    assert iframes[0].payload == b"Testing 1 2 3 4"
+
+    # Our S-frame handler should NOT have been called
+    assert sframes == []
+
+
+def test_recv_raw_mod8_sframe():
+    """
+    Test that a S-frame with Mod8 connection is handled.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub _on_receive_iframe
+    iframes = []
+
+    def _on_receive_iframe(frame):
+        iframes.append(frame)
+
+    peer._on_receive_iframe = _on_receive_iframe
+
+    # Stub _on_receive_sframe
+    sframes = []
+
+    def _on_receive_sframe(frame):
+        sframes.append(frame)
+
+    peer._on_receive_sframe = _on_receive_sframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 8
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x41",
+        )
+    )
+
+    # Our S-frame handler should have been called
+    assert len(sframes) == 1
+    assert isinstance(sframes[0], AX258BitReceiveReadyFrame)
+
+    # Our I-frame handler should NOT have been called
+    assert iframes == []
+
+
+def test_recv_raw_mod128_sframe():
+    """
+    Test that a S-frame with Mod128 connection is handled.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub _on_receive_iframe
+    iframes = []
+
+    def _on_receive_iframe(frame):
+        iframes.append(frame)
+
+    peer._on_receive_iframe = _on_receive_iframe
+
+    # Stub _on_receive_sframe
+    sframes = []
+
+    def _on_receive_sframe(frame):
+        sframes.append(frame)
+
+    peer._on_receive_sframe = _on_receive_sframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 128
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x01\x5c",
+        )
+    )
+
+    # Our S-frame handler should have been called
+    assert len(sframes) == 1
+    assert isinstance(sframes[0], AX2516BitReceiveReadyFrame)
+
+    # Our I-frame handler should NOT have been called
+    assert iframes == []
+
+
+def test_recv_iframe_busy():
+    """
+    Test that an I-frame received while we're busy triggers RNR.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub _send_rnr_notification and _cancel_rr_notification
+    count = dict(send_rnr=0, cancel_rr=0)
+
+    def _cancel_rr_notification():
+        count["cancel_rr"] += 1
+
+    peer._cancel_rr_notification = _cancel_rr_notification
+
+    def _send_rnr_notification():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr_notification
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 8
+    peer._local_busy = True
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\xd4\xf0Testing 1 2 3 4",
+        )
+    )
+
+    # RR notification should be cancelled and there should be a RNR queued
+    assert count == dict(cancel_rr=1, send_rnr=1)
+
+
+def test_recv_iframe_mismatched_seq():
+    """
+    Test that an I-frame with a mismatched sequence number is dropped.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub the functions called
+    count = dict(send_rnr=0, cancel_rr=0, send_next_iframe=0, schedule_rr=0)
+    isframes = []
+    iframes = []
+    state_updates = []
+
+    def _cancel_rr_notification():
+        count["cancel_rr"] += 1
+
+    peer._cancel_rr_notification = _cancel_rr_notification
+
+    def _schedule_rr_notification():
+        count["schedule_rr"] += 1
+
+    peer._schedule_rr_notification = _schedule_rr_notification
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    def _send_rnr_notification():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr_notification
+
+    def _on_receive_isframe_nr_ns(frame):
+        isframes.append(frame)
+
+    peer._on_receive_isframe_nr_ns = _on_receive_isframe_nr_ns
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+
+    peer._update_state = _update_state
+
+    # Hook received_information signal
+
+    def _received_information(frame, payload, **kwargs):
+        assert kwargs == {}
+        assert payload == frame.payload
+        iframes.append(frame)
+
+    peer.received_information.connect(_received_information)
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 8
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\xd4\xf0Testing 1 2 3 4",
+        )
+    )
+
+    # RR notification should be cancelled, no other actions pending
+    assert count == dict(
+        cancel_rr=1, send_rnr=0, schedule_rr=0, send_next_iframe=0
+    )
+
+    assert isframes == []
+    assert iframes == []
+    assert state_updates == []
+
+
+def test_recv_iframe_mismatched_seq():
+    """
+    Test that an I-frame with a mismatched sequence number is dropped.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub the functions called
+    count = dict(send_rnr=0, cancel_rr=0, send_next_iframe=0, schedule_rr=0)
+    isframes = []
+    iframes = []
+    state_updates = []
+
+    def _cancel_rr_notification():
+        count["cancel_rr"] += 1
+
+    peer._cancel_rr_notification = _cancel_rr_notification
+
+    def _schedule_rr_notification():
+        count["schedule_rr"] += 1
+
+    peer._schedule_rr_notification = _schedule_rr_notification
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    def _send_rnr_notification():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr_notification
+
+    def _on_receive_isframe_nr_ns(frame):
+        isframes.append(frame)
+
+    peer._on_receive_isframe_nr_ns = _on_receive_isframe_nr_ns
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+
+    peer._update_state = _update_state
+
+    # Hook received_information signal
+
+    def _received_information(frame, payload, **kwargs):
+        assert kwargs == {}
+        assert payload == frame.payload
+        iframes.append(frame)
+
+    peer.received_information.connect(_received_information)
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 8
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\xd4\xf0Testing 1 2 3 4",
+        )
+    )
+
+    # RR notification should be cancelled, no other actions pending
+    assert count == dict(
+        cancel_rr=1, send_rnr=0, schedule_rr=0, send_next_iframe=0
+    )
+
+    assert isframes == []
+    assert iframes == []
+    assert state_updates == []
+
+
+def test_recv_iframe_matched_seq_nopending():
+    """
+    Test that an I-frame with a matched sequence number is handled.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub the functions called
+    count = dict(send_rnr=0, cancel_rr=0, send_next_iframe=0, schedule_rr=0)
+    isframes = []
+    iframes = []
+    state_updates = []
+
+    def _cancel_rr_notification():
+        count["cancel_rr"] += 1
+
+    peer._cancel_rr_notification = _cancel_rr_notification
+
+    def _schedule_rr_notification():
+        count["schedule_rr"] += 1
+
+    peer._schedule_rr_notification = _schedule_rr_notification
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    def _send_rnr_notification():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr_notification
+
+    def _on_receive_isframe_nr_ns(frame):
+        isframes.append(frame)
+
+    peer._on_receive_isframe_nr_ns = _on_receive_isframe_nr_ns
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+
+    peer._update_state = _update_state
+
+    # Hook received_information signal
+
+    def _received_information(frame, payload, **kwargs):
+        assert kwargs == {}
+        assert payload == frame.payload
+        iframes.append(frame)
+
+    peer.received_information.connect(_received_information)
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 8
+    peer._recv_seq = 2
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\xd4\xf0Testing 1 2 3 4",
+        )
+    )
+
+    # RR notification should be re-scheduled, no I-frame transmissions
+    assert count == dict(
+        cancel_rr=1, send_rnr=0, schedule_rr=1, send_next_iframe=0
+    )
+
+    assert len(isframes) == 1
+
+    frame = isframes.pop(0)
+    assert frame.pid == 0xF0
+    assert frame.payload == b"Testing 1 2 3 4"
+
+    assert iframes == [frame]
+    assert state_updates == [
+        {"comment": "from I-frame N(S)", "prop": "_recv_state", "value": 3}
+    ]
+
+
+def test_recv_iframe_matched_seq_lotspending():
+    """
+    Test that an I-frame with lots of pending I-frames sends RR instead.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub the functions called
+    count = dict(send_rnr=0, cancel_rr=0, send_next_iframe=0, schedule_rr=0)
+    isframes = []
+    iframes = []
+    state_updates = []
+
+    def _cancel_rr_notification():
+        count["cancel_rr"] += 1
+
+    peer._cancel_rr_notification = _cancel_rr_notification
+
+    def _schedule_rr_notification():
+        count["schedule_rr"] += 1
+
+    peer._schedule_rr_notification = _schedule_rr_notification
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    def _send_rnr_notification():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr_notification
+
+    def _on_receive_isframe_nr_ns(frame):
+        isframes.append(frame)
+
+    peer._on_receive_isframe_nr_ns = _on_receive_isframe_nr_ns
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+
+    peer._update_state = _update_state
+
+    # Hook received_information signal
+
+    def _received_information(frame, payload, **kwargs):
+        assert kwargs == {}
+        assert payload == frame.payload
+        iframes.append(frame)
+
+    peer.received_information.connect(_received_information)
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 8
+    peer._max_outstanding = 8
+    peer._recv_seq = 2
+    peer._pending_data = [(0xF0, b"Test outgoing")]
+    peer._pending_iframes = {
+        0: (0xF0, b"Test outgoing 1"),
+        1: (0xF0, b"Test outgoing 2"),
+        2: (0xF0, b"Test outgoing 3"),
+        3: (0xF0, b"Test outgoing 4"),
+        4: (0xF0, b"Test outgoing 5"),
+        5: (0xF0, b"Test outgoing 6"),
+        6: (0xF0, b"Test outgoing 7"),
+        7: (0xF0, b"Test outgoing 8"),
+    }
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\xd4\xf0Testing 1 2 3 4",
+        )
+    )
+
+    # RR notification should be re-scheduled, no I-frame transmissions
+    assert count == dict(
+        cancel_rr=1, send_rnr=0, schedule_rr=1, send_next_iframe=0
+    )
+
+    assert len(isframes) == 1
+
+    frame = isframes.pop(0)
+    assert frame.pid == 0xF0
+    assert frame.payload == b"Testing 1 2 3 4"
+
+    assert iframes == [frame]
+    assert state_updates == [
+        {"comment": "from I-frame N(S)", "prop": "_recv_state", "value": 3}
+    ]
+
+
+def test_recv_iframe_matched_seq_iframepending():
+    """
+    Test that an I-frame reception triggers I-frame transmission if data is
+    pending.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub the functions called
+    count = dict(send_rnr=0, cancel_rr=0, send_next_iframe=0, schedule_rr=0)
+    isframes = []
+    iframes = []
+    state_updates = []
+
+    def _cancel_rr_notification():
+        count["cancel_rr"] += 1
+
+    peer._cancel_rr_notification = _cancel_rr_notification
+
+    def _schedule_rr_notification():
+        count["schedule_rr"] += 1
+
+    peer._schedule_rr_notification = _schedule_rr_notification
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    def _send_rnr_notification():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr_notification
+
+    def _on_receive_isframe_nr_ns(frame):
+        isframes.append(frame)
+
+    peer._on_receive_isframe_nr_ns = _on_receive_isframe_nr_ns
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+
+    peer._update_state = _update_state
+
+    # Hook received_information signal
+
+    def _received_information(frame, payload, **kwargs):
+        assert kwargs == {}
+        assert payload == frame.payload
+        iframes.append(frame)
+
+    peer.received_information.connect(_received_information)
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 8
+    peer._max_outstanding = 8
+    peer._recv_seq = 2
+    peer._pending_data = [(0xF0, b"Test outgoing")]
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\xd4\xf0Testing 1 2 3 4",
+        )
+    )
+
+    # RR notification should be cancelled, no I-frame transmissions
+    assert count == dict(
+        cancel_rr=1, send_rnr=0, schedule_rr=0, send_next_iframe=1
+    )
+
+    assert len(isframes) == 1
+
+    frame = isframes.pop(0)
+    assert frame.pid == 0xF0
+    assert frame.payload == b"Testing 1 2 3 4"
+
+    assert iframes == [frame]
+    assert state_updates == [
+        {"comment": "from I-frame N(S)", "prop": "_recv_state", "value": 3}
+    ]
+
+
+def test_recv_sframe_rr_req_busy():
+    """
+    Test that RR with P/F set while busy sends RNR
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the functions called
+    count = dict(send_rr=0, send_rnr=0, send_next_iframe=0)
+
+    def _send_rr():
+        count["send_rr"] += 1
+
+    peer._send_rr_notification = _send_rr
+
+    def _send_rnr():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._init_connection(False)
+    peer._local_busy = True
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x51",
+        )
+    )
+
+    # We should send a RNR in reply
+    assert count == dict(send_rr=0, send_rnr=1, send_next_iframe=0)
+
+
+def test_recv_sframe_rr_req_notbusy():
+    """
+    Test that RR with P/F set while not busy sends RR
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the functions called
+    count = dict(send_rr=0, send_rnr=0, send_next_iframe=0)
+
+    def _send_rr():
+        count["send_rr"] += 1
+
+    peer._send_rr_notification = _send_rr
+
+    def _send_rnr():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._init_connection(False)
+    peer._local_busy = False
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x51",
+        )
+    )
+
+    # We should send a RR in reply
+    assert count == dict(send_rr=1, send_rnr=0, send_next_iframe=0)
+
+
+def test_recv_sframe_rr_rep():
+    """
+    Test that RR with P/F clear marks peer not busy
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the functions called
+    count = dict(send_rr=0, send_rnr=0, send_next_iframe=0)
+
+    def _send_rr():
+        count["send_rr"] += 1
+
+    peer._send_rr_notification = _send_rr
+
+    def _send_rnr():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._init_connection(False)
+    peer._peer_busy = True
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x41",
+        )
+    )
+
+    # Busy flag should be cleared
+    assert peer._peer_busy is False
+
+    # We should send the next I-frame in reply
+    assert count == dict(send_rr=0, send_rnr=0, send_next_iframe=1)
+
+
+def test_recv_sframe_rnr_req_busy():
+    """
+    Test that RNR with P/F set while busy sends RNR
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the functions called
+    count = dict(send_rr=0, send_rnr=0, send_next_iframe=0)
+
+    def _send_rr():
+        count["send_rr"] += 1
+
+    peer._send_rr_notification = _send_rr
+
+    def _send_rnr():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._init_connection(False)
+    peer._local_busy = True
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x55",
+        )
+    )
+
+    # We should send a RNR in reply
+    assert count == dict(send_rr=0, send_rnr=1, send_next_iframe=0)
+
+
+def test_recv_sframe_rnr_req_notbusy():
+    """
+    Test that RNR with P/F set while not busy sends RR
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the functions called
+    count = dict(send_rr=0, send_rnr=0, send_next_iframe=0)
+
+    def _send_rr():
+        count["send_rr"] += 1
+
+    peer._send_rr_notification = _send_rr
+
+    def _send_rnr():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._init_connection(False)
+    peer._local_busy = False
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x55",
+        )
+    )
+
+    # We should send a RR in reply
+    assert count == dict(send_rr=1, send_rnr=0, send_next_iframe=0)
+
+
+def test_recv_sframe_rnr_rep():
+    """
+    Test that RNR with P/F clear marks peer busy
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the functions called
+    count = dict(send_rr=0, send_rnr=0, send_next_iframe=0)
+
+    def _send_rr():
+        count["send_rr"] += 1
+
+    peer._send_rr_notification = _send_rr
+
+    def _send_rnr():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._init_connection(False)
+    peer._peer_busy = False
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x45",
+        )
+    )
+
+    # Busy flag should be set
+    assert peer._peer_busy is True
+
+
+def test_recv_sframe_rej_req_busy():
+    """
+    Test that REJ with P/F set while busy sends RNR
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the functions called
+    count = dict(send_rr=0, send_rnr=0, send_next_iframe=0)
+    state_updates = []
+
+    def _send_rr():
+        count["send_rr"] += 1
+
+    peer._send_rr_notification = _send_rr
+
+    def _send_rnr():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+        setattr(
+            peer,
+            prop,
+            kwargs.get("value", getattr(peer, prop)) + kwargs.get("delta", 0),
+        )
+
+    peer._update_state = _update_state
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._init_connection(False)
+    peer._local_busy = True
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x59",
+        )
+    )
+
+    # We should update due to resets and peer ACKs
+    assert state_updates == [
+        {"comment": "reset", "prop": "_send_state", "value": 0},
+        {"comment": "reset", "prop": "_send_seq", "value": 0},
+        {"comment": "reset", "prop": "_recv_state", "value": 0},
+        {"comment": "reset", "prop": "_recv_seq", "value": 0},
+        {"comment": "reset", "prop": "_ack_state", "value": 0},
+        {"comment": "ACKed by peer N(R)", "delta": 1, "prop": "_ack_state"},
+    ]
+
+    # We should send a RNR in reply
+    assert count == dict(send_rr=0, send_rnr=1, send_next_iframe=0)
+
+
+def test_recv_sframe_rej_req_notbusy():
+    """
+    Test that REJ with P/F set while not busy sends RR
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the functions called
+    count = dict(send_rr=0, send_rnr=0, send_next_iframe=0)
+    state_updates = []
+
+    def _send_rr():
+        count["send_rr"] += 1
+
+    peer._send_rr_notification = _send_rr
+
+    def _send_rnr():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+        setattr(
+            peer,
+            prop,
+            kwargs.get("value", getattr(peer, prop)) + kwargs.get("delta", 0),
+        )
+
+    peer._update_state = _update_state
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._init_connection(False)
+    peer._local_busy = False
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x59",
+        )
+    )
+
+    # State updates should be a reset and peer ACK
+    assert state_updates == [
+        {"comment": "reset", "prop": "_send_state", "value": 0},
+        {"comment": "reset", "prop": "_send_seq", "value": 0},
+        {"comment": "reset", "prop": "_recv_state", "value": 0},
+        {"comment": "reset", "prop": "_recv_seq", "value": 0},
+        {"comment": "reset", "prop": "_ack_state", "value": 0},
+        {"comment": "ACKed by peer N(R)", "delta": 1, "prop": "_ack_state"},
+    ]
+
+    # We should send a RR in reply
+    assert count == dict(send_rr=1, send_rnr=0, send_next_iframe=0)
+
+
+def test_recv_sframe_rej_rep():
+    """
+    Test that REJ with P/F clear marks peer busy
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the functions called
+    count = dict(send_rr=0, send_rnr=0, send_next_iframe=0)
+    state_updates = []
+
+    def _send_rr():
+        count["send_rr"] += 1
+
+    peer._send_rr_notification = _send_rr
+
+    def _send_rnr():
+        count["send_rnr"] += 1
+
+    peer._send_rnr_notification = _send_rnr
+
+    def _send_next_iframe():
+        count["send_next_iframe"] += 1
+
+    peer._send_next_iframe = _send_next_iframe
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+        setattr(
+            peer,
+            prop,
+            kwargs.get("value", getattr(peer, prop)) + kwargs.get("delta", 0),
+        )
+
+    peer._update_state = _update_state
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._init_connection(False)
+    peer._peer_busy = False
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x49",
+        )
+    )
+
+    assert state_updates == [
+        # Reset state
+        {"comment": "reset", "prop": "_send_state", "value": 0},
+        {"comment": "reset", "prop": "_send_seq", "value": 0},
+        {"comment": "reset", "prop": "_recv_state", "value": 0},
+        {"comment": "reset", "prop": "_recv_seq", "value": 0},
+        {"comment": "reset", "prop": "_ack_state", "value": 0},
+        # Peer ACK
+        {"comment": "ACKed by peer N(R)", "delta": 1, "prop": "_ack_state"},
+        # REJ handling
+        {"comment": "from REJ N(R)", "prop": "_send_state", "value": 2},
+    ]
+
+    # We should send an I-frame in reply
+    assert count == dict(send_rr=0, send_rnr=0, send_next_iframe=1)
+
+
+def test_recv_sframe_srej_pf():
+    """
+    Test that REJ with P/F set retransmits specified frame
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the functions called
+    iframes_rqd = []
+
+    def _transmit_iframe(nr):
+        iframes_rqd.append(nr)
+
+    peer._transmit_iframe = _transmit_iframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._init_connection(True)
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x0d\x55",
+        )
+    )
+
+    assert iframes_rqd == [42]
+
+
+def test_recv_sframe_srej_nopf():
+    """
+    Test that REJ with P/F clear retransmits specified frame
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the functions called
+    iframes_rqd = []
+
+    def _transmit_iframe(nr):
+        iframes_rqd.append(nr)
+
+    peer._transmit_iframe = _transmit_iframe
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTED
+    peer._init_connection(True)
+
+    # Inject a frame
+    peer._on_receive(
+        AX25RawFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+            payload=b"\x0d\x54",
+        )
+    )
+
+    assert iframes_rqd == [42]
+
+
+def test_recv_disc():
+    """
+    Test that DISC is handled.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub _send_ua and _on_disconnect
+    count = dict(send_ua=0, on_disc=0)
+
+    def _send_ua():
+        count["send_ua"] += 1
+
+    peer._send_ua = _send_ua
+
+    def _on_disconnect():
+        count["on_disc"] += 1
+
+    peer._on_disconnect = _on_disconnect
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTING
+
+    # Inject a frame
+    peer._on_receive(
+        AX25DisconnectFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+        )
+    )
+
+    # Our handlers should have been called
+    assert count == dict(send_ua=1, on_disc=1)
+
+
+def test_recv_dm():
+    """
+    Test that DM is handled.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub _dmframe_handler and _on_disconnect
+    count = dict(dmframe_handler=0)
+
+    def _dmframe_handler():
+        count["dmframe_handler"] += 1
+
+    peer._dmframe_handler = _dmframe_handler
+
+    def _on_disconnect():
+        assert False, "_dmframe_handler should not have been called"
+
+    peer._on_disconnect = _on_disconnect
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTING
+
+    # Inject a frame
+    peer._on_receive(
+        AX25DisconnectModeFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+        )
+    )
+
+    # Our handler should have been called
+    assert count == dict(dmframe_handler=1)
+
+    # We should have removed the DM frame handler
+    assert peer._dmframe_handler is None
+
+
+def test_recv_sabm():
+    """
+    Test that SABM is handled.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub _on_receive_sabm, we'll test it fully later
+    frames = []
+
+    def _on_receive_sabm(frame):
+        frames.append(frame)
+
+    peer._on_receive_sabm = _on_receive_sabm
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTING
+
+    # Inject a frame
+    frame = AX25SetAsyncBalancedModeFrame(
+        destination=AX25Address("VK4MSL-1"),
+        source=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+    )
+    peer._on_receive(frame)
+
+    assert frames == [frame]
+
+
+def test_recv_sabme():
+    """
+    Test that SABME is handled.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub idle time-out handling
+    peer._reset_idle_timeout = lambda: None
+
+    # Stub _on_receive_sabm, we'll test it fully later
+    frames = []
+
+    def _on_receive_sabm(frame):
+        frames.append(frame)
+
+    peer._on_receive_sabm = _on_receive_sabm
+
+    # Set the state
+    peer._state = AX25PeerState.CONNECTING
+
+    # Inject a frame
+    frame = AX25SetAsyncBalancedModeExtendedFrame(
+        destination=AX25Address("VK4MSL-1"),
+        source=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+    )
+    peer._on_receive(frame)
+
+    assert frames == [frame]
+
+
+# RR Notification transmission, scheduling and cancellation
+
+
+def test_cancel_rr_notification_notpending():
+    """
+    Test _cancel_rr_notification does nothing if not pending.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    assert peer._rr_notification_timeout_handle is None
+
+    peer._cancel_rr_notification()
+
+    assert peer._rr_notification_timeout_handle is None
+
+
+def test_cancel_rr_notification_ispending():
+    """
+    Test _cancel_rr_notification cancels a pending notification.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    timeout = DummyTimeout(0, lambda: None)
+    peer._rr_notification_timeout_handle = timeout
+
+    peer._cancel_rr_notification()
+
+    assert peer._rr_notification_timeout_handle is None
+    assert timeout.cancelled is True
+
+
+def test_schedule_rr_notification():
+    """
+    Test _schedule_rr_notification schedules a notification.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    peer._schedule_rr_notification()
+
+    assert peer._rr_notification_timeout_handle is not None
+
+
+def test_send_rr_notification_connected():
+    """
+    Test _send_rr_notification sends a notification if connected.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    peer._init_connection(False)
+
+    count = dict(update_recv_seq=0)
+
+    def _update_recv_seq():
+        count["update_recv_seq"] += 1
+
+    peer._update_recv_seq = _update_recv_seq
+
+    transmitted = []
+
+    def _transmit_frame(frame):
+        transmitted.append(frame)
+
+    peer._transmit_frame = _transmit_frame
+
+    peer._state = AX25PeerState.CONNECTED
+
+    peer._send_rr_notification()
+
+    assert count == dict(update_recv_seq=1)
+    assert len(transmitted) == 1
+    assert isinstance(transmitted[0], AX258BitReceiveReadyFrame)
+
+
+def test_send_rr_notification_disconnected():
+    """
+    Test _send_rr_notification sends a notification if connected.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    peer._init_connection(False)
+
+    count = dict(update_recv_seq=0)
+
+    def _update_recv_seq():
+        count["update_recv_seq"] += 1
+
+    peer._update_recv_seq = _update_recv_seq
+
+    transmitted = []
+
+    def _transmit_frame(frame):
+        transmitted.append(frame)
+
+    peer._transmit_frame = _transmit_frame
+
+    peer._state = AX25PeerState.DISCONNECTED
+
+    peer._send_rr_notification()
+
+    assert count == dict(update_recv_seq=0)
+    assert len(transmitted) == 0
+
+
+# RNR transmission
+
+
+def test_send_rnr_notification_connected():
+    """
+    Test _send_rnr_notification sends a notification if connected.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    peer._init_connection(False)
+
+    count = dict(update_recv_seq=0)
+
+    def _update_recv_seq():
+        count["update_recv_seq"] += 1
+
+    peer._update_recv_seq = _update_recv_seq
+
+    transmitted = []
+
+    def _transmit_frame(frame):
+        transmitted.append(frame)
+
+    peer._transmit_frame = _transmit_frame
+
+    peer._state = AX25PeerState.CONNECTED
+
+    peer._send_rnr_notification()
+
+    assert count == dict(update_recv_seq=1)
+    assert len(transmitted) == 1
+    assert isinstance(transmitted[0], AX258BitReceiveNotReadyFrame)
+
+
+def test_send_rnr_notification_connected_recent():
+    """
+    Test _send_rnr_notification skips notification if the last was recent.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    peer._init_connection(False)
+
+    count = dict(update_recv_seq=0)
+
+    def _update_recv_seq():
+        count["update_recv_seq"] += 1
+
+    peer._update_recv_seq = _update_recv_seq
+
+    transmitted = []
+
+    def _transmit_frame(frame):
+        transmitted.append(frame)
+
+    peer._transmit_frame = _transmit_frame
+
+    peer._state = AX25PeerState.CONNECTED
+    peer._last_rnr_sent = peer._loop.time() - (peer._rnr_interval / 2)
+
+    peer._send_rnr_notification()
+
+    assert count == dict(update_recv_seq=0)
+    assert len(transmitted) == 0
+
+
+def test_send_rnr_notification_disconnected():
+    """
+    Test _send_rnr_notification sends a notification if connected.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    peer._init_connection(False)
+
+    count = dict(update_recv_seq=0)
+
+    def _update_recv_seq():
+        count["update_recv_seq"] += 1
+
+    peer._update_recv_seq = _update_recv_seq
+
+    transmitted = []
+
+    def _transmit_frame(frame):
+        transmitted.append(frame)
+
+    peer._transmit_frame = _transmit_frame
+
+    peer._state = AX25PeerState.DISCONNECTED
+
+    peer._send_rnr_notification()
+
+    assert count == dict(update_recv_seq=0)
+    assert len(transmitted) == 0
+
+
+# I-Frame transmission
+
+
+def test_send_next_iframe_max_outstanding():
+    """
+    Test I-frame transmission is suppressed if too many frames are pending.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    peer._init_connection(False)
+
+    count = dict(update_send_seq=0, update_recv_seq=0)
+
+    def _update_recv_seq():
+        count["update_recv_seq"] += 1
+
+    peer._update_recv_seq = _update_recv_seq
+
+    def _update_send_seq():
+        count["update_send_seq"] += 1
+
+    peer._update_send_seq = _update_send_seq
+
+    transmitted = []
+
+    def _transmit_frame(frame):
+        transmitted.append(frame)
+
+    peer._transmit_frame = _transmit_frame
+
+    state_updates = []
+
+    def _update_state(**kwargs):
+        state_updates.append(kwargs)
+
+    peer._update_state = _update_state
+
+    peer._state = AX25PeerState.CONNECTED
+    peer._pending_iframes = {
+        0: (0xF0, b"Frame 1"),
+        1: (0xF0, b"Frame 2"),
+        2: (0xF0, b"Frame 3"),
+        3: (0xF0, b"Frame 4"),
+        4: (0xF0, b"Frame 5"),
+        5: (0xF0, b"Frame 6"),
+        6: (0xF0, b"Frame 7"),
+        7: (0xF0, b"Frame 8"),
+    }
+    peer._max_outstanding = 8
+
+    peer._send_next_iframe()
+
+    assert count == dict(update_send_seq=0, update_recv_seq=0)
+    assert state_updates == []
+    assert transmitted == []
+
+
+def test_send_next_iframe_nothing_pending():
+    """
+    Test I-frame transmission is suppressed no data is pending.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    peer._init_connection(False)
+
+    count = dict(update_send_seq=0, update_recv_seq=0)
+
+    def _update_recv_seq():
+        count["update_recv_seq"] += 1
+
+    peer._update_recv_seq = _update_recv_seq
+
+    def _update_send_seq():
+        count["update_send_seq"] += 1
+
+    peer._update_send_seq = _update_send_seq
+
+    transmitted = []
+
+    def _transmit_frame(frame):
+        transmitted.append(frame)
+
+    peer._transmit_frame = _transmit_frame
+
+    state_updates = []
+
+    def _update_state(**kwargs):
+        state_updates.append(kwargs)
+
+    peer._update_state = _update_state
+
+    peer._state = AX25PeerState.CONNECTED
+    peer._pending_iframes = {
+        0: (0xF0, b"Frame 1"),
+        1: (0xF0, b"Frame 2"),
+        2: (0xF0, b"Frame 3"),
+        3: (0xF0, b"Frame 4"),
+    }
+    peer._max_outstanding = 8
+    peer._send_state = 4
+
+    peer._send_next_iframe()
+
+    assert count == dict(update_send_seq=0, update_recv_seq=0)
+    assert state_updates == []
+    assert transmitted == []
+
+
+def test_send_next_iframe_create_next():
+    """
+    Test I-frame transmission creates a new I-frame if there's data to send.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    peer._init_connection(False)
+
+    count = dict(update_send_seq=0, update_recv_seq=0)
+
+    def _update_recv_seq():
+        count["update_recv_seq"] += 1
+
+    peer._update_recv_seq = _update_recv_seq
+
+    def _update_send_seq():
+        count["update_send_seq"] += 1
+
+    peer._update_send_seq = _update_send_seq
+
+    transmitted = []
+
+    def _transmit_frame(frame):
+        transmitted.append(frame)
+
+    peer._transmit_frame = _transmit_frame
+
+    state_updates = []
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+
+    peer._update_state = _update_state
+
+    peer._state = AX25PeerState.CONNECTED
+    peer._pending_iframes = {
+        0: (0xF0, b"Frame 1"),
+        1: (0xF0, b"Frame 2"),
+        2: (0xF0, b"Frame 3"),
+        3: (0xF0, b"Frame 4"),
+    }
+    peer._pending_data = [
+        (0xF0, b"Frame 5"),
+    ]
+    peer._max_outstanding = 8
+    peer._send_state = 4
+
+    peer._send_next_iframe()
+
+    assert peer._pending_iframes == {
+        0: (0xF0, b"Frame 1"),
+        1: (0xF0, b"Frame 2"),
+        2: (0xF0, b"Frame 3"),
+        3: (0xF0, b"Frame 4"),
+        4: (0xF0, b"Frame 5"),
+    }
+    assert peer._pending_data == []
+    assert count == dict(update_send_seq=1, update_recv_seq=1)
+    assert state_updates == [
+        dict(prop="_send_state", delta=1, comment="send next I-frame")
+    ]
+    assert transmitted[1:] == []
+    frame = transmitted.pop(0)
+    assert isinstance(frame, AX258BitInformationFrame)
+    assert frame.payload == b"Frame 5"
+
+
+def test_send_next_iframe_existing_next():
+    """
+    Test I-frame transmission sends existing next frame.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    peer._init_connection(False)
+
+    count = dict(update_send_seq=0, update_recv_seq=0)
+
+    def _update_recv_seq():
+        count["update_recv_seq"] += 1
+
+    peer._update_recv_seq = _update_recv_seq
+
+    def _update_send_seq():
+        count["update_send_seq"] += 1
+
+    peer._update_send_seq = _update_send_seq
+
+    transmitted = []
+
+    def _transmit_frame(frame):
+        transmitted.append(frame)
+
+    peer._transmit_frame = _transmit_frame
+
+    state_updates = []
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+
+    peer._update_state = _update_state
+
+    peer._state = AX25PeerState.CONNECTED
+    peer._pending_iframes = {
+        0: (0xF0, b"Frame 1"),
+        1: (0xF0, b"Frame 2"),
+        2: (0xF0, b"Frame 3"),
+        3: (0xF0, b"Frame 4"),
+    }
+    peer._pending_data = [
+        (0xF0, b"Frame 5"),
+    ]
+    peer._max_outstanding = 8
+    peer._send_state = 3
+
+    peer._send_next_iframe()
+
+    assert peer._pending_iframes == {
+        0: (0xF0, b"Frame 1"),
+        1: (0xF0, b"Frame 2"),
+        2: (0xF0, b"Frame 3"),
+        3: (0xF0, b"Frame 4"),
+    }
+    assert peer._pending_data == [
+        (0xF0, b"Frame 5"),
+    ]
+    assert count == dict(update_send_seq=1, update_recv_seq=1)
+    assert state_updates == [
+        dict(prop="_send_state", delta=1, comment="send next I-frame")
+    ]
+    assert transmitted[1:] == []
+    frame = transmitted.pop(0)
+    assert isinstance(frame, AX258BitInformationFrame)
+    assert frame.payload == b"Frame 4"
+
+
+# Sequence number state updates
+
+
+def test_update_send_seq():
+    """
+    Test _update_send_seq copies V(S) to N(S).
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    state_updates = []
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+
+    peer._update_state = _update_state
+
+    peer._send_seq = 2
+    peer._send_state = 6
+
+    peer._update_send_seq()
+    assert state_updates == [
+        dict(prop="_send_seq", value=6, comment="from V(S)")
+    ]
+
+
+def test_update_recv_seq():
+    """
+    Test _update_recv_seq copies V(R) to N(R).
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path(),
+    )
+
+    state_updates = []
+
+    def _update_state(prop, **kwargs):
+        kwargs["prop"] = prop
+        state_updates.append(kwargs)
+
+    peer._update_state = _update_state
+
+    peer._recv_state = 6
+    peer._recv_seq = 2
+
+    peer._update_recv_seq()
+    assert state_updates == [
+        dict(prop="_recv_seq", value=6, comment="from V(R)")
+    ]
+
+
+# SABM(E) handling
+
+
+def test_on_receive_sabm_while_connecting():
+    """
+    Test that SABM is handled safely while UA from SABM pending
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Assume we're already connecting to the station
+    peer._state = AX25PeerState.CONNECTING
+
+    # Stub _init_connection
+    count = dict(init=0, sabmframe_handler=0)
+
+    def _init_connection(extended):
+        assert extended is False
+        count["init"] += 1
+
+    peer._init_connection = _init_connection
+
+    # Stub _sabmframe_handler
+    def _sabmframe_handler():
+        count["sabmframe_handler"] += 1
+
+    peer._sabmframe_handler = _sabmframe_handler
+
+    # Stub _start_connect_ack_timer
+    def _start_connect_ack_timer():
+        assert False, "Should not be starting connect timer"
+
+    peer._start_connect_ack_timer = _start_connect_ack_timer
+
+    # Hook connection request event
+    def _on_conn_rq(**kwargs):
+        assert False, "Should not be reporting connection attempt"
+
+    station.connection_request.connect(_on_conn_rq)
+
+    peer._on_receive_sabm(
+        AX25SetAsyncBalancedModeFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+        )
+    )
+
+    assert count == dict(init=1, sabmframe_handler=1)
+
+
+def test_on_receive_sabme_init():
+    """
+    Test the incoming connection is initialised on receipt of SABME.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Assume we know it's an AX.25 2.2 peer
+    peer._protocol = AX25Version.AX25_22
+
+    # Stub _init_connection
+    count = dict(init=0, start_timer=0, conn_rq=0)
+
+    def _init_connection(extended):
+        assert extended is True
+        count["init"] += 1
+
+    peer._init_connection = _init_connection
+
+    # Stub _sabmframe_handler
+    def _sabmframe_handler():
+        assert False, "We should be handling the SABM(E) ourselves"
+
+    peer._sabmframe_handler = _sabmframe_handler
+
+    # Stub _start_connect_ack_timer
+    def _start_connect_ack_timer():
+        count["start_timer"] += 1
+
+    peer._start_connect_ack_timer = _start_connect_ack_timer
+
+    # Hook connection request event
+    def _on_conn_rq(**kwargs):
+        assert kwargs == dict(peer=peer)
+        count["conn_rq"] += 1
+
+    station.connection_request.connect(_on_conn_rq)
+
+    peer._on_receive_sabm(
+        AX25SetAsyncBalancedModeExtendedFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+        )
+    )
+
+    assert count == dict(init=1, start_timer=1, conn_rq=1)
+
+
+def test_on_receive_sabme_init_unknown_peer_ver():
+    """
+    Test we switch the peer to AX.25 2.2 mode on receipt of SABME
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Assume we do not know the peer's AX.25 version
+    peer._protocol = AX25Version.UNKNOWN
+
+    # Stub _init_connection
+    count = dict(init=0, start_timer=0, conn_rq=0)
+
+    def _init_connection(extended):
+        assert extended is True
+        count["init"] += 1
+
+    peer._init_connection = _init_connection
+
+    # Stub _start_connect_ack_timer
+    def _start_connect_ack_timer():
+        count["start_timer"] += 1
+
+    peer._start_connect_ack_timer = _start_connect_ack_timer
+
+    # Hook connection request event
+    def _on_conn_rq(**kwargs):
+        assert kwargs == dict(peer=peer)
+        count["conn_rq"] += 1
+
+    station.connection_request.connect(_on_conn_rq)
+
+    peer._on_receive_sabm(
+        AX25SetAsyncBalancedModeExtendedFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+        )
+    )
+
+    assert peer._protocol == AX25Version.AX25_22
+
+
+def test_on_receive_sabme_ax25_20_station():
+    """
+    Test we reject SABME if station is in AX.25 2.0 mode
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Set AX.25 2.0 mode on the station
+    station._protocol = AX25Version.AX25_20
+
+    # Stub _send_frmr
+    frmr = []
+
+    def _send_frmr(frame, **kwargs):
+        frmr.append((frame, kwargs))
+
+    peer._send_frmr = _send_frmr
+
+    # Stub _init_connection
+    def _init_connection(extended):
+        assert False, "Should not have been called"
+
+    peer._init_connection = _init_connection
+
+    # Stub _start_connect_ack_timer
+    def _start_connect_ack_timer():
+        assert False, "Should not have been called"
+
+    peer._start_connect_ack_timer = _start_connect_ack_timer
+
+    # Hook connection request event
+    def _on_conn_rq(**kwargs):
+        assert False, "Should not have been called"
+
+    station.connection_request.connect(_on_conn_rq)
+
+    frame = AX25SetAsyncBalancedModeExtendedFrame(
+        destination=AX25Address("VK4MSL-1"),
+        source=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+    )
+    peer._on_receive_sabm(frame)
+
+    assert frmr == [(frame, dict(w=True))]
+
+
+def test_on_receive_sabme_ax25_20_peer():
+    """
+    Test we reject SABME if peer not in AX.25 2.2 mode
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Assume the peer runs AX.25 2.0
+    peer._protocol = AX25Version.AX25_20
+
+    # Stub _send_dm
+    count = dict(send_dm=0)
+
+    def _send_dm():
+        count["send_dm"] += 1
+
+    peer._send_dm = _send_dm
+
+    # Stub _init_connection
+    def _init_connection(extended):
+        assert False, "Should not have been called"
+
+    peer._init_connection = _init_connection
+
+    # Stub _start_connect_ack_timer
+    def _start_connect_ack_timer():
+        assert False, "Should not have been called"
+
+    peer._start_connect_ack_timer = _start_connect_ack_timer
+
+    # Hook connection request event
+    def _on_conn_rq(**kwargs):
+        assert False, "Should not have been called"
+
+    station.connection_request.connect(_on_conn_rq)
+
+    peer._on_receive_sabm(
+        AX25SetAsyncBalancedModeExtendedFrame(
+            destination=AX25Address("VK4MSL-1"),
+            source=AX25Address("VK4MSL"),
+            repeaters=AX25Path("VK4RZB"),
+        )
+    )
+
+    assert count == dict(send_dm=1)
+
+
+# Connection initialisation
+
+
+def test_init_connection_mod8():
+    """
+    Test _init_connection can initialise a standard mod-8 connection.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Set some dummy data in fields -- this should be cleared out or set
+    # to sane values.
+    ack_timer = DummyTimeout(None, None)
+    peer._send_state = 1
+    peer._send_seq = 2
+    peer._recv_state = 3
+    peer._recv_seq = 4
+    peer._ack_state = 5
+    peer._modulo = 6
+    peer._max_outstanding = 7
+    peer._IFrameClass = None
+    peer._RRFrameClass = None
+    peer._RNRFrameClass = None
+    peer._REJFrameClass = None
+    peer._SREJFrameClass = None
+    peer._pending_iframes = dict(comment="pending data")
+    peer._pending_data = ["pending data"]
+
+    peer._init_connection(extended=False)
+
+    # These should be set for a Mod-8 connection
+    assert peer._max_outstanding == 7
+    assert peer._modulo == 8
+    assert peer._IFrameClass is AX258BitInformationFrame
+    assert peer._RRFrameClass is AX258BitReceiveReadyFrame
+    assert peer._RNRFrameClass is AX258BitReceiveNotReadyFrame
+    assert peer._REJFrameClass is AX258BitRejectFrame
+    assert peer._SREJFrameClass is AX258BitSelectiveRejectFrame
+
+    # These should be initialised to initial state
+    assert peer._send_state == 0
+    assert peer._send_seq == 0
+    assert peer._recv_state == 0
+    assert peer._recv_seq == 0
+    assert peer._ack_state == 0
+    assert peer._pending_iframes == {}
+    assert peer._pending_data == []
+
+
+def test_init_connection_mod128():
+    """
+    Test _init_connection can initialise a mod-128 connection.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Set some dummy data in fields -- this should be cleared out or set
+    # to sane values.
+    ack_timer = DummyTimeout(None, None)
+    peer._send_state = 1
+    peer._send_seq = 2
+    peer._recv_state = 3
+    peer._recv_seq = 4
+    peer._ack_state = 5
+    peer._modulo = 6
+    peer._max_outstanding = 7
+    peer._IFrameClass = None
+    peer._RRFrameClass = None
+    peer._RNRFrameClass = None
+    peer._REJFrameClass = None
+    peer._SREJFrameClass = None
+    peer._pending_iframes = dict(comment="pending data")
+    peer._pending_data = ["pending data"]
+
+    peer._init_connection(extended=True)
+
+    # These should be set for a Mod-128 connection
+    assert peer._max_outstanding == 127
+    assert peer._modulo == 128
+    assert peer._IFrameClass is AX2516BitInformationFrame
+    assert peer._RRFrameClass is AX2516BitReceiveReadyFrame
+    assert peer._RNRFrameClass is AX2516BitReceiveNotReadyFrame
+    assert peer._REJFrameClass is AX2516BitRejectFrame
+    assert peer._SREJFrameClass is AX2516BitSelectiveRejectFrame
+
+    # These should be initialised to initial state
+    assert peer._send_state == 0
+    assert peer._send_seq == 0
+    assert peer._recv_state == 0
+    assert peer._recv_seq == 0
+    assert peer._ack_state == 0
+    assert peer._pending_iframes == {}
+    assert peer._pending_data == []
+
+
+# Connection acceptance and rejection handling
+
+
+def test_accept_connected_noop():
+    """
+    Test calling .accept() while not receiving a connection is a no-op.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Set the state to known value
+    peer._state = AX25PeerState.CONNECTED
+
+    # Stub functions that should not be called
+    def _stop_ack_timer():
+        assert False, "Should not have stopped connect timer"
+
+    peer._stop_ack_timer = _stop_ack_timer
+
+    def _send_ua():
+        assert False, "Should not have sent UA"
+
+    peer._send_ua = _send_ua
+
+    # Try accepting a ficticious connection
+    peer.accept()
+
+    assert peer._state == AX25PeerState.CONNECTED
+
+
+def test_accept_incoming_ua():
+    """
+    Test calling .accept() with incoming connection sends UA then SABM.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Set the state to known value
+    peer._state = AX25PeerState.INCOMING_CONNECTION
+
+    # Stub functions that should be called
+    actions = []
+
+    def _stop_ack_timer():
+        actions.append("stop-connect-timer")
+
+    peer._stop_ack_timer = _stop_ack_timer
+
+    def _send_ua():
+        # At this time, we should be in the INCOMING_CONNECTION state
+        assert peer._state is AX25PeerState.INCOMING_CONNECTION
+        actions.append("sent-ua")
+
+    peer._send_ua = _send_ua
+
+    # Try accepting a ficticious connection
+    peer.accept()
+
+    assert peer._state is AX25PeerState.CONNECTED
+    assert actions == ["stop-connect-timer", "sent-ua"]
+    assert peer._uaframe_handler is None
+
+
+def test_reject_connected_noop():
+    """
+    Test calling .reject() while not receiving a connection is a no-op.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Set the state to known value
+    peer._state = AX25PeerState.CONNECTED
+
+    # Stub functions that should not be called
+    def _stop_ack_timer():
+        assert False, "Should not have stopped connect timer"
+
+    peer._stop_ack_timer = _stop_ack_timer
+
+    def _send_dm():
+        assert False, "Should not have sent DM"
+
+    peer._send_dm = _send_dm
+
+    # Try rejecting a ficticious connection
+    peer.reject()
+
+    assert peer._state == AX25PeerState.CONNECTED
+
+
+def test_reject_incoming_dm():
+    """
+    Test calling .reject() with no incoming connection is a no-op.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Set the state to known value
+    peer._state = AX25PeerState.INCOMING_CONNECTION
+
+    # Stub functions that should be called
+    actions = []
+
+    def _stop_ack_timer():
+        actions.append("stop-connect-timer")
+
+    peer._stop_ack_timer = _stop_ack_timer
+
+    def _send_dm():
+        actions.append("sent-dm")
+
+    peer._send_dm = _send_dm
+
+    # Try rejecting a ficticious connection
+    peer.reject()
+
+    assert peer._state == AX25PeerState.DISCONNECTED
+    assert actions == ["stop-connect-timer", "sent-dm"]
+
+
+# Connection closure
+
+
+def test_disconnect_disconnected_noop():
+    """
+    Test calling .disconnect() while not connected is a no-op.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Set the state to known value
+    peer._state = AX25PeerState.CONNECTING
+
+    # A dummy UA handler
+    def _dummy_ua_handler():
+        assert False, "Should not get called"
+
+    peer._uaframe_handler = _dummy_ua_handler
+
+    # Stub functions that should not be called
+    def _send_disc():
+        assert False, "Should not have sent DISC frame"
+
+    peer._send_disc = _send_disc
+
+    def _start_disconnect_ack_timer():
+        assert False, "Should not have started disconnect timer"
+
+    peer._start_disconnect_ack_timer = _start_disconnect_ack_timer
+
+    # Try disconnecting a ficticious connection
+    peer.disconnect()
+
+    assert peer._state == AX25PeerState.CONNECTING
+    assert peer._uaframe_handler == _dummy_ua_handler
+
+
+def test_disconnect_connected_disc():
+    """
+    Test calling .disconnect() while connected sends a DISC.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Set the state to known value
+    peer._state = AX25PeerState.CONNECTED
+
+    # A dummy UA handler
+    def _dummy_ua_handler():
+        assert False, "Should not get called"
+
+    peer._uaframe_handler = _dummy_ua_handler
+
+    # Stub functions that should be called
+    actions = []
+
+    def _send_disc():
+        actions.append("sent-disc")
+
+    peer._send_disc = _send_disc
+
+    def _start_disconnect_ack_timer():
+        actions.append("start-ack-timer")
+
+    peer._start_disconnect_ack_timer = _start_disconnect_ack_timer
+
+    # Try disconnecting a ficticious connection
+    peer.disconnect()
+
+    assert peer._state == AX25PeerState.DISCONNECTING
+    assert actions == ["sent-disc", "start-ack-timer"]
+    assert peer._uaframe_handler == peer._on_disconnect
+
+
+# ACK timer handling
+
+
+def test_start_connect_ack_timer():
+    """
+    Test _start_connect_ack_timer schedules _on_incoming_connect_timeout
+    to fire after _ack_timeout.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    count = dict(on_incoming_connect_timeout=0, on_disc_ua_timeout=0)
+
+    def _on_incoming_connect_timeout():
+        count["on_incoming_connect_timeout"] += 1
+
+    peer._on_incoming_connect_timeout = _on_incoming_connect_timeout
+
+    def _on_disc_ua_timeout():
+        count["on_disc_ua_timeout"] += 1
+
+    peer._on_disc_ua_timeout = _on_disc_ua_timeout
+
+    assert peer._ack_timeout_handle is None
+
+    peer._start_connect_ack_timer()
+
+    assert peer._ack_timeout_handle is not None
+    assert peer._ack_timeout_handle.delay == peer._ack_timeout
+
+    assert count == dict(on_incoming_connect_timeout=0, on_disc_ua_timeout=0)
+    peer._ack_timeout_handle.callback()
+    assert count == dict(on_incoming_connect_timeout=1, on_disc_ua_timeout=0)
+
+
+def test_start_disconnect_ack_timer():
+    """
+    Test _start_disconnect_ack_timer schedules _on_disc_ua_timeout
+    to fire after _ack_timeout.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    count = dict(on_incoming_connect_timeout=0, on_disc_ua_timeout=0)
+
+    def _on_incoming_connect_timeout():
+        count["on_incoming_connect_timeout"] += 1
+
+    peer._on_incoming_connect_timeout = _on_incoming_connect_timeout
+
+    def _on_disc_ua_timeout():
+        count["on_disc_ua_timeout"] += 1
+
+    peer._on_disc_ua_timeout = _on_disc_ua_timeout
+
+    assert peer._ack_timeout_handle is None
+
+    peer._start_disconnect_ack_timer()
+
+    assert peer._ack_timeout_handle is not None
+    assert peer._ack_timeout_handle.delay == peer._ack_timeout
+
+    assert count == dict(on_incoming_connect_timeout=0, on_disc_ua_timeout=0)
+    peer._ack_timeout_handle.callback()
+    assert count == dict(on_incoming_connect_timeout=0, on_disc_ua_timeout=1)
+
+
+def test_stop_ack_timer_existing():
+    """
+    Test _stop_ack_timer cancels the existing time-out.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    timeout = DummyTimeout(None, None)
+    peer._ack_timeout_handle = timeout
+
+    peer._stop_ack_timer()
+
+    assert peer._ack_timeout_handle is None
+    assert timeout.cancelled is True
+
+
+def test_stop_ack_timer_notexisting():
+    """
+    Test _stop_ack_timer does nothing if no time-out pending.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    peer._ack_timeout_handle = None
+
+    peer._stop_ack_timer()
+
+
+# AX.25 2.2 XID negotiation
+
+
+@mark.parametrize("version", [AX25Version.AX25_10, AX25Version.AX25_20])
+def test_negotiate_notsupported(version):
+    """
+    Test the peer refuses to perform XID if the protocol does not support it.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    peer._state = AX25PeerState.CONNECTING
+    peer._protocol = version
+
+    try:
+        peer._negotiate(lambda **kwa: None)
+        assert False, "Should not have worked"
+    except RuntimeError as e:
+        assert str(e) == "%s does not support negotiation" % (version.value)
+
+
+@mark.parametrize("version", [AX25Version.AX25_22, AX25Version.UNKNOWN])
+def test_negotiate_supported(version):
+    """
+    Test the peer refuses to perform XID if the protocol does not support it.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub XID transmission
+    count = dict(send_xid=0)
+
+    def _send_xid(cr):
+        count["send_xid"] += 1
+
+    peer._send_xid = _send_xid
+
+    peer._state = AX25PeerState.CONNECTING
+    peer._protocol = version
+
+    peer._negotiate(lambda **kwa: None)
+
+    # Check we actually did request a XID transmission
+    assert count == dict(send_xid=1)
+
+    # Trigger the DM callback to abort time-outs
+    assert peer._dmframe_handler is not None
+    peer._dmframe_handler()
+
+
+@mark.parametrize(
+    "version, response",
+    [
+        (axver, res)
+        for axver in (AX25Version.AX25_22, AX25Version.UNKNOWN)
+        for res in ("frmr", "dm")
+    ],
+)
+def test_on_negotiate_result_unsupported(version, response):
+    """
+    Test we handle a response that indicates an AX.25 2.0 or earlier station.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub XID functions
+    xid_params = set()
+
+    def _set_xid_param(param, value):
+        xid_params.add(param)
+
+    for param in (
+        "cop",
+        "hdlcoptfunc",
+        "ifieldlenrx",
+        "winszrx",
+        "acktimer",
+        "retrycounter",
+    ):
+        setattr(
+            peer, "_process_xid_%s" % param, partial(_set_xid_param, param)
+        )
+
+    assert peer._negotiated == False
+    peer._modulo128 = True
+    peer._protocol = version
+
+    peer._on_negotiate_result(response=response)
+
+    # Should downgrade to AX.25 2.0
+    assert peer._negotiated is True
+    assert peer._modulo128 is False
+    assert peer._protocol is AX25Version.AX25_20
+
+    # Should have inserted defaults for AX.25 2.0
+    assert xid_params == set(
+        [
+            "acktimer",
+            "cop",
+            "hdlcoptfunc",
+            "ifieldlenrx",
+            "retrycounter",
+            "winszrx",
+        ]
+    )
+
+
+@mark.parametrize(
+    "version, response",
+    [
+        (axver, res)
+        for axver in (AX25Version.AX25_20, AX25Version.AX25_10)
+        for res in ("frmr", "dm")
+    ],
+)
+def test_on_negotiate_result_unsupported_old(version, response):
+    """
+    Test we do not accidentally "upgrade" on FRMR/DM in response to XID.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub XID functions
+    xid_params = set()
+
+    def _set_xid_param(param, value):
+        xid_params.add(param)
+
+    for param in (
+        "cop",
+        "hdlcoptfunc",
+        "ifieldlenrx",
+        "winszrx",
+        "acktimer",
+        "retrycounter",
+    ):
+        setattr(
+            peer, "_process_xid_%s" % param, partial(_set_xid_param, param)
+        )
+
+    assert peer._negotiated == False
+    peer._modulo128 = True
+    peer._protocol = version
+
+    peer._on_negotiate_result(response=response)
+
+    # Should leave this as is!
+    assert peer._protocol is version
+
+    # Should disable AX.25 2.2 features
+    assert peer._negotiated is True
+    assert peer._modulo128 is False
+
+    # Should have inserted defaults for AX.25 2.0
+    assert xid_params == set(
+        [
+            "acktimer",
+            "cop",
+            "hdlcoptfunc",
+            "ifieldlenrx",
+            "retrycounter",
+            "winszrx",
+        ]
+    )
+
+
+@mark.parametrize(
+    "version",
+    [
+        AX25Version.UNKNOWN,
+        AX25Version.AX25_22,
+        AX25Version.AX25_20,
+        AX25Version.AX25_10,
+    ],
+)
+def test_on_negotiate_result_success(version):
+    """
+    Test we upgrade to AX.25 2.2 if XID successful.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub XID functions
+    xid_params = set()
+
+    def _set_xid_param(param, value):
+        xid_params.add(param)
+
+    for param in (
+        "cop",
+        "hdlcoptfunc",
+        "ifieldlenrx",
+        "winszrx",
+        "acktimer",
+        "retrycounter",
+    ):
+        setattr(
+            peer, "_process_xid_%s" % param, partial(_set_xid_param, param)
+        )
+
+    assert peer._negotiated == False
+    peer._modulo128 = True
+    peer._protocol = version
+
+    peer._on_negotiate_result(response="success")
+
+    # Should bump to AX.25 2.2
+    assert peer._protocol is AX25Version.AX25_22
+
+    # Should leave AX.25 2.2 features enabled
+    assert peer._negotiated is True
+    assert peer._modulo128 is True
+
+    # Should not override XID parameters set by handler
+    assert xid_params == set([])
diff --git a/tests/test_peer/test_disc.py b/tests/test_peer/test_disc.py
new file mode 100644
index 0000000..2d7f518
--- /dev/null
+++ b/tests/test_peer/test_disc.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+
+"""
+Tests for AX25Peer DISC handling
+"""
+
+from aioax25.frame import (
+    AX25Address,
+    AX25Path,
+    AX25DisconnectFrame,
+    AX25UnnumberedAcknowledgeFrame,
+)
+from aioax25.peer import AX25PeerState
+from aioax25.version import AX25Version
+from .peer import TestingAX25Peer
+from ..mocks import DummyStation, DummyTimeout
+
+
+# DISC reception handling
+
+
+def test_peer_recv_disc():
+    """
+    Test when receiving a DISC whilst connected, the peer disconnects.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    interface = station._interface()
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"),
+        full_duplex=True,
+        locked_path=True,
+    )
+
+    # Set some dummy data in fields -- this should be cleared out.
+    ack_timer = DummyTimeout(None, None)
+    peer._ack_timeout_handle = ack_timer
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 8
+    peer._send_state = 1
+    peer._send_seq = 2
+    peer._recv_state = 3
+    peer._recv_seq = 4
+    peer._pending_iframes = dict(comment="pending data")
+    peer._pending_data = ["pending data"]
+
+    # Pass the peer a DISC frame
+    peer._on_receive(
+        AX25DisconnectFrame(
+            destination=station.address, source=peer.address, repeaters=None
+        )
+    )
+
+    # This was a request, so there should be a reply waiting
+    assert len(interface.transmit_calls) == 1
+    (tx_args, tx_kwargs) = interface.transmit_calls.pop(0)
+
+    # This should be a UA in reply to the DISC
+    assert tx_kwargs == {"callback": None}
+    assert len(tx_args) == 1
+    (frame,) = tx_args
+    assert isinstance(frame, AX25UnnumberedAcknowledgeFrame)
+
+    assert str(frame.header.destination) == "VK4MSL*"
+    assert str(frame.header.source) == "VK4MSL-1"
+    assert str(frame.header.repeaters) == "VK4MSL-2,VK4MSL-3"
+
+    # We should now be "disconnected"
+    assert peer._ack_timeout_handle is None
+    assert peer._state is AX25PeerState.DISCONNECTED
+    assert peer._send_state == 0
+    assert peer._send_seq == 0
+    assert peer._recv_state == 0
+    assert peer._recv_seq == 0
+    assert peer._pending_iframes == {}
+    assert peer._pending_data == []
+
+
+# DISC transmission
+
+
+def test_peer_send_disc():
+    """
+    Test _send_disc correctly addresses and sends a DISC frame.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    interface = station._interface()
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"),
+        full_duplex=True,
+    )
+    peer._modulo = 8
+
+    # Request a DISC frame be sent
+    peer._send_disc()
+
+    # There should be our outgoing request here
+    assert len(interface.transmit_calls) == 1
+    (tx_args, tx_kwargs) = interface.transmit_calls.pop(0)
+
+    # This should be a DISC
+    assert tx_kwargs == {"callback": None}
+    assert len(tx_args) == 1
+    (frame,) = tx_args
+    assert isinstance(frame, AX25DisconnectFrame)
+
+    assert str(frame.header.destination) == "VK4MSL*"
+    assert str(frame.header.source) == "VK4MSL-1"
+    assert str(frame.header.repeaters) == "VK4MSL-2,VK4MSL-3"
+
+
+# DISC UA time-out handling
+
+
+def test_peer_ua_timeout_disconnecting():
+    """
+    Test _on_disc_ua_timeout cleans up the connection if no UA heard
+    from peer after DISC frame.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"),
+        full_duplex=True,
+    )
+
+    peer._state = AX25PeerState.DISCONNECTING
+    peer._modulo = 8
+    peer._ack_timeout_handle = "time-out handle"
+
+    peer._on_disc_ua_timeout()
+
+    assert peer._state is AX25PeerState.DISCONNECTED
+    assert peer._ack_timeout_handle is None
+
+
+def test_peer_ua_timeout_notdisconnecting():
+    """
+    Test _on_disc_ua_timeout does nothing if not disconnecting.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"),
+        full_duplex=True,
+    )
+
+    peer._state = AX25PeerState.CONNECTED
+    peer._ack_timeout_handle = "time-out handle"
+    peer._modulo = 8
+
+    peer._on_disc_ua_timeout()
+
+    assert peer._state is AX25PeerState.CONNECTED
+    assert peer._ack_timeout_handle == "time-out handle"
diff --git a/tests/test_peer/test_dm.py b/tests/test_peer/test_dm.py
new file mode 100644
index 0000000..dd15bd3
--- /dev/null
+++ b/tests/test_peer/test_dm.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+
+"""
+Tests for AX25Peer DM handling
+"""
+
+from aioax25.frame import AX25Address, AX25Path, AX25DisconnectModeFrame
+from aioax25.peer import AX25PeerState
+from aioax25.version import AX25Version
+from .peer import TestingAX25Peer
+from ..mocks import DummyStation, DummyTimeout
+
+
+# DM reception
+
+
+def test_peer_recv_dm():
+    """
+    Test when receiving a DM whilst connected, the peer disconnects.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    interface = station._interface()
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"),
+        full_duplex=True,
+    )
+
+    # Set some dummy data in fields -- this should be cleared out.
+    ack_timer = DummyTimeout(None, None)
+    peer._ack_timeout_handle = ack_timer
+    peer._state = AX25PeerState.CONNECTED
+    peer._modulo = 8
+    peer._send_state = 1
+    peer._send_seq = 2
+    peer._recv_state = 3
+    peer._recv_seq = 4
+    peer._pending_iframes = dict(comment="pending data")
+    peer._pending_data = ["pending data"]
+
+    # Pass the peer a DM frame
+    peer._on_receive(
+        AX25DisconnectModeFrame(
+            destination=station.address, source=peer.address, repeaters=None
+        )
+    )
+
+    # We should now be "disconnected"
+    assert peer._ack_timeout_handle is None
+    assert peer._state is AX25PeerState.DISCONNECTED
+    assert peer._send_state == 0
+    assert peer._send_seq == 0
+    assert peer._recv_state == 0
+    assert peer._recv_seq == 0
+    assert peer._pending_iframes == {}
+    assert peer._pending_data == []
+
+
+def test_peer_recv_dm_disconnected():
+    """
+    Test when receiving a DM whilst not connected, the peer does nothing.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    interface = station._interface()
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"),
+        full_duplex=True,
+    )
+
+    # Set some dummy data in fields -- this should be cleared out.
+    ack_timer = DummyTimeout(None, None)
+    peer._ack_timeout_handle = ack_timer
+    peer._state = AX25PeerState.NEGOTIATING
+    peer._modulo = 8
+    peer._send_state = 1
+    peer._send_seq = 2
+    peer._recv_state = 3
+    peer._recv_seq = 4
+    peer._pending_iframes = dict(comment="pending data")
+    peer._pending_data = ["pending data"]
+
+    # Pass the peer a DM frame
+    peer._on_receive(
+        AX25DisconnectModeFrame(
+            destination=station.address, source=peer.address, repeaters=None
+        )
+    )
+
+    # State should be unchanged from before
+    assert peer._ack_timeout_handle is ack_timer
+    assert peer._state is AX25PeerState.NEGOTIATING
+    assert peer._send_state == 1
+    assert peer._send_seq == 2
+    assert peer._recv_state == 3
+    assert peer._recv_seq == 4
+    assert peer._pending_iframes == dict(comment="pending data")
+    assert peer._pending_data == ["pending data"]
+
+
+# DM transmission
+
+
+def test_peer_send_dm():
+    """
+    Test _send_dm correctly addresses and sends a DM frame.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    interface = station._interface()
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"),
+        full_duplex=True,
+    )
+
+    # Request a DM frame be sent
+    peer._send_dm()
+
+    # There should be a frame sent
+    assert len(interface.transmit_calls) == 1
+    (tx_args, tx_kwargs) = interface.transmit_calls.pop(0)
+
+    # This should be a DM
+    assert tx_kwargs == {"callback": None}
+    assert len(tx_args) == 1
+    (frame,) = tx_args
+    assert isinstance(frame, AX25DisconnectModeFrame)
+
+    assert str(frame.header.destination) == "VK4MSL*"
+    assert str(frame.header.source) == "VK4MSL-1"
+    assert str(frame.header.repeaters) == "VK4MSL-2,VK4MSL-3"
diff --git a/tests/test_peer/test_frmr.py b/tests/test_peer/test_frmr.py
new file mode 100644
index 0000000..3708ae7
--- /dev/null
+++ b/tests/test_peer/test_frmr.py
@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+
+"""
+Tests for FRMR handling
+"""
+
+from pytest import approx
+import weakref
+
+from aioax25.frame import (
+    AX25Address,
+    AX25Path,
+    AX25FrameRejectFrame,
+    AX25SetAsyncBalancedModeFrame,
+    AX25DisconnectFrame,
+    AX25UnnumberedAcknowledgeFrame,
+    AX25TestFrame,
+)
+from aioax25.peer import AX25PeerState
+from ..mocks import DummyPeer, DummyStation
+from .peer import TestingAX25Peer
+
+
+def test_on_receive_frmr_no_handler():
+    """
+    Test that a FRMR frame with no handler sends SABM.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    peer._frmrframe_handler = None
+
+    actions = []
+
+    def _send_sabm():
+        actions.append("sent-sabm")
+
+    peer._send_sabm = _send_sabm
+
+    peer._on_receive(
+        AX25FrameRejectFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZB*"),
+            w=False,
+            x=False,
+            y=False,
+            z=False,
+            vr=0,
+            frmr_cr=False,
+            vs=0,
+            frmr_control=0,
+        )
+    )
+
+    assert actions == ["sent-sabm"]
+
+
+def test_on_receive_frmr_with_handler():
+    """
+    Test that a FRMR frame passes to given FRMR handler.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    frames = []
+
+    def _frmr_handler(frame):
+        frames.append(frame)
+
+    peer._frmrframe_handler = _frmr_handler
+
+    def _send_dm():
+        assert False, "Should not send DM"
+
+    peer._send_dm = _send_dm
+
+    frame = AX25FrameRejectFrame(
+        destination=peer.address,
+        source=station.address,
+        repeaters=AX25Path("VK4RZB*"),
+        w=False,
+        x=False,
+        y=False,
+        z=False,
+        vr=0,
+        frmr_cr=False,
+        vs=0,
+        frmr_control=0,
+    )
+    peer._on_receive(frame)
+
+    assert frames == [frame]
+
+
+# Test handling whilst in FRMR handling mode
+
+
+def test_on_receive_in_frmr_drop_test():
+    """
+    Test _on_receive drops TEST frames when in FRMR state.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    peer._state = AX25PeerState.FRMR
+
+    def _on_receive_test(*a, **kwa):
+        assert False, "Should have ignored frame"
+
+    peer._on_receive_test = _on_receive_test
+
+    peer._on_receive(
+        AX25TestFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZB*"),
+            payload=b"test 1",
+            cr=False,
+        )
+    )
+
+
+def test_on_receive_in_frmr_drop_ua():
+    """
+    Test _on_receive drops UA frames when in FRMR state.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    peer._state = AX25PeerState.FRMR
+
+    def _on_receive_ua(*a, **kwa):
+        assert False, "Should have ignored frame"
+
+    peer._on_receive_ua = _on_receive_ua
+
+    peer._on_receive(
+        AX25UnnumberedAcknowledgeFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZB*"),
+            cr=False,
+        )
+    )
+
+
+def test_on_receive_in_frmr_pass_sabm():
+    """
+    Test _on_receive passes SABM frames when in FRMR state.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    peer._state = AX25PeerState.FRMR
+
+    frames = []
+
+    def _on_receive_sabm(frame):
+        frames.append(frame)
+
+    peer._on_receive_sabm = _on_receive_sabm
+
+    frame = AX25SetAsyncBalancedModeFrame(
+        destination=peer.address,
+        source=station.address,
+        repeaters=AX25Path("VK4RZB*"),
+        cr=False,
+    )
+    peer._on_receive(frame)
+
+    assert frames == [frame]
+
+
+def test_on_receive_in_frmr_pass_disc():
+    """
+    Test _on_receive passes DISC frames when in FRMR state.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    peer._state = AX25PeerState.FRMR
+
+    events = []
+
+    def _on_receive_disc():
+        events.append("disc")
+
+    peer._on_receive_disc = _on_receive_disc
+
+    peer._on_receive(
+        AX25DisconnectFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZB*"),
+            cr=False,
+        )
+    )
+
+    assert events == ["disc"]
diff --git a/tests/test_peer/test_peerconnection.py b/tests/test_peer/test_peerconnection.py
new file mode 100644
index 0000000..f9a7e09
--- /dev/null
+++ b/tests/test_peer/test_peerconnection.py
@@ -0,0 +1,495 @@
+#!/usr/bin/env python3
+
+"""
+Tests for AX25PeerConnectionHandler
+"""
+
+from aioax25.version import AX25Version
+from aioax25.peer import AX25PeerConnectionHandler
+from aioax25.frame import AX25Address, AX25Path
+from .peer import TestingAX25Peer
+from ..mocks import DummyPeer, DummyStation
+
+
+def test_peerconn_go():
+    """
+    Test _go triggers negotiation if the peer has not yet done so.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+
+    # Start it off
+    helper._go()
+
+    # We should hand off to the negotiation handler, so no timeout started yet:
+    assert helper._timeout_handle is None
+
+    # There should be a call to negotiate, with a call-back pointing here.
+    assert peer._negotiate_calls == [helper._on_negotiated]
+
+
+def test_peerconn_go_peer_ax20_stn():
+    """
+    Test _go skips negotiation for AX.25 2.0 stations.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    station._protocol = AX25Version.AX25_20
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+
+    # Start it off
+    helper._go()
+
+    # Check the time-out timer is started
+    assert helper._timeout_handle is not None
+    assert helper._timeout_handle.delay == 0.1
+
+    # Helper should have hooked the handler events
+    assert peer._uaframe_handler == helper._on_receive_ua
+    assert peer._frmrframe_handler == helper._on_receive_frmr
+    assert peer._dmframe_handler == helper._on_receive_dm
+
+    # Station should have been asked to send a SABM
+    assert len(peer.transmit_calls) == 1
+    (frame, callback) = peer.transmit_calls.pop(0)
+
+    # Frame should be a SABM frame
+    assert frame == "sabm"
+    assert callback is None
+
+
+def test_peerconn_go_peer_ax20_peer():
+    """
+    Test _go skips negotiation for AX.25 2.0 peers.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    peer._protocol = AX25Version.AX25_20
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+
+    # Start it off
+    helper._go()
+
+    # Check the time-out timer is started
+    assert helper._timeout_handle is not None
+    assert helper._timeout_handle.delay == 0.1
+
+    # Helper should have hooked the handler events
+    assert peer._uaframe_handler == helper._on_receive_ua
+    assert peer._frmrframe_handler == helper._on_receive_frmr
+    assert peer._dmframe_handler == helper._on_receive_dm
+
+    # Station should have been asked to send a SABM
+    assert len(peer.transmit_calls) == 1
+    (frame, callback) = peer.transmit_calls.pop(0)
+
+    # Frame should be a SABM frame
+    assert frame == "sabm"
+    assert callback is None
+
+
+def test_peerconn_go_prenegotiated():
+    """
+    Test _go skips negotiation if already completed.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Pretend we've done negotiation
+    peer._negotiated = True
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+
+    # Start it off
+    helper._go()
+
+    # Check the time-out timer is started
+    assert helper._timeout_handle is not None
+    assert helper._timeout_handle.delay == 0.1
+
+    # Helper should have hooked the handler events
+    assert peer._uaframe_handler == helper._on_receive_ua
+    assert peer._frmrframe_handler == helper._on_receive_frmr
+    assert peer._dmframe_handler == helper._on_receive_dm
+
+    # Station should have been asked to send a SABM
+    assert len(peer.transmit_calls) == 1
+    (frame, callback) = peer.transmit_calls.pop(0)
+
+    # Frame should be a SABM frame
+    assert frame == "sabm"
+    assert callback is None
+
+
+def test_peerconn_on_negotiated_failed():
+    """
+    Test _on_negotiated winds up the request if negotiation fails.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Try to connect
+    helper._on_negotiated("whoopsie")
+    assert done_evts == [{"response": "whoopsie"}]
+
+
+def test_peerconn_on_negotiated_uaframe_handler():
+    """
+    Test _on_negotiated refuses to run if another UA frame handler is hooked.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Hook the UA handler
+    peer._uaframe_handler = lambda *a, **kwa: None
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Try to connect
+    helper._on_negotiated("xid")
+    assert done_evts == [{"response": "station_busy"}]
+
+
+def test_peerconn_on_negotiated_frmrframe_handler():
+    """
+    Test _on_negotiated refuses to run if another FRMR frame handler is hooked.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Hook the FRMR handler
+    peer._frmrframe_handler = lambda *a, **kwa: None
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Try to connect
+    helper._on_negotiated("xid")
+    assert done_evts == [{"response": "station_busy"}]
+
+
+def test_peerconn_on_negotiated_dmframe_handler():
+    """
+    Test _on_negotiated refuses to run if another DM frame handler is hooked.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Hook the DM handler
+    peer._dmframe_handler = lambda *a, **kwa: None
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Try to connect
+    helper._on_negotiated("xid")
+    assert done_evts == [{"response": "station_busy"}]
+
+
+def test_peerconn_on_negotiated_xid():
+    """
+    Test _on_negotiated triggers SABM transmission on receipt of XID
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Try to connect
+    helper._on_negotiated("xid")
+
+    # Helper should not be done
+    assert not helper._done
+
+    # Helper should be hooked
+    assert peer._uaframe_handler == helper._on_receive_ua
+    assert peer._frmrframe_handler == helper._on_receive_frmr
+    assert peer._dmframe_handler == helper._on_receive_dm
+
+    # Station should have been asked to send a SABM
+    assert len(peer.transmit_calls) == 1
+    (frame, callback) = peer.transmit_calls.pop(0)
+
+    # Frame should be a SABM frame
+    assert frame == "sabm"
+    assert callback is None
+
+
+def test_peerconn_receive_ua():
+    """
+    Test _on_receive_ua marks the SABM as ACKed
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+
+    # Call _on_receive_ua
+    helper._on_receive_ua()
+
+    # We should be connected
+    assert helper._done is True
+    assert done_evts == [{"response": "ack"}]
+
+
+def test_peerconn_receive_frmr():
+    """
+    Test _on_receive_frmr ends the helper
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Call _on_receive_frmr
+    helper._on_receive_frmr()
+
+    # See that the helper finished
+    assert helper._done is True
+    assert done_evts == [{"response": "frmr"}]
+
+    # Station should have been asked to send a DM
+    assert len(peer.transmit_calls) == 1
+    (frame, callback) = peer.transmit_calls.pop(0)
+
+    # Frame should be a DM frame
+    assert frame == "dm"
+    assert callback is None
+
+
+def test_peerconn_receive_dm():
+    """
+    Test _on_receive_dm ends the helper
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Call _on_receive_frmr
+    helper._on_receive_dm()
+
+    # See that the helper finished
+    assert helper._done is True
+    assert done_evts == [{"response": "dm"}]
+
+
+def test_peerconn_on_timeout_first():
+    """
+    Test _on_timeout retries if there are retries left
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # We should have retries left
+    assert helper._retries == 2
+
+    # Call the time-out handler
+    helper._on_timeout()
+
+    # Check the time-out timer is re-started
+    assert helper._timeout_handle is not None
+    assert helper._timeout_handle.delay == 0.1
+
+    # There should now be fewer retries left
+    assert helper._retries == 1
+
+    # Helper should have hooked the handler events
+    assert peer._uaframe_handler == helper._on_receive_ua
+    assert peer._frmrframe_handler == helper._on_receive_frmr
+    assert peer._dmframe_handler == helper._on_receive_dm
+
+    # Station should have been asked to send an XID
+    assert len(peer.transmit_calls) == 1
+    (frame, callback) = peer.transmit_calls.pop(0)
+
+    # Frame should be a SABM frame
+    assert frame == "sabm"
+    assert callback is None
+
+
+def test_peerconn_on_timeout_last():
+    """
+    Test _on_timeout finishes the helper if retries exhausted
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Pretend there are no more retries left
+    helper._retries = 0
+
+    # Pretend we're hooked up
+    peer._uaframe_handler = helper._on_receive_ua
+    peer._frmrframe_handler = helper._on_receive_frmr
+    peer._dmframe_handler = helper._on_receive_dm
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Call the time-out handler
+    helper._on_timeout()
+
+    # Check the time-out timer is not re-started
+    assert helper._timeout_handle is None
+
+    # Helper should have unhooked the handler events
+    assert peer._uaframe_handler is None
+    assert peer._frmrframe_handler is None
+    assert peer._dmframe_handler is None
+
+    # Station should not have been asked to send anything
+    assert len(peer.transmit_calls) == 0
+
+    # See that the helper finished
+    assert helper._done is True
+    assert done_evts == [{"response": "timeout"}]
+
+
+def test_peerconn_finish_disconnect_ua():
+    """
+    Test _finish leaves other UA hooks intact
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Pretend we're hooked up
+    dummy_uaframe_handler = lambda *a, **kw: None
+    peer._uaframe_handler = dummy_uaframe_handler
+    peer._frmrframe_handler = helper._on_receive_frmr
+    peer._dmframe_handler = helper._on_receive_dm
+
+    # Call the finish routine
+    helper._finish()
+
+    # All except UA (which is not ours) should be disconnected
+    assert peer._uaframe_handler == dummy_uaframe_handler
+    assert peer._frmrframe_handler is None
+    assert peer._dmframe_handler is None
+
+
+def test_peerconn_finish_disconnect_frmr():
+    """
+    Test _finish leaves other FRMR hooks intact
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Pretend we're hooked up
+    dummy_frmrframe_handler = lambda *a, **kw: None
+    peer._uaframe_handler = helper._on_receive_ua
+    peer._frmrframe_handler = dummy_frmrframe_handler
+    peer._dmframe_handler = helper._on_receive_dm
+
+    # Call the finish routine
+    helper._finish()
+
+    # All except FRMR (which is not ours) should be disconnected
+    assert peer._uaframe_handler is None
+    assert peer._frmrframe_handler == dummy_frmrframe_handler
+    assert peer._dmframe_handler is None
+
+
+def test_peerconn_finish_disconnect_dm():
+    """
+    Test _finish leaves other DM hooks intact
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerConnectionHandler(peer)
+
+    # Pretend we're hooked up
+    dummy_dmframe_handler = lambda *a, **kw: None
+    peer._uaframe_handler = helper._on_receive_ua
+    peer._frmrframe_handler = helper._on_receive_frmr
+    peer._dmframe_handler = dummy_dmframe_handler
+
+    # Call the finish routine
+    helper._finish()
+
+    # All except DM (which is not ours) should be disconnected
+    assert peer._uaframe_handler is None
+    assert peer._frmrframe_handler is None
+    assert peer._dmframe_handler == dummy_dmframe_handler
diff --git a/tests/test_peer/test_peerhelper.py b/tests/test_peer/test_peerhelper.py
new file mode 100644
index 0000000..7063056
--- /dev/null
+++ b/tests/test_peer/test_peerhelper.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+
+"""
+Tests for AX25PeerHelper
+"""
+
+from aioax25.peer import AX25PeerHelper
+from aioax25.frame import AX25Address
+from ..mocks import DummyPeer, DummyStation
+
+
+def test_peerhelper_start_timer():
+    """
+    Test _start_timer sets up a timeout timer.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+
+    class TestHelper(AX25PeerHelper):
+        def _on_timeout(self):
+            pass
+
+    helper = TestHelper(peer, timeout=0.1)
+
+    assert helper._timeout_handle is None
+
+    helper._start_timer()
+    assert len(peer._loop.call_later_list) == 1
+    timeout = peer._loop.call_later_list.pop(0)
+
+    assert timeout is helper._timeout_handle
+    assert timeout.delay == 0.1
+    assert timeout.callback == helper._on_timeout
+    assert timeout.args == ()
+    assert timeout.kwargs == {}
+
+
+def test_peerhelper_stop_timer():
+    """
+    Test _stop_timer clears an existing timeout timer.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerHelper(peer, timeout=0.1)
+
+    # Inject a timeout timer
+    timeout = peer._loop.call_later(0, lambda: None)
+    helper._timeout_handle = timeout
+    assert not timeout.cancelled
+
+    helper._stop_timer()
+
+    assert timeout.cancelled
+    assert helper._timeout_handle is None
+
+
+def test_peerhelper_stop_timer_cancelled():
+    """
+    Test _stop_timer does not call cancel on already cancelled timer.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerHelper(peer, timeout=0.1)
+
+    # Inject a timeout timer
+    timeout = peer._loop.call_later(0, lambda: None)
+    helper._timeout_handle = timeout
+
+    # Cancel it
+    timeout.cancel()
+
+    # Now stop the timer, this should not call .cancel itself
+    helper._stop_timer()
+    assert helper._timeout_handle is None
+
+
+def test_peerhelper_stop_timer_absent():
+    """
+    Test _stop_timer does nothing if time-out object absent.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerHelper(peer, timeout=0.1)
+
+    # Cancel the non-existent timer, this should not trigger errors
+    helper._stop_timer()
+
+
+def test_finish():
+    """
+    Test _finish stops the timer and emits the done signal.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerHelper(peer, timeout=0.1)
+    assert not helper._done
+
+    # Hook the done signal
+    done_events = []
+    helper.done_sig.connect(lambda **kw: done_events.append(kw))
+
+    # Inject a timeout timer
+    timeout = peer._loop.call_later(0, lambda: None)
+    helper._timeout_handle = timeout
+
+    # Call _finish to end the helper
+    helper._finish(arg1="abc", arg2=123)
+
+    # Task should be done
+    assert helper._done
+
+    # Signal should have fired
+    assert done_events == [{"arg1": "abc", "arg2": 123}]
+
+    # Timeout should have been cancelled
+    assert timeout.cancelled
+
+
+def test_finish_repeat():
+    """
+    Test _finish does nothing if already "done"
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerHelper(peer, timeout=0.1)
+
+    # Force the done flag.
+    helper._done = True
+
+    # Hook the done signal
+    done_events = []
+    helper.done_sig.connect(lambda **kw: done_events.append(kw))
+
+    # Inject a timeout timer
+    timeout = peer._loop.call_later(0, lambda: None)
+    helper._timeout_handle = timeout
+
+    # Call _finish to end the helper
+    helper._finish(arg1="abc", arg2=123)
+
+    # Signal should not have fired
+    assert done_events == []
+
+    # Timeout should not have been cancelled
+    assert not timeout.cancelled
diff --git a/tests/test_peer/test_peernegotiation.py b/tests/test_peer/test_peernegotiation.py
new file mode 100644
index 0000000..69b62c4
--- /dev/null
+++ b/tests/test_peer/test_peernegotiation.py
@@ -0,0 +1,341 @@
+#!/usr/bin/env python3
+
+"""
+Tests for AX25PeerNegotiationHandler
+"""
+
+from aioax25.peer import AX25PeerNegotiationHandler
+from aioax25.frame import AX25Address
+from ..mocks import DummyPeer, DummyStation
+
+
+def test_peerneg_go():
+    """
+    Test _go transmits a test frame with CR=True and starts a timer.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Start it off
+    helper._go()
+    assert helper._timeout_handle is not None
+    assert helper._timeout_handle.delay == 0.1
+
+    # Helper should have hooked the handler events
+    assert peer._xidframe_handler == helper._on_receive_xid
+    assert peer._frmrframe_handler == helper._on_receive_frmr
+    assert peer._dmframe_handler == helper._on_receive_dm
+
+    # Station should have been asked to send an XID
+    assert len(peer.transmit_calls) == 1
+    (frame, callback) = peer.transmit_calls.pop(0)
+
+    # Frame should be a test frame, with CR=True
+    assert frame == "xid:cr=True"
+    assert callback is None
+
+
+def test_peerneg_go_xidframe_handler():
+    """
+    Test _go refuses to run if another XID frame handler is hooked.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Hook the XID handler
+    peer._xidframe_handler = lambda *a, **kwa: None
+
+    # Try to start it off
+    try:
+        helper._go()
+        assert False, "Should not have worked"
+    except RuntimeError as e:
+        if str(e) != "Another frame handler is busy":
+            raise
+
+
+def test_peerneg_go_frmrframe_handler():
+    """
+    Test _go refuses to run if another FRMR frame handler is hooked.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Hook the FRMR handler
+    peer._frmrframe_handler = lambda *a, **kwa: None
+
+    # Try to start it off
+    try:
+        helper._go()
+        assert False, "Should not have worked"
+    except RuntimeError as e:
+        if str(e) != "Another frame handler is busy":
+            raise
+
+
+def test_peerneg_go_dmframe_handler():
+    """
+    Test _go refuses to run if another DM frame handler is hooked.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Hook the DM handler
+    peer._dmframe_handler = lambda *a, **kwa: None
+
+    # Try to start it off
+    try:
+        helper._go()
+        assert False, "Should not have worked"
+    except RuntimeError as e:
+        if str(e) != "Another frame handler is busy":
+            raise
+
+
+def test_peerneg_receive_xid():
+    """
+    Test _on_receive_xid ends the helper
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Call _on_receive_xid
+    helper._on_receive_xid()
+
+    # See that the helper finished
+    assert helper._done is True
+    assert done_evts == [{"response": "xid"}]
+
+
+def test_peerneg_receive_frmr():
+    """
+    Test _on_receive_frmr ends the helper
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Call _on_receive_frmr
+    helper._on_receive_frmr()
+
+    # See that the helper finished
+    assert helper._done is True
+    assert done_evts == [{"response": "frmr"}]
+
+
+def test_peerneg_receive_dm():
+    """
+    Test _on_receive_dm ends the helper
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Call _on_receive_frmr
+    helper._on_receive_dm()
+
+    # See that the helper finished
+    assert helper._done is True
+    assert done_evts == [{"response": "dm"}]
+
+
+def test_peerneg_on_timeout_first():
+    """
+    Test _on_timeout retries if there are retries left
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # We should have retries left
+    assert helper._retries == 2
+
+    # Call the time-out handler
+    helper._on_timeout()
+
+    # Check the time-out timer is re-started
+    assert helper._timeout_handle is not None
+    assert helper._timeout_handle.delay == 0.1
+
+    # There should now be fewer retries left
+    assert helper._retries == 1
+
+    # Helper should have hooked the handler events
+    assert peer._xidframe_handler == helper._on_receive_xid
+    assert peer._frmrframe_handler == helper._on_receive_frmr
+    assert peer._dmframe_handler == helper._on_receive_dm
+
+    # Station should have been asked to send an XID
+    assert len(peer.transmit_calls) == 1
+    (frame, callback) = peer.transmit_calls.pop(0)
+
+    # Frame should be a test frame, with CR=True
+    assert frame == "xid:cr=True"
+    assert callback is None
+
+
+def test_peerneg_on_timeout_last():
+    """
+    Test _on_timeout finishes the helper if retries exhausted
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Pretend there are no more retries left
+    helper._retries = 0
+
+    # Pretend we're hooked up
+    peer._xidframe_handler = helper._on_receive_xid
+    peer._frmrframe_handler = helper._on_receive_frmr
+    peer._dmframe_handler = helper._on_receive_dm
+
+    # Hook the done signal
+    done_evts = []
+    helper.done_sig.connect(lambda **kw: done_evts.append(kw))
+
+    # Call the time-out handler
+    helper._on_timeout()
+
+    # Check the time-out timer is not re-started
+    assert helper._timeout_handle is None
+
+    # Helper should have hooked the handler events
+    assert peer._xidframe_handler is None
+    assert peer._frmrframe_handler is None
+    assert peer._dmframe_handler is None
+
+    # Station should not have been asked to send anything
+    assert len(peer.transmit_calls) == 0
+
+    # See that the helper finished
+    assert helper._done is True
+    assert done_evts == [{"response": "timeout"}]
+
+
+def test_peerneg_finish_disconnect_xid():
+    """
+    Test _finish leaves other XID hooks intact
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Pretend we're hooked up
+    dummy_xidframe_handler = lambda *a, **kw: None
+    peer._xidframe_handler = dummy_xidframe_handler
+    peer._frmrframe_handler = helper._on_receive_frmr
+    peer._dmframe_handler = helper._on_receive_dm
+
+    # Call the finish routine
+    helper._finish()
+
+    # All except XID (which is not ours) should be disconnected
+    assert peer._xidframe_handler == dummy_xidframe_handler
+    assert peer._frmrframe_handler is None
+    assert peer._dmframe_handler is None
+
+
+def test_peerneg_finish_disconnect_frmr():
+    """
+    Test _finish leaves other FRMR hooks intact
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Pretend we're hooked up
+    dummy_frmrframe_handler = lambda *a, **kw: None
+    peer._xidframe_handler = helper._on_receive_xid
+    peer._frmrframe_handler = dummy_frmrframe_handler
+    peer._dmframe_handler = helper._on_receive_dm
+
+    # Call the finish routine
+    helper._finish()
+
+    # All except XID (which is not ours) should be disconnected
+    assert peer._xidframe_handler is None
+    assert peer._frmrframe_handler == dummy_frmrframe_handler
+    assert peer._dmframe_handler is None
+
+
+def test_peerneg_finish_disconnect_dm():
+    """
+    Test _finish leaves other DM hooks intact
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerNegotiationHandler(peer)
+
+    # Pretend we're hooked up
+    dummy_dmframe_handler = lambda *a, **kw: None
+    peer._xidframe_handler = helper._on_receive_xid
+    peer._frmrframe_handler = helper._on_receive_frmr
+    peer._dmframe_handler = dummy_dmframe_handler
+
+    # Call the finish routine
+    helper._finish()
+
+    # All except XID (which is not ours) should be disconnected
+    assert peer._xidframe_handler is None
+    assert peer._frmrframe_handler is None
+    assert peer._dmframe_handler == dummy_dmframe_handler
diff --git a/tests/test_peer/test_peertest.py b/tests/test_peer/test_peertest.py
new file mode 100644
index 0000000..b603954
--- /dev/null
+++ b/tests/test_peer/test_peertest.py
@@ -0,0 +1,470 @@
+#!/usr/bin/env python3
+
+"""
+Tests for AX25PeerTestHandler
+"""
+
+from pytest import approx
+import weakref
+
+from aioax25.peer import AX25PeerTestHandler
+from aioax25.frame import AX25Address, AX25TestFrame, AX25Path
+from ..mocks import DummyPeer, DummyStation
+from .peer import TestingAX25Peer
+
+
+def test_peertest_go():
+    """
+    Test _go transmits a test frame with CR=True and starts a timer.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerTestHandler(peer, payload=b"test", timeout=0.1)
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Start it off
+    helper._go()
+    assert helper._timeout_handle is not None
+    assert helper._timeout_handle.delay == 0.1
+
+    assert len(peer.transmit_calls) == 1
+    (frame, callback) = peer.transmit_calls.pop(0)
+
+    # Frame should be a test frame, with CR=True
+    assert frame is helper.tx_frame
+    assert isinstance(frame, AX25TestFrame)
+    assert frame.header.cr
+
+    # Callback should be the _transmit_done method
+    assert callback == helper._transmit_done
+
+    # We should be registered to receive the reply
+    assert peer._testframe_handler is not None
+    assert isinstance(peer._testframe_handler, weakref.ref)
+    assert peer._testframe_handler() is helper
+
+
+def test_peertest_go_pending():
+    """
+    Test _go refuses to start if another test frame is pending.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerTestHandler(peer, payload=b"test", timeout=0.1)
+
+    # Inject a different helper
+    peer._testframe_handler = AX25PeerTestHandler(
+        peer, payload=b"test", timeout=0.2
+    )
+
+    # Nothing should be set up
+    assert helper._timeout_handle is None
+    assert not helper._done
+    assert peer.transmit_calls == []
+
+    # Start it off
+    try:
+        helper._go()
+        assert False, "Should not have worked"
+    except RuntimeError as e:
+        if str(e) != "Test frame already pending":
+            raise
+
+
+def test_peertest_transmit_done():
+    """
+    Test _transmit_done records time of transmission.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerTestHandler(peer, payload=b"test", timeout=0.1)
+
+    assert helper.tx_time is None
+    helper._transmit_done()
+    assert helper.tx_time is not None
+
+    assert approx(peer._loop.time()) == helper.tx_time
+
+
+def test_peertest_on_receive():
+    """
+    Test _on_receive records time of reception and finishes the helper.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerTestHandler(peer, payload=b"test", timeout=0.1)
+
+    # Hook the "done" event
+    done_events = []
+    helper.done_sig.connect(lambda **kw: done_events.append(kw))
+
+    assert helper.rx_time is None
+    helper._on_receive(frame="Make believe TEST frame")
+    assert helper.rx_time is not None
+
+    assert approx(peer._loop.time()) == helper.rx_time
+    assert helper.rx_frame == "Make believe TEST frame"
+
+    # We should be done now
+    assert len(done_events) == 1
+    done_evt = done_events.pop()
+    assert list(done_evt.keys()) == ["handler"]
+    assert done_evt["handler"] is helper
+
+
+def test_peertest_on_receive_done():
+    """
+    Test _on_receive ignores packets once done.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerTestHandler(peer, payload=b"test", timeout=0.1)
+
+    # Mark as done
+    helper._done = True
+
+    # Hook the "done" event
+    done_events = []
+    helper.done_sig.connect(lambda **kw: done_events.append(kw))
+
+    assert helper.rx_time is None
+    helper._on_receive(frame="Make believe TEST frame")
+
+    assert helper.rx_time is None
+    assert helper.rx_frame is None
+    assert len(done_events) == 0
+
+
+def test_peertest_on_timeout():
+    """
+    Test _on_timeout winds up the handler
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = DummyPeer(station, AX25Address("VK4MSL"))
+    helper = AX25PeerTestHandler(peer, payload=b"test", timeout=0.1)
+
+    # Hook the "done" event
+    done_events = []
+    helper.done_sig.connect(lambda **kw: done_events.append(kw))
+
+    helper._on_timeout()
+
+    # We should be done now
+    assert len(done_events) == 1
+    done_evt = done_events.pop()
+    assert list(done_evt.keys()) == ["handler"]
+    assert done_evt["handler"] is helper
+
+
+# Integration into AX25Peer
+
+
+def test_peer_ping():
+    """
+    Test that calling peer.ping() sets up a AX25PeerTestHandler
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the peer's _transmit_frame method
+    tx_frames = []
+
+    def _transmit_frame(frame, callback):
+        tx_frames.append(frame)
+        callback()
+
+    peer._transmit_frame = _transmit_frame
+
+    # Send a ping request
+    handler = peer.ping()
+
+    # We should have a reference to the handler
+    assert isinstance(handler, AX25PeerTestHandler)
+
+    # Handler should have sent a frame with an empty payload
+    assert len(tx_frames) == 1
+    assert isinstance(tx_frames[0], AX25TestFrame)
+    assert tx_frames[0].payload == b""
+
+
+def test_peer_ping_payload():
+    """
+    Test that we can supply a payload to the ping request
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the peer's _transmit_frame method
+    tx_frames = []
+
+    def _transmit_frame(frame, callback):
+        tx_frames.append(frame)
+        callback()
+
+    peer._transmit_frame = _transmit_frame
+
+    # Send a ping request
+    handler = peer.ping(payload=b"testing")
+
+    # We should have a reference to the handler
+    assert isinstance(handler, AX25PeerTestHandler)
+
+    # Handler should have sent a frame with an empty payload
+    assert len(tx_frames) == 1
+    assert isinstance(tx_frames[0], AX25TestFrame)
+    assert tx_frames[0].payload == b"testing"
+
+
+def test_peer_ping_cb():
+    """
+    Test that peer.ping() attaches callback if given
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the peer's _transmit_frame method
+    tx_frames = []
+
+    def _transmit_frame(frame, callback):
+        tx_frames.append(frame)
+        callback()
+
+    peer._transmit_frame = _transmit_frame
+
+    # Create a callback routine
+    cb_args = []
+
+    def _callback(*args, **kwargs):
+        cb_args.append((args, kwargs))
+
+    # Send a ping request
+    handler = peer.ping(callback=_callback)
+
+    # We should have a reference to the handler
+    assert isinstance(handler, AX25PeerTestHandler)
+
+    # Pass a reply to the handler to trigger completion
+    handler._on_receive(frame=b"test")
+
+    # Our callback should have been called on completion
+    assert cb_args == [((), {"handler": handler})]
+
+
+# Handling of incoming TEST frame
+#
+# The AX25Station class itself will respond to TEST frames
+# where the CR bit is set to True.  AX25Peer only handles
+# the case where CR is set to False.
+
+
+def test_on_receive_test_no_handler():
+    """
+    Test that a TEST frame with no handler does nothing.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    peer._testframe_handler = None
+
+    peer._on_receive(
+        AX25TestFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZB*"),
+            payload=b"test 1",
+            cr=False,
+        )
+    )
+
+
+def test_on_receive_test_stale_handler():
+    """
+    Test that a TEST frame with stale handler cleans up reference.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    class DummyHandler:
+        pass
+
+    handler = DummyHandler()
+    peer._testframe_handler = weakref.ref(handler)
+
+    # Now destroy the new handler
+    del handler
+    assert peer._testframe_handler is not None
+    assert peer._testframe_handler() is None
+
+    # See what it does
+    peer._on_receive(
+        AX25TestFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZB*"),
+            payload=b"test 1",
+            cr=False,
+        )
+    )
+
+    assert peer._testframe_handler is None
+
+
+def test_on_receive_test_valid_handler():
+    """
+    Test that a TEST frame with valid handler pass on frame.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    class DummyHandler:
+        def __init__(self):
+            self.frames = []
+
+        def _on_receive(self, frame):
+            self.frames.append(frame)
+
+    handler = DummyHandler()
+    peer._testframe_handler = weakref.ref(handler)
+
+    # See what it does
+    frame = AX25TestFrame(
+        destination=peer.address,
+        source=station.address,
+        repeaters=AX25Path("VK4RZB*"),
+        payload=b"test 1",
+        cr=False,
+    )
+    peer._on_receive(frame)
+
+    assert handler.frames == [frame]
+
+
+def test_on_test_done_no_handler():
+    """
+    Test that a TEST frame with no handler does nothing.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    peer._testframe_handler = None
+
+    class DummyHandler:
+        pass
+
+    peer._on_test_done(handler=DummyHandler())
+
+
+def test_on_test_done_stale_handler():
+    """
+    Test that a TEST frame with stale handler cleans up reference.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    class DummyHandler:
+        pass
+
+    handler = DummyHandler()
+    peer._testframe_handler = weakref.ref(handler)
+
+    # Now destroy the new handler
+    del handler
+    assert peer._testframe_handler is not None
+    assert peer._testframe_handler() is None
+
+    # See what it does
+    peer._on_test_done(handler=DummyHandler())
+
+    assert peer._testframe_handler is None
+
+
+def test_on_test_done_wrong_handler():
+    """
+    Test that a TEST frame with wrong handler ignores signal.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    class DummyHandler:
+        pass
+
+    handler = DummyHandler()
+    peer._testframe_handler = weakref.ref(handler)
+
+    # See what it does with the wrong handler reference
+    peer._on_test_done(handler=DummyHandler())
+
+    assert peer._testframe_handler() is handler
+
+
+def test_on_test_done_valid_handler():
+    """
+    Test that a TEST frame with valid handler pass on frame.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    class DummyHandler:
+        pass
+
+    handler = DummyHandler()
+    peer._testframe_handler = weakref.ref(handler)
+
+    # See what it does
+    peer._on_test_done(handler=handler)
+
+    assert peer._testframe_handler is None
diff --git a/tests/test_peer/test_replypath.py b/tests/test_peer/test_replypath.py
new file mode 100644
index 0000000..65fd4d2
--- /dev/null
+++ b/tests/test_peer/test_replypath.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+
+"""
+Tests for AX25Peer reply path handling
+"""
+
+from aioax25.frame import AX25Address, AX25Path
+from .peer import TestingAX25Peer
+from ..mocks import DummyStation
+
+
+def test_peer_reply_path_locked():
+    """
+    Test reply_path with a locked path returns the repeaters given
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Ensure not pre-determined path is set
+    peer._reply_path = None
+
+    assert str(peer.reply_path) == "VK4RZB"
+
+
+def test_peer_reply_path_predetermined():
+    """
+    Test reply_path with pre-determined path returns the chosen path
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        locked_path=False,
+    )
+
+    # Inject pre-determined path
+    peer._reply_path = AX25Path("VK4RZB")
+
+    assert str(peer.reply_path) == "VK4RZB"
+
+
+def test_peer_reply_path_weight_score():
+    """
+    Test reply_path tries to select the "best" scoring path.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        locked_path=False,
+    )
+
+    # Ensure not pre-determined path is set
+    peer._reply_path = None
+
+    # Inject path scores
+    peer._tx_path_score = {AX25Path("VK4RZB"): 2, AX25Path("VK4RZA"): 1}
+
+    assert str(peer.reply_path) == "VK4RZB"
+
+    # We should also use this from now on:
+    assert str(peer._reply_path) == "VK4RZB"
+
+
+def test_peer_reply_path_rx_count():
+    """
+    Test reply_path considers received paths if no rated TX path.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        locked_path=False,
+    )
+
+    # Ensure not pre-determined path is set
+    peer._reply_path = None
+
+    # Ensure empty TX path scores
+    peer._tx_path_score = {}
+
+    # Inject path counts
+    peer._rx_path_count = {AX25Path("VK4RZB"): 2, AX25Path("VK4RZA"): 1}
+
+    assert str(peer.reply_path) == "VK4RZB"
+
+    # We should also use this from now on:
+    assert str(peer._reply_path) == "VK4RZB"
+
+
+# Path weighting
+
+
+def test_weight_path_absolute():
+    """
+    Test we can set the score for a given path.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        locked_path=False,
+    )
+
+    # Ensure known weights
+    peer._tx_path_score = {
+        tuple(AX25Path("VK4RZB", "VK4RZA")): 1,
+        tuple(AX25Path("VK4RZA")): 2,
+    }
+
+    # Rate a few paths
+    peer.weight_path(AX25Path("VK4RZB*", "VK4RZA*"), 5, relative=False)
+    peer.weight_path(AX25Path("VK4RZA*"), 3, relative=False)
+
+    assert peer._tx_path_score == {
+        tuple(AX25Path("VK4RZB", "VK4RZA")): 5,
+        tuple(AX25Path("VK4RZA")): 3,
+    }
+
+
+def test_weight_path_relative():
+    """
+    Test we can increment the score for a given path.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        locked_path=False,
+    )
+
+    # Ensure known weights
+    peer._tx_path_score = {
+        tuple(AX25Path("VK4RZB", "VK4RZA")): 5,
+        tuple(AX25Path("VK4RZA")): 3,
+    }
+
+    # Rate a few paths
+    peer.weight_path(AX25Path("VK4RZB*", "VK4RZA*"), 2, relative=True)
+    peer.weight_path(AX25Path("VK4RZA*"), 1, relative=True)
+
+    assert peer._tx_path_score == {
+        tuple(AX25Path("VK4RZB", "VK4RZA")): 7,
+        tuple(AX25Path("VK4RZA")): 4,
+    }
diff --git a/tests/test_peer/test_rx_path.py b/tests/test_peer/test_rx_path.py
new file mode 100644
index 0000000..f3d9b0f
--- /dev/null
+++ b/tests/test_peer/test_rx_path.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+
+"""
+Tests for receive path handling
+"""
+
+from aioax25.frame import AX25Address, AX25TestFrame, AX25Path
+from ..mocks import DummyStation
+from .peer import TestingAX25Peer
+
+
+def test_rx_path_stats_unlocked():
+    """
+    Test that incoming message paths are counted when path NOT locked.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=False,
+    )
+
+    # Stub the peer's _on_receive_test method
+    rx_frames = []
+
+    def _on_receive_test(frame):
+        rx_frames.append(frame)
+
+    peer._on_receive_test = _on_receive_test
+
+    # Send a few test frames via different paths
+    peer._on_receive(
+        AX25TestFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZB*"),
+            payload=b"test 1",
+            cr=True,
+        )
+    )
+    peer._on_receive(
+        AX25TestFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZA*", "VK4RZB*"),
+            payload=b"test 2",
+            cr=True,
+        )
+    )
+    peer._on_receive(
+        AX25TestFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZD*", "VK4RZB*"),
+            payload=b"test 3",
+            cr=True,
+        )
+    )
+    peer._on_receive(
+        AX25TestFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZB*"),
+            payload=b"test 4",
+            cr=True,
+        )
+    )
+
+    # For test readability, convert the tuple keys to strings
+    # AX25Path et all has its own tests for str.
+    rx_path_count = dict(
+        [
+            (str(AX25Path(*path)), count)
+            for path, count in peer._rx_path_count.items()
+        ]
+    )
+
+    assert rx_path_count == {
+        "VK4RZB": 2,
+        "VK4RZA,VK4RZB": 1,
+        "VK4RZD,VK4RZB": 1,
+    }
+
+
+def test_rx_path_stats_locked():
+    """
+    Test that incoming message paths are NOT counted when path locked.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    # Stub the peer's _on_receive_test method
+    rx_frames = []
+
+    def _on_receive_test(frame):
+        rx_frames.append(frame)
+
+    peer._on_receive_test = _on_receive_test
+
+    # Send a few test frames via different paths
+    peer._on_receive(
+        AX25TestFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZB*"),
+            payload=b"test 1",
+            cr=True,
+        )
+    )
+    peer._on_receive(
+        AX25TestFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZA*", "VK4RZB*"),
+            payload=b"test 2",
+            cr=True,
+        )
+    )
+    peer._on_receive(
+        AX25TestFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZD*", "VK4RZB*"),
+            payload=b"test 3",
+            cr=True,
+        )
+    )
+    peer._on_receive(
+        AX25TestFrame(
+            destination=peer.address,
+            source=station.address,
+            repeaters=AX25Path("VK4RZB*"),
+            payload=b"test 4",
+            cr=True,
+        )
+    )
+
+    # For test readability, convert the tuple keys to strings
+    # AX25Path et all has its own tests for str.
+    rx_path_count = dict(
+        [
+            (str(AX25Path(*path)), count)
+            for path, count in peer._rx_path_count.items()
+        ]
+    )
+
+    assert rx_path_count == {}
diff --git a/tests/test_peer/test_send.py b/tests/test_peer/test_send.py
new file mode 100644
index 0000000..fed475a
--- /dev/null
+++ b/tests/test_peer/test_send.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+
+"""
+Tests for AX25Peer transmit segmentation
+"""
+
+from aioax25.frame import (
+    AX25Address,
+)
+from .peer import TestingAX25Peer
+from ..mocks import DummyStation
+
+
+# UA reception
+
+
+def test_peer_send_short():
+    """
+    Test send accepts short payloads and enqueues a single transmission.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=[],
+        full_duplex=True,
+    )
+
+    peer._send_next_iframe_scheduled = False
+
+    def _send_next_iframe():
+        peer._send_next_iframe_scheduled = True
+
+    peer._send_next_iframe = _send_next_iframe
+
+    peer.send(b"Testing 1 2 3 4")
+
+    assert peer._send_next_iframe_scheduled is True
+    assert peer._pending_data == [(0xF0, b"Testing 1 2 3 4")]
+
+
+def test_peer_send_long():
+    """
+    Test send accepts long payloads and enqueues multiple transmissions.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=[],
+        full_duplex=True,
+    )
+
+    peer._send_next_iframe_scheduled = False
+
+    def _send_next_iframe():
+        peer._send_next_iframe_scheduled = True
+
+    peer._send_next_iframe = _send_next_iframe
+
+    peer.send(
+        b"(0) Testing 1 2 3 4 5\n(1) Testing 1 2 3 4 5\n(2) Testing 1 2 3 4 5"
+        b"\n(3) Testing 1 2 3 4 5\n(4) Testing 1 2 3 4 5\n(5) Testing 1 2 3 4"
+        b" 5\n(6) Testing 1 2 3 4 5\n(7) Testing 1 2 3 4 5\n"
+    )
+
+    assert peer._send_next_iframe_scheduled is True
+    assert peer._pending_data == [
+        (
+            0xF0,
+            b"(0) Testing 1 2 3 4 5\n(1) Testing 1 2 3 4 5\n(2) Testing "
+            b"1 2 3 4 5\n(3) Testing 1 2 3 4 5\n(4) Testing 1 2 3 4 5\n(5) "
+            b"Testing 1 2 3 ",
+        ),
+        (0xF0, b"4 5\n(6) Testing 1 2 3 4 5\n(7) Testing 1 2 3 4 5\n"),
+    ]
+
+
+def test_peer_send_paclen():
+    """
+    Test send respects PACLEN.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=[],
+        full_duplex=True,
+        paclen=16,
+    )
+
+    peer._send_next_iframe_scheduled = False
+
+    def _send_next_iframe():
+        peer._send_next_iframe_scheduled = True
+
+    peer._send_next_iframe = _send_next_iframe
+
+    peer.send(
+        b"(0) Testing 1 2 3 4 5\n(1) Testing 1 2 3 4 5\n(2) Testing 1 2 3 4 5"
+        b"\n(3) Testing 1 2 3 4 5\n(4) Testing 1 2 3 4 5\n(5) Testing 1 2 3 4"
+        b" 5\n(6) Testing 1 2 3 4 5\n(7) Testing 1 2 3 4 5\n"
+    )
+
+    assert peer._send_next_iframe_scheduled is True
+    assert peer._pending_data == [
+        (0xF0, b"(0) Testing 1 2 "),
+        (0xF0, b"3 4 5\n(1) Testin"),
+        (0xF0, b"g 1 2 3 4 5\n(2) "),
+        (0xF0, b"Testing 1 2 3 4 "),
+        (0xF0, b"5\n(3) Testing 1 "),
+        (0xF0, b"2 3 4 5\n(4) Test"),
+        (0xF0, b"ing 1 2 3 4 5\n(5"),
+        (0xF0, b") Testing 1 2 3 "),
+        (0xF0, b"4 5\n(6) Testing "),
+        (0xF0, b"1 2 3 4 5\n(7) Te"),
+        (0xF0, b"sting 1 2 3 4 5\n"),
+    ]
diff --git a/tests/test_peer/test_state.py b/tests/test_peer/test_state.py
new file mode 100644
index 0000000..453fa88
--- /dev/null
+++ b/tests/test_peer/test_state.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+
+"""
+Test state transition logic
+"""
+
+from aioax25.frame import AX25Address, AX25Path
+from aioax25.peer import AX25PeerState
+from .peer import TestingAX25Peer
+from ..mocks import DummyStation
+
+# Idle time-out cancellation
+
+
+def test_state_unchanged():
+    """
+    Test that _set_conn_state is a no-op if the state is not different.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    state_changes = []
+
+    def _on_state_change(**kwargs):
+        state_changes.append(kwargs)
+
+    peer.connect_state_changed.connect(_on_state_change)
+
+    assert peer._state is AX25PeerState.DISCONNECTED
+    assert peer.state is AX25PeerState.DISCONNECTED
+
+    peer._set_conn_state(AX25PeerState.DISCONNECTED)
+
+    assert state_changes == []
+
+
+def test_state_changed():
+    """
+    Test that _set_conn_state reports and stores state changes.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4RZB"),
+        locked_path=True,
+    )
+
+    state_changes = []
+
+    def _on_state_change(**kwargs):
+        state_changes.append(kwargs)
+
+    peer.connect_state_changed.connect(_on_state_change)
+
+    assert peer._state is AX25PeerState.DISCONNECTED
+    assert peer.state is AX25PeerState.DISCONNECTED
+
+    peer._set_conn_state(AX25PeerState.CONNECTED)
+
+    assert peer._state is AX25PeerState.CONNECTED
+    assert peer.state is AX25PeerState.CONNECTED
+    assert state_changes[1:] == []
+
+    change = state_changes.pop(0)
+    assert change.pop("station") is station
+    assert change.pop("peer") is peer
+    assert change.pop("state") is AX25PeerState.CONNECTED
+    assert change == {}
diff --git a/tests/test_peer/test_ua.py b/tests/test_peer/test_ua.py
new file mode 100644
index 0000000..75c2cb9
--- /dev/null
+++ b/tests/test_peer/test_ua.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+
+"""
+Tests for AX25Peer UA handling
+"""
+
+from aioax25.frame import (
+    AX25Address,
+    AX25Path,
+    AX25UnnumberedAcknowledgeFrame,
+)
+from aioax25.version import AX25Version
+from .peer import TestingAX25Peer
+from ..mocks import DummyStation
+
+
+# UA reception
+
+
+def test_peer_recv_ua():
+    """
+    Test _on_receive_ua does nothing if no UA expected.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"),
+        full_duplex=True,
+    )
+
+    peer._on_receive_ua()
+
+    # does nothing
+
+
+# UA transmission
+
+
+def test_peer_send_ua():
+    """
+    Test _send_ua correctly addresses and sends a UA frame.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    interface = station._interface()
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=AX25Path("VK4MSL-2", "VK4MSL-3"),
+        full_duplex=True,
+    )
+
+    # Request a UA frame be sent
+    peer._send_ua()
+
+    # There should be a frame sent
+    assert len(interface.transmit_calls) == 1
+    (tx_args, tx_kwargs) = interface.transmit_calls.pop(0)
+
+    # This should be a UA
+    assert tx_kwargs == {"callback": None}
+    assert len(tx_args) == 1
+    (frame,) = tx_args
+    assert isinstance(frame, AX25UnnumberedAcknowledgeFrame)
+
+    assert str(frame.header.destination) == "VK4MSL*"
+    assert str(frame.header.source) == "VK4MSL-1"
+    assert str(frame.header.repeaters) == "VK4MSL-2,VK4MSL-3"
diff --git a/tests/test_peer/test_xid.py b/tests/test_peer/test_xid.py
new file mode 100644
index 0000000..90fd233
--- /dev/null
+++ b/tests/test_peer/test_xid.py
@@ -0,0 +1,1081 @@
+#!/usr/bin/env python3
+
+"""
+Tests for AX25Peer XID handling
+"""
+
+from aioax25.frame import (
+    AX25Address,
+    AX25XIDClassOfProceduresParameter,
+    AX25XIDHDLCOptionalFunctionsParameter,
+    AX25XIDIFieldLengthTransmitParameter,
+    AX25XIDIFieldLengthReceiveParameter,
+    AX25XIDWindowSizeTransmitParameter,
+    AX25XIDWindowSizeReceiveParameter,
+    AX25XIDAcknowledgeTimerParameter,
+    AX25XIDRetriesParameter,
+    AX25XIDRawParameter,
+    AX25ExchangeIdentificationFrame,
+    AX25FrameRejectFrame,
+)
+from aioax25.peer import AX25PeerState, AX25RejectMode
+from aioax25.version import AX25Version
+from .peer import TestingAX25Peer
+from ..mocks import DummyStation
+
+
+def test_peer_process_xid_cop_fds_fdp():
+    """
+    Test _process_xid_cop enables full-duplex if both stations negotiate it.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        full_duplex=True,
+    )
+
+    # Pass in a CoP XID parameter
+    peer._process_xid_cop(
+        AX25XIDClassOfProceduresParameter(full_duplex=True, half_duplex=False)
+    )
+
+    # Full duplex should be enabled
+    assert peer._full_duplex
+
+
+def test_peer_process_xid_cop_fds_hdp():
+    """
+    Test _process_xid_cop disables full-duplex if the peer is half-duplex.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        full_duplex=True,
+    )
+
+    # Pass in a CoP XID parameter
+    peer._process_xid_cop(
+        AX25XIDClassOfProceduresParameter(full_duplex=False, half_duplex=True)
+    )
+
+    # Full duplex should be disabled
+    assert not peer._full_duplex
+
+
+def test_peer_process_xid_cop_hds_fdp():
+    """
+    Test _process_xid_cop disables full-duplex if the station is half-duplex.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        full_duplex=False,
+    )
+
+    # Pass in a CoP XID parameter
+    peer._process_xid_cop(
+        AX25XIDClassOfProceduresParameter(full_duplex=True, half_duplex=False)
+    )
+
+    # Full duplex should be disabled
+    assert not peer._full_duplex
+
+
+def test_peer_process_xid_cop_malformed_cop_fdx_hdx():
+    """
+    Test _process_xid_cop disables full-duplex the CoP frame sets both
+    half-duplex and full-duplex flags.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        full_duplex=True,
+    )
+
+    # Pass in a CoP XID parameter
+    peer._process_xid_cop(
+        AX25XIDClassOfProceduresParameter(full_duplex=True, half_duplex=True)
+    )
+
+    # Full duplex should be disabled
+    assert not peer._full_duplex
+
+
+def test_peer_process_xid_cop_malformed_cop_nfdx_nhdx():
+    """
+    Test _process_xid_cop disables full-duplex the CoP frame clears both
+    half-duplex and full-duplex flags.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        full_duplex=True,
+    )
+
+    # Pass in a CoP XID parameter
+    peer._process_xid_cop(
+        AX25XIDClassOfProceduresParameter(
+            full_duplex=False, half_duplex=False
+        )
+    )
+
+    # Full duplex should be disabled
+    assert not peer._full_duplex
+
+
+def test_peer_process_xid_cop_default():
+    """
+    Test _process_xid_cop assumes AX.25 2.2 defaults if given null CoP
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        full_duplex=True,
+    )
+
+    # Pass in a CoP XID parameter
+    peer._process_xid_cop(
+        AX25XIDRawParameter(pi=AX25XIDClassOfProceduresParameter.PI, pv=None)
+    )
+
+    # Full duplex should be disabled
+    assert not peer._full_duplex
+
+
+def test_peer_process_xid_hdlcoptfunc_stnssr_peerssr():
+    """
+    Test _process_xid_hdlcoptfunc sets SRR if both set SRR
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        reject_mode=AX25RejectMode.SELECTIVE_RR,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(rej=True, srej=True)
+    )
+
+    # Selective Reject-Reject should be chosen.
+    assert peer._reject_mode == AX25RejectMode.SELECTIVE_RR
+
+
+def test_peer_process_xid_hdlcoptfunc_stnsr_peerssr():
+    """
+    Test _process_xid_hdlcoptfunc sets SR if station sets SR
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        reject_mode=AX25RejectMode.SELECTIVE,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(rej=True, srej=True)
+    )
+
+    # Selective Reject should be chosen.
+    assert peer._reject_mode == AX25RejectMode.SELECTIVE
+
+
+def test_peer_process_xid_hdlcoptfunc_stnssr_peersr():
+    """
+    Test _process_xid_hdlcoptfunc sets SR if peer sets SR
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        reject_mode=AX25RejectMode.SELECTIVE_RR,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(rej=False, srej=True)
+    )
+
+    # Selective Reject should be chosen.
+    assert peer._reject_mode == AX25RejectMode.SELECTIVE
+
+
+def test_peer_process_xid_hdlcoptfunc_stnsr_peersr():
+    """
+    Test _process_xid_hdlcoptfunc sets SR if both agree on SR
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        reject_mode=AX25RejectMode.SELECTIVE,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(rej=False, srej=True)
+    )
+
+    # Selective Reject should be chosen.
+    assert peer._reject_mode == AX25RejectMode.SELECTIVE
+
+
+def test_peer_process_xid_hdlcoptfunc_stnir_peersr():
+    """
+    Test _process_xid_hdlcoptfunc sets IR if station sets IR (peer=SR)
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        reject_mode=AX25RejectMode.IMPLICIT,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(rej=False, srej=True)
+    )
+
+    # Implicit Reject should be chosen.
+    assert peer._reject_mode == AX25RejectMode.IMPLICIT
+
+
+def test_peer_process_xid_hdlcoptfunc_stnsr_peerir():
+    """
+    Test _process_xid_hdlcoptfunc sets IR if peer sets IR (station=SR)
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        reject_mode=AX25RejectMode.SELECTIVE,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(rej=True, srej=False)
+    )
+
+    # Implicit Reject should be chosen.
+    assert peer._reject_mode == AX25RejectMode.IMPLICIT
+
+
+def test_peer_process_xid_hdlcoptfunc_stnir_peerssr():
+    """
+    Test _process_xid_hdlcoptfunc sets IR if station sets IR (peer=SSR)
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        reject_mode=AX25RejectMode.IMPLICIT,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(rej=True, srej=True)
+    )
+
+    # Implicit Reject should be chosen.
+    assert peer._reject_mode == AX25RejectMode.IMPLICIT
+
+
+def test_peer_process_xid_hdlcoptfunc_stnssr_peerir():
+    """
+    Test _process_xid_hdlcoptfunc sets IR if peer sets IR (station=SSR)
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        reject_mode=AX25RejectMode.SELECTIVE_RR,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(rej=True, srej=False)
+    )
+
+    # Implicit Reject should be chosen.
+    assert peer._reject_mode == AX25RejectMode.IMPLICIT
+
+
+def test_peer_process_xid_hdlcoptfunc_malformed_rej_srej():
+    """
+    Test _process_xid_hdlcoptfunc sets IR if peer clears REJ and SREJ
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        reject_mode=AX25RejectMode.SELECTIVE_RR,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(rej=False, srej=False)
+    )
+
+    # Implicit Reject should be chosen.
+    assert peer._reject_mode == AX25RejectMode.IMPLICIT
+
+
+def test_peer_process_xid_hdlcoptfunc_default_rej_srej():
+    """
+    Test _process_xid_hdlcoptfunc sets SR if peer does not send value.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        reject_mode=AX25RejectMode.SELECTIVE_RR,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDRawParameter(
+            pi=AX25XIDHDLCOptionalFunctionsParameter.PI, pv=None
+        )
+    )
+
+    # Selective Reject should be chosen.
+    assert peer._reject_mode == AX25RejectMode.SELECTIVE
+
+
+def test_peer_process_xid_hdlcoptfunc_s128_p128():
+    """
+    Test _process_xid_hdlcoptfunc sets mod128 if both agree
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        modulo128=True,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(modulo8=False, modulo128=True)
+    )
+
+    # Modulo128 should be chosen.
+    assert peer._modulo128
+
+
+def test_peer_process_xid_hdlcoptfunc_s128_p8():
+    """
+    Test _process_xid_hdlcoptfunc sets mod8 if peer sets mod8
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        modulo128=True,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(modulo8=True, modulo128=False)
+    )
+
+    # Modulo8 should be chosen.
+    assert not peer._modulo128
+
+
+def test_peer_process_xid_hdlcoptfunc_s8_p128():
+    """
+    Test _process_xid_hdlcoptfunc sets mod8 if station sets mod8
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        modulo128=False,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(modulo8=False, modulo128=True)
+    )
+
+    # Modulo8 should be chosen.
+    assert not peer._modulo128
+
+
+def test_peer_process_xid_hdlcoptfunc_s8_p8():
+    """
+    Test _process_xid_hdlcoptfunc sets mod8 if both agree on mod8
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        modulo128=False,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(modulo8=True, modulo128=False)
+    )
+
+    # Modulo8 should be chosen.
+    assert not peer._modulo128
+
+
+def test_peer_process_xid_hdlcoptfunc_malformed_m8s_m128s():
+    """
+    Test _process_xid_hdlcoptfunc sets mod8 if peer sets mod8 and mod128 bits
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        modulo128=True,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(modulo8=True, modulo128=True)
+    )
+
+    # Modulo8 should be chosen.
+    assert not peer._modulo128
+
+
+def test_peer_process_xid_hdlcoptfunc_malformed_m8c_m128c():
+    """
+    Test _process_xid_hdlcoptfunc sets mod8 if peer clears mod8 and mod128 bits
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        modulo128=True,
+    )
+
+    # Pass in a HDLC Optional Functions XID parameter
+    peer._process_xid_hdlcoptfunc(
+        AX25XIDHDLCOptionalFunctionsParameter(modulo8=False, modulo128=False)
+    )
+
+    # Modulo8 should be chosen.
+    assert not peer._modulo128
+
+
+def test_peer_process_xid_ifieldlenrx_station_smaller():
+    """
+    Test _process_xid_ifieldlenrx chooses station's field length if it's smaller
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        max_ifield=128,
+    )
+
+    # Pass in a I-Field Length Receive XID parameter
+    peer._process_xid_ifieldlenrx(AX25XIDIFieldLengthReceiveParameter(2048))
+
+    # 128 bytes should be set
+    assert peer._max_ifield == 128
+
+
+def test_peer_process_xid_ifieldlenrx_peer_smaller():
+    """
+    Test _process_xid_ifieldlenrx chooses peer's field length if it's smaller
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        max_ifield=256,
+    )
+
+    # Pass in a I-Field Length Receive XID parameter
+    peer._process_xid_ifieldlenrx(AX25XIDIFieldLengthReceiveParameter(1024))
+
+    # 128 bytes should be set
+    assert peer._max_ifield == 128
+
+
+def test_peer_process_xid_ifieldlenrx_default():
+    """
+    Test _process_xid_ifieldlenrx assumes defaults if not given a value
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        max_ifield=256,
+    )
+
+    # Pass in a I-Field Length Receive XID parameter
+    peer._process_xid_ifieldlenrx(
+        AX25XIDRawParameter(
+            pi=AX25XIDIFieldLengthReceiveParameter.PI, pv=None
+        )
+    )
+
+    # 256 bytes should be set
+    assert peer._max_ifield == 256
+
+
+def test_peer_process_xid_winszrx_station_smaller():
+    """
+    Test _process_xid_winszrx chooses station's window size if it's smaller
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        modulo128=True,
+        max_outstanding_mod128=63,
+    )
+
+    # Pass in a Window Size Receive XID parameter
+    peer._process_xid_winszrx(AX25XIDWindowSizeReceiveParameter(127))
+
+    # 63 frames should be set
+    assert peer._max_outstanding == 63
+
+
+def test_peer_process_xid_winszrx_peer_smaller():
+    """
+    Test _process_xid_winszrx chooses peer's window size if it's smaller
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        modulo128=True,
+        max_outstanding_mod128=127,
+    )
+
+    # Pass in a Window Size Receive XID parameter
+    peer._process_xid_winszrx(AX25XIDWindowSizeReceiveParameter(63))
+
+    # 63 frames should be set
+    assert peer._max_outstanding == 63
+
+
+def test_peer_process_xid_winszrx_default():
+    """
+    Test _process_xid_winszrx assumes defaults if not given a value
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        modulo128=True,
+        max_outstanding_mod128=127,
+    )
+
+    # Pass in a Window Size Receive XID parameter
+    peer._process_xid_winszrx(
+        AX25XIDRawParameter(pi=AX25XIDWindowSizeReceiveParameter.PI, pv=None)
+    )
+
+    # 7 frames should be set
+    assert peer._max_outstanding == 7
+
+
+def test_peer_process_xid_acktimer_station_larger():
+    """
+    Test _process_xid_acktimer chooses station's acknowledge timer if it's larger
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        ack_timeout=10.0,
+    )
+
+    # Pass in a Acknowledge Timer XID parameter
+    peer._process_xid_acktimer(AX25XIDAcknowledgeTimerParameter(5000))
+
+    # 10 seconds should be set
+    assert peer._ack_timeout == 10.0
+
+
+def test_peer_process_xid_acktimer_peer_larger():
+    """
+    Test _process_xid_acktimer chooses peer's acknowledge timer if it's larger
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        ack_timeout=5.0,
+    )
+
+    # Pass in a Acknowledge Timer XID parameter
+    peer._process_xid_acktimer(AX25XIDAcknowledgeTimerParameter(10000))
+
+    # 10 seconds should be set
+    assert peer._ack_timeout == 10.0
+
+
+def test_peer_process_xid_acktimer_default():
+    """
+    Test _process_xid_acktimer assumes defaults if not given a value
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        ack_timeout=1.0,
+    )
+
+    # Pass in a Acknowledge Timer XID parameter
+    peer._process_xid_acktimer(
+        AX25XIDRawParameter(pi=AX25XIDAcknowledgeTimerParameter.PI, pv=None)
+    )
+
+    # 3 seconds should be set
+    assert peer._ack_timeout == 3.0
+
+
+def test_peer_process_xid_retrycounter_station_larger():
+    """
+    Test _process_xid_retrycounter chooses station's retry count if it's larger
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        max_retries=6,
+    )
+
+    # Pass in a Retries XID parameter
+    peer._process_xid_retrycounter(AX25XIDRetriesParameter(2))
+
+    # 6 retries should be set
+    assert peer._max_retries == 6
+
+
+def test_peer_process_xid_retrycounter_peer_larger():
+    """
+    Test _process_xid_retrycounter chooses peer's retry count if it's larger
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        max_retries=2,
+    )
+
+    # Pass in a Retries XID parameter
+    peer._process_xid_retrycounter(AX25XIDRetriesParameter(6))
+
+    # 6 retries should be set
+    assert peer._max_retries == 6
+
+
+def test_peer_process_xid_retrycounter_default():
+    """
+    Test _process_xid_retrycounter assumes defaults if not given a value
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        repeaters=None,
+        max_retries=0,
+    )
+
+    # Pass in a Retries XID parameter
+    peer._process_xid_retrycounter(
+        AX25XIDRawParameter(pi=AX25XIDRetriesParameter.PI, pv=None)
+    )
+
+    # 10 retries should be set
+    assert peer._max_retries == 10
+
+
+def test_peer_on_receive_xid_ax20_mode():
+    """
+    Test _on_receive responds with FRMR when in AX.25 2.0 mode.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    station._protocol = AX25Version.AX25_20
+    interface = station._interface()
+    peer = TestingAX25Peer(
+        station=station, address=AX25Address("VK4MSL"), repeaters=None
+    )
+
+    # Nothing yet sent
+    assert interface.transmit_calls == []
+
+    # Pass in the XID frame to our AX.25 2.0 station.
+    peer._on_receive(
+        AX25ExchangeIdentificationFrame(
+            destination=station.address,
+            source=peer.address,
+            repeaters=None,
+            parameters=[],
+        )
+    )
+
+    # One frame sent
+    assert len(interface.transmit_calls) == 1
+    (tx_args, tx_kwargs) = interface.transmit_calls.pop(0)
+
+    # This should be a FRMR
+    assert tx_kwargs == {"callback": None}
+    assert len(tx_args) == 1
+    (frame,) = tx_args
+    assert isinstance(frame, AX25FrameRejectFrame)
+
+    # W bit should be set
+    assert frame.w
+
+    # We should now be in the FRMR state
+    assert peer._state == AX25PeerState.FRMR
+
+
+def test_peer_on_receive_xid_connecting():
+    """
+    Test _on_receive ignores XID when connecting.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    interface = station._interface()
+    peer = TestingAX25Peer(
+        station=station, address=AX25Address("VK4MSL"), repeaters=None
+    )
+
+    # Nothing yet sent
+    assert interface.transmit_calls == []
+
+    # Set state
+    peer._state = AX25PeerState.CONNECTING
+
+    # Pass in the XID frame to our AX.25 2.2 station.
+    peer._on_receive(
+        AX25ExchangeIdentificationFrame(
+            destination=station.address,
+            source=peer.address,
+            repeaters=None,
+            parameters=[],
+            cr=True,
+        )
+    )
+
+    # Still nothing yet sent
+    assert interface.transmit_calls == []
+
+
+def test_peer_on_receive_xid_disconnecting():
+    """
+    Test _on_receive ignores XID when disconnecting.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    interface = station._interface()
+    peer = TestingAX25Peer(
+        station=station, address=AX25Address("VK4MSL"), repeaters=None
+    )
+
+    # Nothing yet sent
+    assert interface.transmit_calls == []
+
+    # Set state
+    peer._state = AX25PeerState.DISCONNECTING
+
+    # Pass in the XID frame to our AX.25 2.2 station.
+    peer._on_receive(
+        AX25ExchangeIdentificationFrame(
+            destination=station.address,
+            source=peer.address,
+            repeaters=None,
+            parameters=[],
+            cr=True,
+        )
+    )
+
+    # Still nothing yet sent
+    assert interface.transmit_calls == []
+
+
+def test_peer_on_receive_xid_sets_proto_version():
+    """
+    Test _on_receive sets protocol version if unknown.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station, address=AX25Address("VK4MSL"), repeaters=None
+    )
+
+    # Version should be unknown
+    assert peer._protocol == AX25Version.UNKNOWN
+
+    # Pass in the XID frame to our AX.25 2.2 station.
+    peer._on_receive(
+        AX25ExchangeIdentificationFrame(
+            destination=station.address,
+            source=peer.address,
+            repeaters=None,
+            parameters=[],
+        )
+    )
+
+    # We now should consider the other station as AX.25 2.2 or better
+    assert peer._protocol == AX25Version.AX25_22
+
+
+def test_peer_on_receive_xid_keeps_known_proto_version():
+    """
+    Test _on_receive keeps existing protocol version if known.
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station,
+        address=AX25Address("VK4MSL"),
+        protocol=AX25Version.AX25_22,
+        repeaters=None,
+    )
+
+    # Pass in the XID frame to our AX.25 2.2 station.
+    peer._on_receive(
+        AX25ExchangeIdentificationFrame(
+            destination=station.address,
+            source=peer.address,
+            repeaters=None,
+            parameters=[],
+        )
+    )
+
+    # We should still consider the other station as AX.25 2.2 or better
+    assert peer._protocol == AX25Version.AX25_22
+
+
+def test_peer_on_receive_xid_ignores_bad_fi():
+    """
+    Test _on_receive ignores parameters if FI is unknown
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station, address=AX25Address("VK4MSL"), repeaters=None
+    )
+
+    # Stub out _process_xid_cop
+    def _stub_process_cop(param):
+        assert False, "Should not be called"
+
+    peer._process_xid_cop = _stub_process_cop
+
+    # Pass in the XID frame to our AX.25 2.2 station.
+    # There should be no assertion triggered.
+    peer._on_receive(
+        AX25ExchangeIdentificationFrame(
+            destination=station.address,
+            source=peer.address,
+            repeaters=None,
+            parameters=[AX25XIDClassOfProceduresParameter(half_duplex=True)],
+            fi=26,
+        )
+    )
+
+
+def test_peer_on_receive_xid_ignores_bad_gi():
+    """
+    Test _on_receive ignores parameters if GI is unknown
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station, address=AX25Address("VK4MSL"), repeaters=None
+    )
+
+    # Stub out _process_xid_cop
+    def _stub_process_cop(param):
+        assert False, "Should not be called"
+
+    peer._process_xid_cop = _stub_process_cop
+
+    # Pass in the XID frame to our AX.25 2.2 station.
+    # There should be no assertion triggered.
+    peer._on_receive(
+        AX25ExchangeIdentificationFrame(
+            destination=station.address,
+            source=peer.address,
+            repeaters=None,
+            parameters=[AX25XIDClassOfProceduresParameter(half_duplex=True)],
+            gi=26,
+        )
+    )
+
+
+def test_peer_on_receive_xid_processes_parameters():
+    """
+    Test _on_receive processes parameters on good XID frames
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    peer = TestingAX25Peer(
+        station=station, address=AX25Address("VK4MSL"), repeaters=None
+    )
+
+    # Pass in the XID frame to our AX.25 2.2 station.
+    # There should be no assertion triggered.
+    peer._on_receive(
+        AX25ExchangeIdentificationFrame(
+            destination=station.address,
+            source=peer.address,
+            repeaters=None,
+            parameters=[AX25XIDIFieldLengthReceiveParameter(512)],
+        )
+    )
+
+    # Should be negotiated to 64 bytes
+    assert peer._max_ifield == 64
+
+
+def test_peer_on_receive_xid_reply():
+    """
+    Test _on_receive sends reply if incoming frame has CR=True
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    interface = station._interface()
+    peer = TestingAX25Peer(
+        station=station, address=AX25Address("VK4MSL"), repeaters=None
+    )
+
+    # Nothing yet sent
+    assert interface.transmit_calls == []
+
+    # Pass in the XID frame to our AX.25 2.2 station.
+    peer._on_receive(
+        AX25ExchangeIdentificationFrame(
+            destination=station.address,
+            source=peer.address,
+            repeaters=None,
+            parameters=[],
+            cr=True,
+        )
+    )
+
+    # This was a request, so there should be a reply waiting
+    assert len(interface.transmit_calls) == 1
+    (tx_args, tx_kwargs) = interface.transmit_calls.pop(0)
+
+    # This should be a XID
+    assert tx_kwargs == {"callback": None}
+    assert len(tx_args) == 1
+    (frame,) = tx_args
+    assert isinstance(frame, AX25ExchangeIdentificationFrame)
+
+    # CR bit should be clear
+    assert not frame.header.cr
+
+    # Frame should reflect the settings of the station
+    assert len(frame.parameters) == 8
+
+    param = frame.parameters[0]
+    assert isinstance(param, AX25XIDClassOfProceduresParameter)
+    assert param.half_duplex
+    assert not param.full_duplex
+
+    param = frame.parameters[1]
+    assert isinstance(param, AX25XIDHDLCOptionalFunctionsParameter)
+    assert param.srej
+    assert not param.rej
+    assert not param.modulo128
+    assert param.modulo8
+
+    param = frame.parameters[2]
+    assert isinstance(param, AX25XIDIFieldLengthTransmitParameter)
+    assert param.value == 2048
+
+    param = frame.parameters[3]
+    assert isinstance(param, AX25XIDIFieldLengthReceiveParameter)
+    assert param.value == 2048
+
+    param = frame.parameters[4]
+    assert isinstance(param, AX25XIDWindowSizeTransmitParameter)
+    assert param.value == 7
+
+    param = frame.parameters[5]
+    assert isinstance(param, AX25XIDWindowSizeReceiveParameter)
+    assert param.value == 7
+
+    param = frame.parameters[6]
+    assert isinstance(param, AX25XIDAcknowledgeTimerParameter)
+    assert param.value == 3000
+
+    param = frame.parameters[7]
+    assert isinstance(param, AX25XIDRetriesParameter)
+    assert param.value == 10
+
+
+def test_peer_on_receive_xid_relay():
+    """
+    Test _on_receive sends relays to XID handler if CR=False
+    """
+    station = DummyStation(AX25Address("VK4MSL", ssid=1))
+    interface = station._interface()
+    peer = TestingAX25Peer(
+        station=station, address=AX25Address("VK4MSL"), repeaters=None
+    )
+
+    # Nothing yet sent
+    assert interface.transmit_calls == []
+
+    # Hook the XID handler
+    xid_events = []
+    peer._xidframe_handler = lambda *a, **kw: xid_events.append((a, kw))
+
+    # Pass in the XID frame to our AX.25 2.2 station.
+    frame = AX25ExchangeIdentificationFrame(
+        destination=station.address,
+        source=peer.address,
+        repeaters=None,
+        parameters=[],
+        cr=False,
+    )
+    peer._on_receive(frame)
+
+    # There should have been a XID event
+    assert len(xid_events) == 1
+
+    # It should be passed our handler
+    (xid_args, xid_kwargs) = xid_events.pop(0)
+    (xid_frame,) = xid_args
+    assert frame is xid_frame
diff --git a/tests/test_station/__init__.py b/tests/test_station/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_station/test_attach_detach.py b/tests/test_station/test_attach_detach.py
new file mode 100644
index 0000000..671286e
--- /dev/null
+++ b/tests/test_station/test_attach_detach.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+
+from aioax25.station import AX25Station
+
+from ..mocks import DummyInterface
+
+
+def test_attach():
+    """
+    Test attach binds the station to the interface.
+    """
+    interface = DummyInterface()
+    station = AX25Station(interface=interface, callsign="VK4MSL-5")
+    station.attach()
+
+    assert len(interface.bind_calls) == 1
+    assert len(interface.unbind_calls) == 0
+    assert len(interface.transmit_calls) == 0
+
+    (args, kwargs) = interface.bind_calls.pop()
+    assert args == (station._on_receive,)
+    assert set(kwargs.keys()) == set(["callsign", "ssid", "regex"])
+    assert kwargs["callsign"] == "VK4MSL"
+    assert kwargs["ssid"] == 5
+    assert kwargs["regex"] == False
+
+
+def test_detach():
+    """
+    Test attach unbinds the station to the interface.
+    """
+    interface = DummyInterface()
+    station = AX25Station(interface=interface, callsign="VK4MSL-5")
+    station.detach()
+
+    assert len(interface.bind_calls) == 0
+    assert len(interface.unbind_calls) == 1
+    assert len(interface.transmit_calls) == 0
+
+    (args, kwargs) = interface.unbind_calls.pop()
+    assert args == (station._on_receive,)
+    assert set(kwargs.keys()) == set(["callsign", "ssid", "regex"])
+    assert kwargs["callsign"] == "VK4MSL"
+    assert kwargs["ssid"] == 5
+    assert kwargs["regex"] == False
diff --git a/tests/test_station/test_constructor.py b/tests/test_station/test_constructor.py
new file mode 100644
index 0000000..e9ad813
--- /dev/null
+++ b/tests/test_station/test_constructor.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+
+from aioax25.station import AX25Station
+from aioax25.version import AX25Version
+
+from ..mocks import DummyInterface
+
+
+def test_constructor_log():
+    """
+    Test the AX25Constructor uses the log given.
+    """
+
+    class DummyLogger(object):
+        pass
+
+    log = DummyLogger()
+    interface = DummyInterface()
+    station = AX25Station(
+        interface=interface, callsign="VK4MSL", ssid=3, log=log
+    )
+    assert station._log is log
+
+
+def test_constructor_loop():
+    """
+    Test the AX25Constructor uses the IO loop given.
+    """
+
+    class DummyLoop(object):
+        pass
+
+    loop = DummyLoop()
+    interface = DummyInterface()
+    station = AX25Station(
+        interface=interface, callsign="VK4MSL", ssid=3, loop=loop
+    )
+    assert station._loop is loop
+
+
+def test_constructor_protocol():
+    """
+    Test the AX25Constructor validates the protocol
+    """
+    try:
+        AX25Station(
+            interface=DummyInterface(),
+            callsign="VK4MSL",
+            ssid=3,
+            protocol=AX25Version.AX25_10,
+        )
+        assert False, "Should not have worked"
+    except ValueError as e:
+        assert str(e) == "'1.x' not a supported AX.25 protocol version"
diff --git a/tests/test_station/test_getpeer.py b/tests/test_station/test_getpeer.py
new file mode 100644
index 0000000..db572e5
--- /dev/null
+++ b/tests/test_station/test_getpeer.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+
+from aioax25.station import AX25Station
+from aioax25.peer import AX25Peer
+from aioax25.frame import AX25Address
+
+from ..mocks import DummyInterface, DummyPeer
+
+
+def test_unknown_peer_nocreate_keyerror():
+    """
+    Test fetching an unknown peer with create=False raises KeyError
+    """
+    station = AX25Station(interface=DummyInterface(), callsign="VK4MSL-5")
+    try:
+        station.getpeer("VK4BWI", create=False)
+        assert False, "Should not have worked"
+    except KeyError as e:
+        assert str(e) == (
+            "AX25Address(callsign=VK4BWI, ssid=0, "
+            "ch=False, res0=True, res1=True, extension=False)"
+        )
+
+
+def test_unknown_peer_create_instance():
+    """
+    Test fetching an unknown peer with create=True generates peer
+    """
+    station = AX25Station(interface=DummyInterface(), callsign="VK4MSL-5")
+    peer = station.getpeer("VK4BWI", create=True)
+    assert isinstance(peer, AX25Peer)
+
+
+def test_known_peer_fetch_instance():
+    """
+    Test fetching an known peer returns that known peer
+    """
+    station = AX25Station(interface=DummyInterface(), callsign="VK4MSL-5")
+    mypeer = DummyPeer(station, AX25Address("VK4BWI"))
+
+    # Inject the peer
+    station._peers[mypeer._address] = mypeer
+
+    # Retrieve the peer instance
+    peer = station.getpeer("VK4BWI")
+    assert peer is mypeer
diff --git a/tests/test_station/test_properties.py b/tests/test_station/test_properties.py
new file mode 100644
index 0000000..4796910
--- /dev/null
+++ b/tests/test_station/test_properties.py
@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+
+from aioax25.station import AX25Station
+from aioax25.version import AX25Version
+from aioax25.frame import AX25Address
+
+from ..mocks import DummyInterface
+
+
+def test_address():
+    """
+    Test the address of the station is set from the constructor.
+    """
+    station = AX25Station(interface=DummyInterface(), callsign="VK4MSL-5")
+    assert station.address == AX25Address(callsign="VK4MSL", ssid=5)
+
+
+def test_protocol():
+    """
+    Test the protocol of the station is set from the constructor.
+    """
+    station = AX25Station(
+        interface=DummyInterface(),
+        callsign="VK4MSL-5",
+        protocol=AX25Version.AX25_20,
+    )
+    assert station.protocol == AX25Version.AX25_20
diff --git a/tests/test_station/test_receive.py b/tests/test_station/test_receive.py
new file mode 100644
index 0000000..4bcaac6
--- /dev/null
+++ b/tests/test_station/test_receive.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+
+from aioax25.station import AX25Station
+from aioax25.frame import (
+    AX25Address,
+    AX25TestFrame,
+    AX25UnnumberedInformationFrame,
+)
+
+from ..mocks import DummyInterface, DummyPeer
+
+
+def test_testframe_cmd_echo():
+    """
+    Test passing a test frame with CR=True triggers a reply frame.
+    """
+    interface = DummyInterface()
+    station = AX25Station(interface=interface, callsign="VK4MSL-5")
+
+    # Pass in a frame
+    station._on_receive(
+        frame=AX25TestFrame(
+            destination="VK4MSL-5",
+            source="VK4MSL-7",
+            cr=True,
+            payload=b"This is a test frame",
+        )
+    )
+
+    # There should be no peers
+    assert station._peers == {}
+
+    # There should be a reply queued up
+    assert interface.bind_calls == []
+    assert interface.unbind_calls == []
+    assert len(interface.transmit_calls) == 1
+
+    (tx_call_args, tx_call_kwargs) = interface.transmit_calls.pop()
+    assert tx_call_kwargs == {}
+    assert len(tx_call_args) == 1
+    frame = tx_call_args[0]
+
+    # The reply should have the source/destination swapped and the
+    # CR bit cleared.
+    assert isinstance(frame, AX25TestFrame), "Not a test frame"
+    assert frame.header.cr == False
+    assert frame.header.destination == AX25Address("VK4MSL", ssid=7)
+    assert frame.header.source == AX25Address("VK4MSL", ssid=5)
+    assert frame.payload == b"This is a test frame"
+
+
+def test_route_testframe_reply():
+    """
+    Test passing a test frame reply routes to the appropriate AX25Peer instance.
+    """
+    interface = DummyInterface()
+    station = AX25Station(interface=interface, callsign="VK4MSL-5")
+
+    # Stub out _on_test_frame
+    def stub_on_test_frame(*args, **kwargs):
+        assert False, "Should not have been called"
+
+    station._on_test_frame = stub_on_test_frame
+
+    # Inject a couple of peers
+    peer1 = DummyPeer(station, AX25Address("VK4MSL", ssid=7))
+    peer2 = DummyPeer(station, AX25Address("VK4BWI", ssid=7))
+    station._peers[peer1._address] = peer1
+    station._peers[peer2._address] = peer2
+
+    # Pass in the message
+    txframe = AX25TestFrame(
+        destination="VK4MSL-5",
+        source="VK4MSL-7",
+        cr=False,
+        payload=b"This is a test frame",
+    )
+    station._on_receive(frame=txframe)
+
+    # There should be no replies queued
+    assert interface.bind_calls == []
+    assert interface.unbind_calls == []
+    assert interface.transmit_calls == []
+
+    # This should have gone to peer1, not peer2
+    assert peer2.on_receive_calls == []
+    assert len(peer1.on_receive_calls) == 1
+    (rx_call_args, rx_call_kwargs) = peer1.on_receive_calls.pop()
+    assert rx_call_kwargs == {}
+    assert len(rx_call_args) == 1
+    assert rx_call_args[0] is txframe
+
+
+def test_route_incoming_msg():
+    """
+    Test passing a frame routes to the appropriate AX25Peer instance.
+    """
+    interface = DummyInterface()
+    station = AX25Station(interface=interface, callsign="VK4MSL-5")
+
+    # Stub out _on_test_frame
+    def stub_on_test_frame(*args, **kwargs):
+        assert False, "Should not have been called"
+
+    station._on_test_frame = stub_on_test_frame
+
+    # Inject a couple of peers
+    peer1 = DummyPeer(station, AX25Address("VK4MSL", ssid=7))
+    peer2 = DummyPeer(station, AX25Address("VK4BWI", ssid=7))
+    station._peers[peer1._address] = peer1
+    station._peers[peer2._address] = peer2
+
+    # Pass in the message
+    txframe = AX25UnnumberedInformationFrame(
+        destination="VK4MSL-5",
+        source="VK4BWI-7",
+        cr=True,
+        pid=0xAB,
+        payload=b"This is a test frame",
+    )
+    station._on_receive(frame=txframe)
+
+    # There should be no replies queued
+    assert interface.bind_calls == []
+    assert interface.unbind_calls == []
+    assert interface.transmit_calls == []
+
+    # This should have gone to peer2, not peer1
+    assert peer1.on_receive_calls == []
+    assert len(peer2.on_receive_calls) == 1
+    (rx_call_args, rx_call_kwargs) = peer2.on_receive_calls.pop()
+    assert rx_call_kwargs == {}
+    assert len(rx_call_args) == 1
+    assert rx_call_args[0] is txframe
diff --git a/tests/test_uint.py b/tests/test_uint.py
new file mode 100644
index 0000000..c3f173a
--- /dev/null
+++ b/tests/test_uint.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+
+from aioax25.uint import encode, decode
+from .hex import from_hex, hex_cmp
+
+
+def test_encode_zero():
+    """
+    Test encode generates at least one byte if given a zero.
+    """
+    hex_cmp(encode(0), b"\x00")
+
+
+def test_encode_le_nolen():
+    """
+    Test encode represents a little-endian integer in as few bytes as needed.
+    """
+    hex_cmp(encode(0x12345, big_endian=False), from_hex("45 23 01"))
+
+
+def test_encode_le_len():
+    """
+    Test encode will generate an integer of the required size.
+    """
+    hex_cmp(
+        encode(0x12345, big_endian=False, length=4), from_hex("45 23 01 00")
+    )
+
+
+def test_encode_le_truncate():
+    """
+    Test encode will truncate an integer to the required size.
+    """
+    hex_cmp(
+        encode(0x123456789A, big_endian=False, length=4),
+        from_hex("9a 78 56 34"),
+    )
+
+
+def test_encode_be():
+    """
+    Test we can encode big-endian integers.
+    """
+    hex_cmp(encode(0x11223344, big_endian=True), from_hex("11 22 33 44"))
+
+
+def test_decode_be():
+    """
+    Test we can decode big-endian integers.
+    """
+    assert decode(from_hex("11 22 33"), big_endian=True) == 0x112233
+
+
+def test_decode_le():
+    """
+    Test we can decode little-endian integers.
+    """
+    assert decode(from_hex("11 22 33"), big_endian=False) == 0x332211