Repository: pydicom/pynetdicom

Issue:
Add support for IPv6
**Describe the bug**
Can't connect to hostname with only AAAA-record. It seems AF_INET sockets are hardcoded.

**Expected behavior**
Working DICOM transport

**Steps To Reproduce**
1. Have a DICOM device on IPv6-only with AAAA-record (e.g. honey.synnack.net)
2. Try to connect

Hints from issue comments:
@scaramallion: IPv6 is not supported

Solution patch:
diff --git a/README.rst b/README.rst
index 35729ef04..17deed24d 100644
--- a/README.rst
+++ b/README.rst
@@ -31,42 +31,38 @@
 pynetdicom
 ==========
 
-A Python implementation of the `DICOM <https://www.dicomstandard.org>`_
-networking protocol, originally based on (legacy)
+A Python implementation of the `DICOM <https://www.dicomstandard.org>`_ networking
+protocol, originally based on (legacy)
 `pynetdicom <https://github.com/patmun/pynetdicom_legacy>`_.
 
 
 Description
 -----------
 
-`DICOM <https://www.dicomstandard.org>`_ is the international standard for
-medical images and related information. It defines the formats and communication
-protocols for media exchange in radiology, cardiology, radiotherapy and other
-medical domains.
+`DICOM <https://www.dicomstandard.org>`_ is the international standard for medical
+images and related information. It defines the formats and communication protocols for
+media exchange in radiology, cardiology, radiotherapy and other medical domains.
 
-*pynetdicom* is a pure Python package that implements the DICOM
-networking protocol. Working with
-`pydicom <https://github.com/pydicom/pydicom>`_, it allows the easy creation
-of DICOM *Service Class Users* (SCUs) and *Service Class Providers* (SCPs).
+*pynetdicom* is a pure Python package that implements the DICOM networking protocol.
+Working with `pydicom <https://github.com/pydicom/pydicom>`_, it allows the easy
+creation of DICOM *Service Class Users* (SCUs) and *Service Class Providers* (SCPs).
 
 *pynetdicom's* main user class is
 `AE <https://pydicom.github.io/pynetdicom/stable/reference/generated/pynetdicom.ae.ApplicationEntity.html>`_
 and is used to represent a DICOM Application Entity. With it you can:
 
-- Start the application as an SCP by specifying the supported presentation
-  contexts then calling
+- Start the application as an SCP by specifying the supported presentation contexts
+  then calling
   `AE.start_server() <https://pydicom.github.io/pynetdicom/stable/reference/generated/pynetdicom.ae.ApplicationEntity.html#pynetdicom.ae.ApplicationEntity.start_server>`_
   and waiting for incoming association requests
-- Use the application as an SCU by specifying the presentation contexts you
-  want the peer SCP to support, then requesting an association
-  via the
+- Use the application as an SCU by specifying the presentation contexts you want the
+  peer SCP to support, then requesting an association via the
   `AE.associate() <https://pydicom.github.io/pynetdicom/stable/reference/generated/pynetdicom.ae.ApplicationEntity.html#pynetdicom.ae.ApplicationEntity.associate>`_
   method, which returns an
   `Association <https://pydicom.github.io/pynetdicom/stable/reference/generated/pynetdicom.association.Association.html#pynetdicom.association.Association>`_
   thread.
 
-Once associated, the services available to the association can
-be used by sending
+Once associated, the services available to the association can be used by sending
 `DIMSE-C <https://dicom.nema.org/medical/dicom/current/output/html/part07.html#chapter_9>`_
 and
 `DIMSE-N <https://dicom.nema.org/medical/dicom/current/output/html/part07.html#chapter_10>`_
@@ -104,8 +100,8 @@ Using conda:
 
     conda install -c conda-forge pynetdicom
 
-For more detailed instructions, including how to install the
-current development version, please see the `installation guide
+For more detailed instructions, including how to install the current development
+version, please see the `installation guide
 <https://pydicom.github.io/pynetdicom/stable/tutorials/installation.html>`_.
 
 
@@ -114,8 +110,8 @@ Supported DIMSE Services
 SCU Services
 ~~~~~~~~~~~~
 
-When the AE is acting as an SCU and an association has been established with a
-peer SCP, the following DIMSE-C and -N services are available:
+When the AE is acting as an SCU and an association has been established with a peer
+SCP, the following DIMSE-C and -N services are available:
 
 .. _assoc: https://pydicom.github.io/pynetdicom/stable/reference/generated/pynetdicom.association.Association.html
 .. _echo: https://pydicom.github.io/pynetdicom/stable/reference/generated/pynetdicom.association.Association.html#pynetdicom.association.Association.send_c_echo
@@ -170,8 +166,8 @@ for more information.
 SCP Services
 ~~~~~~~~~~~~
 
-When the AE is acting as an SCP the following DIMSE-C and -N services are
-available to the peer once an association has been established:
+When the AE is acting as an SCP the following DIMSE-C and -N services are available to
+the peer once an association has been established:
 
 .. _hecho: https://pydicom.github.io/pynetdicom/stable/reference/generated/pynetdicom._handlers.doc_handle_echo.html
 .. _hfind: https://pydicom.github.io/pynetdicom/stable/reference/generated/pynetdicom._handlers.doc_handle_find.html
@@ -212,27 +208,24 @@ available to the peer once an association has been established:
 +----------------+----------------------------+---------------------------------+
 
 
-With the exception of the C-ECHO service, a user-defined callable function,
-*handler*, must be bound to the corresponding
+With the exception of the C-ECHO service, a user-defined callable function, *handler*,
+must be bound to the corresponding
 `intervention event <https://pydicom.github.io/pynetdicom/stable/user/events#intervention-events>`_
-in order to complete a DIMSE service request. Events
-can be imported with ``from pynetdicom import evt`` and a handler can be
-bound to an event prior to starting an association through the *evt_handlers*
-keyword arguments in
+in order to complete a DIMSE service request. Events can be imported with
+``from pynetdicom import evt`` and a handler can be bound to an event prior to starting
+an association through the *evt_handlers* keyword arguments in
 `AE.start_server() <https://pydicom.github.io/pynetdicom/stable/reference/generated/pynetdicom.ae.ApplicationEntity.html#pynetdicom.ae.ApplicationEntity.start_server>`_
 and
 `AE.associate() <https://pydicom.github.io/pynetdicom/stable/reference/generated/pynetdicom.ae.ApplicationEntity.html#pynetdicom.ae.ApplicationEntity.associate>`_.
 
-When an event occurs the *handler* function is called and passed a single
-parameter, *event*, which is an
+When an event occurs the *handler* function is called and passed a single parameter,
+*event*, which is an
 `Event <https://pydicom.github.io/pynetdicom/stable/reference/generated/pynetdicom.events.Event.html>`_
-object whose specific attributes
-are dependent on the type of event that occurred. Handlers bound to
-intervention events must  return or yield certain values. See the
+object whose specific attributes are dependent on the type of event that occurred.
+Handlers bound to intervention events must  return or yield certain values. See the
 `handler documentation <https://pydicom.github.io/pynetdicom/stable/reference/events>`_
-for information on what attributes and properties are available in ``Event``
-for each event type and the expected returns/yields for the
-corresponding handlers.
+for information on what attributes and properties are available in ``Event`` for each
+event type and the expected returns/yields for the corresponding handlers.
 
 Applications
 ------------
@@ -258,8 +251,8 @@ are available in the documentation.
 
 Echo SCU
 ~~~~~~~~
-Send a C-ECHO request to a Verification SCP (at TCP/IP address
-*addr*, listen port number *port*):
+Send a C-ECHO request to a Verification SCP (at TCP/IP address *addr*, listen port
+number *port*):
 
 .. code-block:: python
 
@@ -287,9 +280,9 @@ Send a C-ECHO request to a Verification SCP (at TCP/IP address
 
 Echo SCP
 ~~~~~~~~
-Create a blocking Echo SCP on port ``11112`` (you may optionally
-bind a handler to the ``evt.EVT_C_ECHO`` event if you want to return something
-other than an ``0x0000`` *Success* status):
+Create a blocking Echo SCP on port ``11112`` (you may optionally bind a handler to the
+``evt.EVT_C_ECHO`` event if you want to return something other than an ``0x0000``
+*Success* status):
 
 .. code-block:: python
 
@@ -352,8 +345,8 @@ requestor's address and port number and the timestamp for the event.
             requestor = event.assoc.requestor
             timestamp = event.timestamp.strftime("%Y-%m-%d %H:%M:%S")
             msg = (
-                "Received C-ECHO service request from ({}, {}) at {}"
-                .format(requestor.address, requestor.port, timestamp)
+                "Received C-ECHO service request from "
+                f"({requestor.address}, {requestor.port}) at {timestamp}"
             )
             logger.info(msg)
 
@@ -396,12 +389,15 @@ SCP (at TCP/IP address *addr*, listen port number *port*):
         # Or we can use inbuilt objects like CTImageStorage.
         # The requested presentation context's transfer syntaxes can also
         #   be specified using a str/UID or list of str/UIDs
-        ae.add_requested_context(CTImageStorage,
-                                 transfer_syntax=ImplicitVRLittleEndian)
+        ae.add_requested_context(
+            CTImageStorage,
+            transfer_syntax=ImplicitVRLittleEndian,
+        )
         # Adding a presentation context with multiple transfer syntaxes
-        ae.add_requested_context(MRImageStorage,
-                                 transfer_syntax=[ImplicitVRLittleEndian,
-                                                  '1.2.840.10008.1.2.1'])
+        ae.add_requested_context(
+            MRImageStorage,
+            transfer_syntax=[ImplicitVRLittleEndian, '1.2.840.10008.1.2.1'],
+        )
 
         assoc = ae.associate(addr, port)
         if assoc.is_established:
diff --git a/docs/changelog/v3.0.0.rst b/docs/changelog/v3.0.0.rst
index 6e6ab4751..92272fb81 100644
--- a/docs/changelog/v3.0.0.rst
+++ b/docs/changelog/v3.0.0.rst
@@ -8,6 +8,7 @@ The major breaking changes with the version 3.0 release are:
 * Minimum required pydicom version is 3.0
 * Changed :attr:`~pynetdicom.presentation.StoragePresentationContexts` to match DCMTK
   implementation
+* Various small changes required to support IPv6
 
 Changes
 -------
@@ -18,6 +19,36 @@ Changes
 * Updated SOP classes to version 2025b of the DICOM Standard
 * P-DATA PDUs received during association release are now added to the normal DIMSE
   messaging queue
+* The following changes have been made in order to support IPv6 connections:
+
+  * Changed :meth:`AE.associate()<pynetdicom.ae.ApplicationEntity.associate>` to accept
+    either a str or tuple[str, int, int] as the `addr` parameter.
+  * Changed :meth:`AE.associate()<pynetdicom.ae.ApplicationEntity.associate>` to accept
+    either a tuple[str, int] or tuple[str, int, int, int] as the `bind_address` keyword
+    parameter.
+  * Changed :meth:`AE.make_server()<pynetdicom.ae.ApplicationEntity.associate>` and
+    :meth:`AE.start_server()<pynetdicom.ae.ApplicationEntity.associate>` to
+    accept either a tuple[str, int] or tuple[str, int, int, int] as the `address`
+    parameter.
+  * Added :class:`~pynetdicom.transport.AddressInformation`
+  * :attr:`ServiceUser.address<pynetdicom.association.ServiceUser.address>` and
+    :attr:`ServiceUser.port<pynetdicom.association.ServiceUser.port>` are now
+    read-only properties.
+  * Added :attr:`ServiceUser.address_info
+    <pynetdicom.association.ServiceUser.address_info>`.
+  * Changed :meth:`pynetdicom.transport.AssociationSocket` to accept an
+    :class:`~pynetdicom.transport.AddressInformation` instance as the `address`
+    keyword parameter.
+  * :attr:`RequestHandler.local<pynetdicom.transport.RequestHandler.local>` and
+    :attr:`RequestHandler.remote<pynetdicom.transport.RequestHandler.remote>` now
+    return an :class:`~pynetdicom.transport.AddressInformation` instance
+  * Added :attr:`T_CONNECT.address_info<pynetdicom.transport.T_CONNECT.address_info>`
+  * :attr:`A_ASSOCIATE.calling_presentation_address
+    <pynetdicom.pdu_primitives.A_ASSOCIATE.calling_presentation_address>` and
+    :attr:`A_ASSOCIATE.called_presentation_address
+    <pynetdicom.pdu_primitives.A_ASSOCIATE.called_presentation_address>` are both now
+    set using an :class:`~pynetdicom.transport.AddressInformation` instance
+
 
 Enhancements
 ------------
@@ -26,6 +57,8 @@ Enhancements
   absent from the N-CREATE-RQ can now be done by adding it to the returned *Attribute
   List* dataset (it'll be removed from the dataset prior to sending) (:issue:`995`)
 * Added debugging handlers for the remaining DIMSE-N messages
+* Added :attr:`_config.DEFAULT_BIND_ADDRESS<pynetdicom._config.DEFAULT_BIND_ADDRESS>`
+* Added support for IPv6 connections (:issue:`984`)
 
 
 Fixes
diff --git a/docs/reference/config.rst b/docs/reference/config.rst
index 9e36a7478..81f37030e 100644
--- a/docs/reference/config.rst
+++ b/docs/reference/config.rst
@@ -11,6 +11,7 @@ Configuration Options (:mod:`pynetdicom._config`)
    :toctree: generated/
 
    CODECS
+   DEFAULT_BIND_ADDRESS
    ENFORCE_UID_CONFORMANCE
    LOG_HANDLER_LEVEL
    LOG_REQUEST_IDENTIFIERS
diff --git a/docs/reference/transport.rst b/docs/reference/transport.rst
index 11ae9f8d4..25bb45ab5 100644
--- a/docs/reference/transport.rst
+++ b/docs/reference/transport.rst
@@ -17,6 +17,7 @@ communication with peer AEs.
 
    AssociationSocket
    AssociationServer
+   AddressInformation
    RequestHandler
    ThreadedAssociationServer
    T_CONNECT
diff --git a/docs/tutorials/create_scu.rst b/docs/tutorials/create_scu.rst
index a557fd852..e15c706cd 100644
--- a/docs/tutorials/create_scu.rst
+++ b/docs/tutorials/create_scu.rst
@@ -13,13 +13,13 @@ This tutorial is intended for people who are new to *pynetdicom*. In it you'll:
 * Learn how to perform some basic troubleshooting of associations
 * Modify your AE to be an Echo SCU
 
-The tutorial is written for *pynetdicom* 2.0 and higher, which supports Python
-3.7+. You can tell which version of *pynetdicom* you have by running
+The tutorial is written for *pynetdicom* 3.0 and higher, which supports Python
+3.10+. You can tell which version of *pynetdicom* you have by running
 the following command::
 
     $ python -m pynetdicom --version
 
-If you get an error or if your version is earlier than 2.0, then install or
+If you get an error or if your version is earlier than 3.0, then install or
 upgrade *pynetdicom* by following the instructions in the
 :doc:`installation guide</tutorials/installation>`. For
 this tutorial we'll also be using the :doc:`echoscp<../apps/echoscp>`
diff --git a/docs/user/ae.rst b/docs/user/ae.rst
index 74dc8ece9..b0d952eba 100644
--- a/docs/user/ae.rst
+++ b/docs/user/ae.rst
@@ -221,8 +221,12 @@ Specifying the network port
 In general it shouldn't be necessary to specify the port when acting as an SCU,
 as by default *pynetdicom* will use the first port available. To specify the
 port number manually you can use the *bind_address* keyword parameter when
-requesting an association, which takes a 2-tuple of (:class:`str` *host*,
-:class:`int` *port*):
+requesting an association, which takes:
+
+* For IPv4 or IPv6 a 2-tuple of (:class:`str` *host*, :class:`int` *port*), with the
+  `flowinfo` and `scope_id` defaulting to ``0`` for IPv6, or,
+* For IPv6 a 4-tuple of (:class:`str` *host*, :class:`int` *port*, :class:`int`
+  flowinfo, :class:`int` scope_id)
 
 .. doctest::
 
@@ -230,6 +234,9 @@ requesting an association, which takes a 2-tuple of (:class:`str` *host*,
     >>> ae = AE()
     >>> ae.add_requested_context('1.2.840.10008.1.1')
     >>> assoc = ae.associate("127.0.0.1", 11112, bind_address=("127.0.0.1", 11113))  # doctest: +SKIP
+    >>> assoc.release()
+    >>> assoc = ae.associate("::1", 11112, bind_address=("::1", 11113))  # doctest: +SKIP
+    >>> assoc.release()
 
 .. note::
 
@@ -497,15 +504,22 @@ to see the requirements for implementations of the ``evt.EVT_USER_ID`` handler.
 Specifying the bind address
 ...........................
 The bind address for the server socket is specified by the *address*
-parameter to :meth:`~pynetdicom.ae.ApplicationEntity.start_server` as
-(str *host*, int *port*).
+parameter to :meth:`~pynetdicom.ae.ApplicationEntity.start_server` as:
+
+* For IPv4 or IPv6 a 2-tuple of (:class:`str` *host*, :class:`int` *port*), with the
+  `flowinfo` and `scope_id` defaulting to ``0`` for IPv6, or,
+* For IPv6 a 4-tuple of (:class:`str` *host*, :class:`int` *port*, :class:`int`
+  flowinfo, :class:`int` scope_id)
 
 .. code-block:: python
 
     >>> from pynetdicom import AE
     >>> ae = AE()
     >>> ae.add_supported_context('1.2.840.10008.1.1')
-    >>> ae.start_server(("127.0.0.1", 11112))
+    >>> server = ae.start_server(("127.0.0.1", 11112), block=False)
+    >>> server.shutdown()
+    >>> server = ae.start_server(("::1", 11112), block=False)
+    >>> server.shutdown()
 
 
 Association
diff --git a/docs/user/association.rst b/docs/user/association.rst
index 7a97dd875..f6f314c90 100644
--- a/docs/user/association.rst
+++ b/docs/user/association.rst
@@ -23,14 +23,15 @@ method, which returns an :class:`Association` thread:
     ae = AE()
     ae.add_requested_context(Verification)
 
-    # Associate with the peer at IP address 127.0.0.1 and port 11112
+    # Associate with the peer at IPv4 address 127.0.0.1 and port 11112
+    #   IPv6 is also supported
     assoc = ae.associate("127.0.0.1", 11112)
 
     # Release the association
     if assoc.is_established:
         assoc.release()
 
-This sends an association request to the IP address ``127.0.0.1`` on port
+This sends an association request to the IPv4 address ``127.0.0.1`` on port
 ``11112`` with the request containing the presentation contexts from
 :attr:`AE.requested_contexts<pynetdicom.ae.ApplicationEntity.requested_contexts>`
 and the default *Called AE Title* parameter of ``'ANY-SCP'``.
@@ -96,7 +97,7 @@ also acting as a Storage SCP), plus a *User Identity Negotiation* item:
     ae = AE()
     # Contexts supported as a Storage SCP - requires Role Selection
     #   Note that we are limited to a maximum of 128 contexts.
-    #   StoragePresentationContexts includes 120, it is therefore 
+    #   StoragePresentationContexts includes 120, it is therefore
     #   possible to add 8 additional presentation contexts if needed.
     ae.requested_contexts = StoragePresentationContexts
     # Contexts proposed as a QR SCU
@@ -343,7 +344,7 @@ method:
     ae = AE()
     ae.add_supported_context(Verification)
 
-    # Listen for association requests
+    # Listen for association requests on a IPv4 address, IPv6 is also supported
     ae.start_server(("127.0.0.1", 11112))
 
 The above is suitable as an implementation of the Verification Service
diff --git a/pynetdicom/_config.py b/pynetdicom/_config.py
index 5bee2af08..13ebba0d3 100644
--- a/pynetdicom/_config.py
+++ b/pynetdicom/_config.py
@@ -1,6 +1,7 @@
 """pynetdicom configuration options"""
 
 from typing import Callable, Any
+import socket
 
 from pynetdicom._validators import validate_ae, validate_ui
 
@@ -26,8 +27,6 @@
 ENFORCE_UID_CONFORMANCE: bool = False
 """Enforce UID conformance
 
-.. versionadded:: 1.3
-
 If ``True`` then UIDs will be checked to ensure they're conformant to the
 DICOM Standard and if not then an appropriate response sent, otherwise
 UIDs will only be checked to ensure they're no longer then 64 characters and
@@ -46,8 +45,6 @@
 USE_SHORT_DIMSE_AET: bool = True
 """Use short AE titles in DIMSE messages.
 
-.. versionadded:: 1.5
-
 If ``False`` then elements with a VR of AE in DIMSE messages will be padded
 with trailing spaces up to the maximum allowable length (16 bytes), otherwise
 no padding will be added.
@@ -65,8 +62,6 @@
 LOG_RESPONSE_IDENTIFIERS: bool = True
 """Log incoming C-FIND, C-GET and C-MOVE response *Identifier* datasets.
 
-.. versionadded:: 1.5
-
 If ``True`` then the *Identifier* datasets received in Pending
 responses to C-FIND, C-GET and C-MOVE requests will be logged.
 
@@ -83,8 +78,6 @@
 LOG_REQUEST_IDENTIFIERS: bool = True
 """Log incoming C-FIND, C-GET and C-MOVE request *Identifier* datasets.
 
-.. versionadded:: 1.5
-
 If ``True`` then the *Identifier* datasets received in
 C-FIND, C-GET and C-MOVE requests will be logged.
 
@@ -331,3 +324,32 @@
 >>> from pynetdicom import _config
 >>> _config.UNRESTRICTED_STORAGE_SERVICE = True
 """
+
+
+DEFAULT_BIND_ADDRESS: dict[socket.AddressFamily, tuple[str, int]] = {
+    socket.AF_INET: ("0.0.0.0", 0),
+    socket.AF_INET6: ("::0", 0),
+}
+"""The default address and port to use for binding client sockets in
+:meth:`AE.associate()<pynetdicom.ae.ApplicationEntity.associate>`.
+
+.. versionadded:: 3.0
+
+Note that the default addresses mean that the client socket may be bound on any
+available interface that can viably route to the remote. This may be a security
+consideration for listen sockets on a server, however the client sockets used by
+:meth:`AE.associate()<pynetdicom.ae.ApplicationEntity.associate>` connect directly to
+the specified remote and do not listen.
+
+Default: ``("0.0.0.0", 0)`` for IPv4 and ``("::0", 0)`` for IPv6.
+
+Examples
+--------
+
+Change the default bind addresses for IPv4 and IPv6.
+
+>>> import socket
+>>> from pynetdicom import _config
+>>> _config.DEFAULT_BIND_ADDRESS[socket.AF_INET] = ("192.168.1.2", 0)
+>>> _config.DEFAULT_BIND_ADDRESS[socket.AF_INET6] = ("::1", 11112)
+"""
diff --git a/pynetdicom/_globals.py b/pynetdicom/_globals.py
index 69f18d3da..52ffe9508 100644
--- a/pynetdicom/_globals.py
+++ b/pynetdicom/_globals.py
@@ -1,7 +1,5 @@
 """Global variables for pynetdicom."""
 
-from typing import List, Optional, Union
-
 from pydicom.uid import UID
 
 
@@ -13,7 +11,7 @@
 # DICOM Application Context Name - see Part 7, Annex A.2.1
 APPLICATION_CONTEXT_NAME: str = "1.2.840.10008.3.1.1.1"
 
-DEFAULT_TRANSFER_SYNTAXES: List[str] = [
+DEFAULT_TRANSFER_SYNTAXES: list[str] = [
     "1.2.840.10008.1.2",  # Implicit VR Little Endian,
     "1.2.840.10008.1.2.1",  # Explicit VR Little Endian,
     "1.2.840.10008.1.2.1.99",  # Deflated Explicit VR Little Endian
@@ -27,7 +25,7 @@
 * Explicit VR Big Endian (retired)
 """
 
-ALL_TRANSFER_SYNTAXES: List[str] = [
+ALL_TRANSFER_SYNTAXES: list[str] = [
     "1.2.840.10008.1.2",  # Implicit VR Little Endian,
     "1.2.840.10008.1.2.1",  # Explicit VR Little Endian,
     "1.2.840.10008.1.2.1.99",  # Deflated Explicit VR Little Endian
@@ -136,8 +134,4 @@
 STATUS_UNKNOWN: str = "Unknown"
 
 
-# The default address that client sockets are bound to
-BIND_ADDRESS = ("", 0)
-
-
-OptionalUIDType = Optional[Union[str, bytes, UID]]
+OptionalUIDType = str | bytes | UID | None
diff --git a/pynetdicom/acse.py b/pynetdicom/acse.py
index 51a7b0306..44617827d 100644
--- a/pynetdicom/acse.py
+++ b/pynetdicom/acse.py
@@ -821,16 +821,10 @@ def send_request(self) -> None:
         primitive.calling_ae_title = self.requestor.ae_title
         # Called AE Title is the destination DICOM AE title
         primitive.called_ae_title = self.acceptor.ae_title
-        # The TCP/IP address of the source, pynetdicom includes port too
-        primitive.calling_presentation_address = (
-            cast(str, self.requestor.address),
-            cast(int, self.requestor.port),
-        )
-        # The TCP/IP address of the destination, pynetdicom includes port too
-        primitive.called_presentation_address = (
-            cast(str, self.acceptor.address),
-            cast(int, self.acceptor.port),
-        )
+        # The TCP/IP address info of the source
+        primitive.calling_presentation_address = self.requestor.address_info
+        # The TCP/IP address info of the destination
+        primitive.called_presentation_address = self.acceptor.address_info
         # Proposed presentation contexts
         primitive.presentation_context_definition_list = (
             self.requestor.requested_contexts
diff --git a/pynetdicom/ae.py b/pynetdicom/ae.py
index 060ccfbff..19d7d144d 100644
--- a/pynetdicom/ae.py
+++ b/pynetdicom/ae.py
@@ -27,13 +27,13 @@
     AssociationSocket,
     AssociationServer,
     ThreadedAssociationServer,
+    AddressInformation,
 )
 from pynetdicom.utils import make_target, set_ae, decode_bytes, set_uid
 from pynetdicom._globals import (
     MODE_REQUESTOR,
     DEFAULT_MAX_LENGTH,
     DEFAULT_TRANSFER_SYNTAXES,
-    BIND_ADDRESS,
 )
 
 
@@ -457,13 +457,13 @@ def ae_title(self, value: str) -> None:
 
     def associate(
         self,
-        addr: str,
+        addr: str | tuple[str, int, int],
         port: int,
         contexts: ListCXType | None = None,
         ae_title: str = "ANY-SCP",
         max_pdu: int = DEFAULT_MAX_LENGTH,
         ext_neg: list[_UI] | None = None,
-        bind_address: tuple[str, int] = BIND_ADDRESS,
+        bind_address: tuple[str, int] | tuple[str, int, int, int] | None = None,
         tls_args: tuple[SSLContext, str] | None = None,
         evt_handlers: list[EventHandlerType] | None = None,
     ) -> Association:
@@ -476,26 +476,35 @@ def associate(
         before sending any messages. The returned thread will only be running
         if the association was established.
 
-        .. versionchanged:: 1.2
-
-            Added `bind_address` and `tls_arg` keyword parameters
-
-        .. versionchanged:: 1.3
+        .. versionchanged:: 2.0
 
-            Added `evt_handlers` keyword parameter
+            `ae_title` should now be :class:`str`
 
-        .. versionchanged:: 1.5
+        .. versionchanged:: 3.0
 
-            `evt_handlers` now takes a list of 2- or 3-tuples
+            `addr` can be either an IPv4 or IPv6 address str such as ``"192.168.1.2"``
+            or ``"::1"``, or a tuple containing an IPv6 address such as
+            ``("2a00:1450:4001:81c::200e", 0, 0)`` where the last two items are the
+            `flowinfo` and `scope_id`.
 
-        .. versionchanged:: 2.0
+        .. versionchanged:: 3.0
 
-            * `ae_title` should now be :class:`str`
+            `bind_address` can be either a tuple containing IPv4 or IPv6 address
+            str and port number such as ``("192.168.1.2", 11112)`` or ``("::1", 0)``,
+            or a tuple containing an IPv6 address str and port number such as
+            ``("2a00:1450:4001:81c::200e", 11112, 0, 0)`` where the last two items are
+            the `flowinfo` and `scope_id`.
 
         Parameters
         ----------
-        addr : str
-            The peer AE's TCP/IP address.
+        addr : str | tuple[str, int, int]
+            The peer AE's TCP/IP address, as one of the following:
+
+            * `str`: An IPv4 or IPv6 address, such as ``"192.168.1.2"`` or
+              ``"2a00:1450:4001:81c::200e"``. If using IPv6 then `flowinfo` and
+              `scope_id` will default to ``0``.
+            * `tuple[str, int, int]`: An IPv6 address as ``(address, flowinfo,
+              scope_id)``.
         port : int
             The peer AE's listen port number.
         contexts : list of presentation.PresentationContext, optional
@@ -519,9 +528,14 @@ def associate(
             * :class:`~SOPClassCommonExtendedNegotiation` (0 to N items)
             * :class:`~SOPClassExtendedNegotiation` (0 to N items)
             * :class:`~UserIdentityNegotiation` (0 or 1 item)
-        bind_address : 2-tuple, optional
-            The (host, port) to bind the association's communication socket
-            to, default ``("", 0)``.
+        bind_address : tuple[str, int] | tuple[str, int, int, int], optional
+            The address to bind the association's communication socket to. For IPv4 or
+            IPv6 this may be the ``(str: address, int: port)``, with the `flowinfo` and
+            `scope_id` defaulting to ``0`` for IPv6. Alternatively for IPv6 this
+            may be the ``(str: address, int: port, int: flowinfo, int: scope_id)``.
+            Default: the value in :attr:`_config.DEFAULT_BIND_ADDRESS
+            <pynetdicom._config.DEFAULT_BIND_ADDRESS>` corresponding to the
+            address family of `addr`.
         tls_args : 2-tuple, optional
             If TLS is required then this should be a 2-tuple containing a
             (`ssl_context`, `server_hostname`), where `ssl_context` is the
@@ -554,11 +568,46 @@ def associate(
             :attr:`~pynetdicom.ae.ApplicationEntity.requested_contexts` is
             empty).
         """
-        if not isinstance(addr, str):
-            raise TypeError("'addr' must be a valid IPv4 string")
+        if not isinstance(addr, (str, tuple)):
+            raise TypeError("'addr' must be str or tuple[str, int, int]")
+
+        if isinstance(addr, tuple) and (
+            not isinstance(addr[0], str)
+            or not isinstance(addr[1], int)
+            or not isinstance(addr[2], int)
+        ):
+            raise TypeError("'addr' must be str or tuple[str, int, int]")
 
         if not isinstance(port, int):
-            raise TypeError("'port' must be a valid port number")
+            raise TypeError("'port' must be int")
+
+        remote_address = AddressInformation.from_addr_port(addr, port)
+
+        if bind_address is None:
+            bind_address = _config.DEFAULT_BIND_ADDRESS[remote_address.address_family]
+
+        if not isinstance(bind_address, (tuple, list)):
+            raise TypeError(
+                "'bind_address' must be tuple[str, int] or tuple[str, int, int, int]"
+            )
+
+        if (
+            len(bind_address) not in (2, 4)
+            or not isinstance(bind_address[0], str)
+            or not isinstance(bind_address[1], int)
+        ):
+            raise TypeError(
+                "'bind_address' must be tuple[str, int] or tuple[str, int, int, int]"
+            )
+
+        if len(bind_address) == 4 and (
+            not isinstance(bind_address[2], int) or not isinstance(bind_address[3], int)
+        ):
+            raise TypeError(
+                "'bind_address' must be tuple[str, int] or tuple[str, int, int, int]"
+            )
+
+        local_address = AddressInformation.from_tuple(bind_address)
 
         # Association
         assoc = Association(self, MODE_REQUESTOR)
@@ -568,18 +617,17 @@ def associate(
         assoc.name = f"RequestorThread@{timestamp}"
 
         # Setup the association's communication socket
-        sock = self._create_socket(assoc, bind_address, tls_args)
+        sock = self._create_socket(assoc, local_address, tls_args)
         assoc.set_socket(sock)
 
         # Association Acceptor object -> remote AE
         # `ae_title` validation is performed by the ServiceUser
         assoc.acceptor.ae_title = ae_title
-        assoc.acceptor.address = addr
-        assoc.acceptor.port = port
+        assoc.acceptor.address_info = remote_address
 
         # Association Requestor object -> local AE
-        assoc.requestor.address = sock.get_local_addr()
-        assoc.requestor.port = bind_address[1]
+        # Nominal address info - will get updated by AssociationSocket.connect()
+        assoc.requestor.address_info = local_address
         assoc.requestor.ae_title = self.ae_title
         assoc.requestor.maximum_length = max_pdu
         assoc.requestor.implementation_class_uid = self.implementation_class_uid
@@ -626,14 +674,13 @@ def associate(
     def _create_socket(
         self,
         assoc: Association,
-        address: tuple[str, int],
+        address: AddressInformation,
         tls_args: tuple[SSLContext, str] | None,
     ) -> AssociationSocket:
         """Create an :class:`~pynetdicom.transport.AssociationSocket` for the
         current association.
-
-        .. versionadded:: 1.5
         """
+        # Creates and binds to `address` but doesn't connect
         sock = AssociationSocket(assoc, address=address)
         sock.tls_args = tls_args
         return sock
@@ -752,7 +799,7 @@ def implementation_version_name(self, value: str | None) -> None:
 
     def make_server(
         self,
-        address: tuple[str, int],
+        address: tuple[str, int] | tuple[str, int, int, int],
         ae_title: str | None = None,
         contexts: ListCXType | None = None,
         ssl_context: SSLContext | None = None,
@@ -762,8 +809,6 @@ def make_server(
     ) -> _T | ThreadedAssociationServer:
         """Return an association server.
 
-        .. versionadded:: 1.5
-
         Allows the use of a custom association server class.
 
         Accepts the same parameters as :meth:`start_server`. Additional keyword
@@ -773,6 +818,14 @@ def make_server(
 
             `ae_title` should now be :class:`str`
 
+        .. versionchanged:: 3.0
+
+            `address` can be either a tuple containing IPv4 or IPv6 address
+            str and port number such as ``("192.168.1.2", 11112)`` or ``("::1", 0)``,
+            or a tuple containing an IPv6 address str and port number such as
+            ``("2a00:1450:4001:81c::200e", 11112, 0, 0)`` where the last two items are
+            the `flowinfo` and `scope_id`.
+
         Parameters
         ----------
         server_class : object, optional
@@ -1314,7 +1367,7 @@ def shutdown(self) -> None:
 
     def start_server(
         self,
-        address: tuple[str, int],
+        address: tuple[str, int] | tuple[str, int, int, int],
         block: bool = True,
         ssl_context: SSLContext | None = None,
         evt_handlers: list[EventHandlerType] | None = None,
@@ -1323,34 +1376,34 @@ def start_server(
     ) -> ThreadedAssociationServer | None:
         """Start the AE as an association *acceptor*.
 
-        .. versionadded:: 1.2
-
         If set to non-blocking then a running
         :class:`~pynetdicom.transport.ThreadedAssociationServer`
         instance will be returned. This can be stopped using
         :meth:`~pynetdicom.transport.AssociationServer.shutdown`.
 
-        .. versionchanged:: 1.3
-
-            Added `evt_handlers` keyword parameter
-
-        .. versionchanged:: 1.4
-
-            Added `ae_title` and `contexts` keyword parameters
-
-        .. versionchanged:: 1.5
-
-            `evt_handlers` now takes a list of 2- or 3-tuples
-
         .. versionchanged:: 2.0
 
             `ae_title` should now be :class:`str`
 
+        .. versionchanged:: 3.0
+
+            `address` can be either a tuple containing IPv4 or IPv6 address
+            str and port number such as ``("192.168.1.2", 11112)`` or ``("::1", 0)``,
+            or a tuple containing an IPv6 address str and port number such as
+            ``("2a00:1450:4001:81c::200e", 11112, 0, 0)`` where the last two items are
+            the `flowinfo` and `scope_id`.
+
         Parameters
         ----------
-        address : tuple[str, int]
-            The ``(host: str, port: int)`` to use when listening for incoming
+        address : tuple[str, int] | tuple[str, int, int, int]
+            The host IP address and port number to use when listening for incoming
             association requests.
+
+            * `tuple[str, int]`: An IPv4 or IPv6 address and port number, such as
+              ``("192.168.1.2", 104)`` or ``("2a00:1450:4001:81c::200e", 11112)``.
+              If using IPv6 then `flowinfo` and `scope_id` will default to ``0``.
+            * `tuple[str, int, int, int]`: An IPv6 address as ``(address, port,
+              flowinfo, scope_id)``.
         block : bool, optional
             If ``True`` (default) then the server will be blocking, otherwise
             it will start the server in a new thread and be non-blocking.
diff --git a/pynetdicom/association.py b/pynetdicom/association.py
index d9a857379..52f8bc27a 100644
--- a/pynetdicom/association.py
+++ b/pynetdicom/association.py
@@ -82,6 +82,7 @@
     Verification,
 )
 from pynetdicom.status import code_to_category, STORAGE_SERVICE_CLASS_STATUS
+from pynetdicom.transport import AddressInformation
 from pynetdicom.utils import make_target, set_timer_resolution, set_ae, decode_bytes
 
 if TYPE_CHECKING:  # pragma: no cover
@@ -3630,13 +3631,18 @@ class ServiceUser:
         * Maximum PDU length
         * Implementation class UID
 
+    .. versionchanged:: 3.0
+
+        `address` and `port` have been changed to properties and added the
+        `address_info` attribute.
+
     Attributes
     ----------
-    address : str
-        The TCP/IP address of the AE.
-    port : int
-        The port number of the AE.
-    primitive : None or pdu_primitives.A_ASSOCIATE
+    address_info : pynetdicom.transport.AddressInformation | None
+        The connection properties of the local or remote AE. When the local AE is the
+        requestor the IP address and port should be considered nominal until the
+        connection with the remote is made.
+    primitive : pynetdicom.pdu_primitives.A_ASSOCIATE | None
         The A-ASSOCIATE primitive (request if mode is ``'requestor'``,
         accept/reject if mode is ``'acceptor'``) sent or received by the AE
         during association negotiation.
@@ -3663,8 +3669,8 @@ def __init__(self, assoc: Association, mode: str) -> None:
         self._mode: str = mode
         self._ae_title: str = ""
         self.primitive: A_ASSOCIATE | None = None
-        self.port: int | None = None
-        self.address: str | None = ""
+
+        self.address_info: AddressInformation | None = None
 
         # If Requestor this is the requested contexts, otherwise this is
         #   the supported contexts
@@ -3752,6 +3758,14 @@ def add_negotiation_item(self, item: _UI) -> None:
         except KeyError:
             raise TypeError("'item' is not a valid extended negotiation item")
 
+    @property
+    def address(self) -> str:
+        """Get the requestor or acceptor's IP address (if set)."""
+        if self.address_info is None:
+            raise ValueError("No address information has been set")
+
+        return self.address_info.address
+
     @property
     def ae_title(self) -> str:
         """Get or set the AE title.
@@ -4032,13 +4046,11 @@ def implementation_version_name(self, value: str | None) -> None:
 
     @property
     def info(self) -> dict[str, Any]:
-        """Return a :class:`dict` with information about the
-        :class:`ServiceUser`.
-        """
+        """Return a :class:`dict` with information about the :class:`ServiceUser`."""
         info = {
             "ae_title": self.ae_title,
-            "address": self.address,
-            "port": self.port,
+            "address": self.address if self.address_info else None,
+            "port": self.port if self.address_info else None,
             "mode": self.mode,
         }
         if not self.writeable:
@@ -4048,16 +4060,12 @@ def info(self) -> dict[str, Any]:
 
     @property
     def is_acceptor(self) -> bool:
-        """Return ``True`` if the :class:`ServiceUser` is the association
-        acceptor.
-        """
+        """Return ``True`` if the :class:`ServiceUser` is the association acceptor."""
         return self.mode == MODE_ACCEPTOR
 
     @property
     def is_requestor(self) -> bool:
-        """Return ``True`` if the :class:`ServiceUser` is the association
-        requestor.
-        """
+        """Return ``True`` if the :class:`ServiceUser` is the association requestor."""
         return self.mode == MODE_REQUESTOR
 
     @property
@@ -4114,6 +4122,14 @@ def mode(self) -> str:
         """
         return self._mode
 
+    @property
+    def port(self) -> int:
+        """Get the requestor or acceptor's port number (if set)."""
+        if self.address_info is None:
+            raise ValueError("No address information has been set")
+
+        return self.address_info.port
+
     @property
     def requested_contexts(self) -> list[PresentationContext]:
         """A :class:`list` of the requestor's requested presentation
diff --git a/pynetdicom/fsm.py b/pynetdicom/fsm.py
index 8d4023e8a..1fd29234b 100644
--- a/pynetdicom/fsm.py
+++ b/pynetdicom/fsm.py
@@ -17,7 +17,7 @@
     A_ABORT_RQ,
 )
 from pynetdicom.pdu_primitives import A_P_ABORT, A_ABORT
-from pynetdicom.transport import T_CONNECT, AssociationSocket
+from pynetdicom.transport import T_CONNECT, AssociationSocket, AddressInformation
 
 if TYPE_CHECKING:  # pragma: no cover
     from pynetdicom.dul import DULServiceProvider
@@ -264,9 +264,8 @@ def AE_4(dul: "DULServiceProvider") -> str:
 
     assoc = dul.assoc
     remote = assoc.acceptor if assoc.is_requestor else assoc.requestor
-
-    address = (remote.address, remote.port)
-    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": address})
+    conn_info = cast(AddressInformation, remote.address_info).as_tuple
+    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": conn_info})
 
     dul.kill_dul()
 
@@ -539,9 +538,8 @@ def AR_3(dul: "DULServiceProvider") -> str:
 
     assoc = dul.assoc
     remote = assoc.acceptor if assoc.is_requestor else assoc.requestor
-
-    address = (remote.address, remote.port)
-    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": address})
+    conn_info = cast(AddressInformation, remote.address_info).as_tuple
+    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": conn_info})
 
     dul.kill_dul()
 
@@ -601,9 +599,8 @@ def AR_5(dul: "DULServiceProvider") -> str:
 
     assoc = dul.assoc
     remote = assoc.acceptor if assoc.is_requestor else assoc.requestor
-
-    address = (remote.address, remote.port)
-    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": address})
+    conn_info = cast(AddressInformation, remote.address_info).as_tuple
+    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": conn_info})
 
     # Stop ARTIM timer
     dul.artim_timer.stop()
@@ -818,8 +815,8 @@ def AA_2(dul: "DULServiceProvider") -> str:
     assoc.dimse.msg_queue.put((None, None))
 
     remote = assoc.acceptor if assoc.is_requestor else assoc.requestor
-    address = (remote.address, remote.port)
-    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": address})
+    conn_info = cast(AddressInformation, remote.address_info).as_tuple
+    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": conn_info})
 
     dul.kill_dul()
 
@@ -861,8 +858,8 @@ def AA_3(dul: "DULServiceProvider") -> str:
     assoc.dimse.msg_queue.put((None, None))
 
     remote = assoc.acceptor if assoc.is_requestor else assoc.requestor
-    address = (remote.address, remote.port)
-    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": address})
+    conn_info = cast(AddressInformation, remote.address_info).as_tuple
+    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": conn_info})
 
     dul.kill_dul()
 
@@ -897,8 +894,8 @@ def AA_4(dul: "DULServiceProvider") -> str:
     assoc.dimse.msg_queue.put((None, None))
 
     remote = assoc.acceptor if assoc.is_requestor else assoc.requestor
-    address = (remote.address, remote.port)
-    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": address})
+    conn_info = cast(AddressInformation, remote.address_info).as_tuple
+    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": conn_info})
 
     # Issue A-P-ABORT indication primitive.
     primitive = A_P_ABORT()
@@ -935,9 +932,8 @@ def AA_5(dul: "DULServiceProvider") -> str:
 
     assoc = dul.assoc
     remote = assoc.acceptor if assoc.is_requestor else assoc.requestor
-
-    address = (remote.address, remote.port)
-    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": address})
+    conn_info = cast(AddressInformation, remote.address_info).as_tuple
+    evt.trigger(dul.assoc, evt.EVT_CONN_CLOSE, {"address": conn_info})
 
     # Stop ARTIM timer.
     dul.artim_timer.stop()
diff --git a/pynetdicom/pdu_primitives.py b/pynetdicom/pdu_primitives.py
index 8dabab6e5..5b7a35fd9 100644
--- a/pynetdicom/pdu_primitives.py
+++ b/pynetdicom/pdu_primitives.py
@@ -3,7 +3,7 @@
 """
 
 import logging
-from typing import Any, cast, Type, TypeAlias
+from typing import Any, cast, Type, TypeAlias, TYPE_CHECKING
 
 from pydicom.uid import UID
 
@@ -23,6 +23,10 @@
 from pynetdicom.utils import validate_uid, decode_bytes, set_ae, set_uid
 from pynetdicom._globals import DEFAULT_MAX_LENGTH
 
+if TYPE_CHECKING:  # pragma: no cover
+    from pynetdicom.transport import AddressInformation
+
+
 LOGGER = logging.getLogger(__name__)
 
 _PDUPrimitiveType: TypeAlias = "A_ASSOCIATE | A_RELEASE | A_ABORT | A_P_ABORT | P_DATA"
@@ -141,8 +145,8 @@ def __init__(self) -> None:
         self._result: int | None = None
         self._result_source: int | None = None
         self._diagnostic: int | None = None
-        self._calling_presentation_address: tuple[str, int] | None = None
-        self._called_presentation_address: tuple[str, int] | None = None
+        self._calling_presentation_address: "AddressInformation | None" = None
+        self._called_presentation_address: "AddressInformation | None" = None
         self._presentation_context_definition_list: list[PresentationContext] = []
         self._presentation_context_definition_results_list: list[
             PresentationContext
@@ -198,46 +202,37 @@ def called_ae_title(self, value: str) -> None:
         )
 
     @property
-    def called_presentation_address(self) -> tuple[str, int] | None:
+    def called_presentation_address(self) -> "AddressInformation | None":
         """Get or set the *Called Presentation Address* parameter.
 
+        .. versionchanged:: 3.0
+
+            Changed to take and return an
+            :class:`~pynetdicom.transport.AddressInformation` instance.
+
         Parameters
         ----------
-        value : (str, int) tuple
-            A tuple containing a valid TCP/IP address string and the port
-            number as an int
+        value : pynetdicom.transport.AddressInformation | None
+            IPv4 or IPv6 connection information.
         """
         return self._called_presentation_address
 
     @called_presentation_address.setter
-    def called_presentation_address(self, value: tuple[str, int] | None) -> None:
+    def called_presentation_address(self, value: "AddressInformation | None") -> None:
         """Set the Called Presentation Address parameter."""
         # pylint: disable=attribute-defined-outside-init
-        if isinstance(value, tuple):
-            if (
-                len(value) == 2
-                and isinstance(value[0], str)
-                and isinstance(value[1], int)
-            ):
-                self._called_presentation_address = value
-            else:
-                LOGGER.error(
-                    "A_ASSOCIATE.called_presentation_address must "
-                    "be (str, int) tuple"
-                )
-                raise TypeError(
-                    "A_ASSOCIATE.called_presentation_address "
-                    "must be (str, int) tuple"
-                )
-        elif value is None:
+        from pynetdicom.transport import AddressInformation
+
+        if value is None or isinstance(value, AddressInformation):
             self._called_presentation_address = value
-        else:
-            LOGGER.error(
-                "A_ASSOCIATE.called_presentation_address must be (str, int) tuple"
-            )
-            raise TypeError(
-                "A_ASSOCIATE.called_presentation_address must be (str, int) tuple"
-            )
+            return
+
+        msg = (
+            "'A_ASSOCIATE.called_presentation_address' must be an AddressInformation "
+            "instance or None"
+        )
+        LOGGER.error(msg)
+        raise TypeError(msg)
 
     @property
     def calling_ae_title(self) -> str:
@@ -263,48 +258,39 @@ def calling_ae_title(self, value: str) -> None:
         )
 
     @property
-    def calling_presentation_address(self) -> tuple[str, int] | None:
+    def calling_presentation_address(self) -> "AddressInformation | None":
         """Get or set the *Calling Presentation Address* parameter.
 
+        .. versionchanged:: 3.0
+
+            Changed to take and return an
+            :class:`~pynetdicom.transport.AddressInformation` instance.
+
         Parameters
         ----------
-        value : (str, int) tuple
-            A tuple containing a valid TCP/IP address string and the port
-            number as an int
+        value : pynetdicom.transport.AddressInformation | None
+            IPv4 or IPv6 connection information.
         """
         return self._calling_presentation_address
 
     @calling_presentation_address.setter
-    def calling_presentation_address(self, value: tuple[str, int] | None) -> None:
+    def calling_presentation_address(self, value: "AddressInformation | None") -> None:
         """Set the A-ASSOCIATE Service primitive's Calling Presentation
         Address parameter.
         """
+        from pynetdicom.transport import AddressInformation
+
         # pylint: disable=attribute-defined-outside-init
-        if isinstance(value, tuple):
-            if (
-                len(value) == 2
-                and isinstance(value[0], str)
-                and isinstance(value[1], int)
-            ):
-                self._calling_presentation_address = value
-            else:
-                LOGGER.error(
-                    "A_ASSOCIATE.calling_presentation_address must "
-                    "be (str, int) tuple"
-                )
-                raise TypeError(
-                    "A_ASSOCIATE.calling_presentation_address "
-                    "must be (str, int) tuple"
-                )
-        elif value is None:
+        if value is None or isinstance(value, AddressInformation):
             self._calling_presentation_address = value
-        else:
-            LOGGER.error(
-                "A_ASSOCIATE.calling_presentation_address must be (str, int) tuple"
-            )
-            raise TypeError(
-                "A_ASSOCIATE.calling_presentation_address must be (str, int) tuple"
-            )
+            return
+
+        msg = (
+            "'A_ASSOCIATE.calling_presentation_address' must be an AddressInformation "
+            f"instance or None, not {type(value).__name__}"
+        )
+        LOGGER.error(msg)
+        raise TypeError(msg)
 
     @property
     def diagnostic(self) -> int | None:
@@ -572,7 +558,7 @@ def responding_ae_title(self) -> str | None:
         return self.called_ae_title
 
     @property
-    def responding_presentation_address(self) -> tuple[str, int] | None:
+    def responding_presentation_address(self) -> "AddressInformation | None":
         """Get the Responding Presentation Address parameter."""
         return self.called_presentation_address
 
diff --git a/pynetdicom/transport.py b/pynetdicom/transport.py
index 9c5c36323..41930ac5c 100644
--- a/pynetdicom/transport.py
+++ b/pynetdicom/transport.py
@@ -19,9 +19,10 @@
     _HAS_SSL = False
 import threading
 from typing import TYPE_CHECKING, Any, cast, Callable
+import warnings
 
 from pynetdicom import evt, _config
-from pynetdicom._globals import MODE_ACCEPTOR, BIND_ADDRESS
+from pynetdicom._globals import MODE_ACCEPTOR
 from pynetdicom._handlers import (
     standard_dimse_recv_handler,
     standard_dimse_sent_handler,
@@ -40,6 +41,153 @@
 LOGGER = logging.getLogger(__name__)
 
 
+class AddressInformation:
+    """IPv4 or IPv6 address information.
+
+    .. versionadded:: 3.0
+
+    Attributes
+    ----------
+    port : int
+        The port number.
+    flowinfo : int
+        The flow info value (IPv6)
+    scope_id : int
+        The scope ID (IPv6)
+    """
+
+    def __init__(
+        self, addr: str, port: int, flowinfo: int = 0, scope_id: int = 0
+    ) -> None:
+        """Create a new AddressInformation instance.
+
+        Parameters
+        ----------
+        addr : str
+            The IP address (IPv4 and IPv6).
+        port : int
+            The port number (IPv4 and IPv6).
+        flowinfo : int, optional
+            The flow info value (IPv6 only), default ``0``.
+        scope_id : int, optional
+            The scope ID (IPv6 only), default ``0``.
+        """
+        self.address = addr
+        self._addr: str
+        self.port = port
+        self.scope_id = scope_id
+        self.flowinfo = flowinfo
+
+    @property
+    def address(self) -> str:
+        """Get or set the IP address.
+
+        Parameters
+        ----------
+        value : str
+            The IPv4 or IPv6 address. The following conversion will be made:
+
+            * ``""`` (INADDR_ANY) -> ``"0.0.0.0"``
+            * ``"<broadcast>"`` (INADDR_BROADCAST) -> ``"255.255.255.255"``
+            * ``"localhost"`` -> ``"127.0.0.1"``
+
+        Returns
+        -------
+        str
+            The IPv4 or IPv6 address.
+        """
+        return self._addr
+
+    @address.setter
+    def address(self, value: str) -> None:
+        value = "0.0.0.0" if value == "" else value
+        value = "127.0.0.1" if value == "localhost" else value
+        value = "255.255.255.255" if value == "<broadcast>" else value
+
+        self._addr = value
+
+    @property
+    def address_family(self) -> socket.AddressFamily:
+        """Return the address family enum that the address belongs to."""
+        return socket.AF_INET if self.is_ipv4 else socket.AF_INET6
+
+    @property
+    def as_tuple(self) -> tuple[str, int] | tuple[str, int, int, int]:
+        """Return the address information as ``(address, port)`` or ``(address, port,
+        flowinfo, scope_id)``.
+        """
+        if self.is_ipv4:
+            return (self.address, self.port)
+
+        return (self.address, self.port, self.flowinfo, self.scope_id)
+
+    @classmethod
+    def from_addr_port(
+        cls, addr: str | tuple[str, int, int], port: int
+    ) -> "AddressInformation":
+        """Return the a new AddressInformation instance from `addr` and `port`
+
+        Parameters
+        ----------
+        addr : str | tuple[str, int, int]
+            One of the following:
+
+            * `str`: an IPv4 or IPv6 address. If the latter then the `flowinfo` and
+              `scope_id` will be set to ``0``
+            * `tuple[str, int, int]`: an IPv6 address as ``(address, flowinfo,
+              scope_id)``
+        port : int
+            The port number.
+        """
+        if isinstance(addr, tuple):
+            return cls.from_tuple((addr[0], port, addr[1], addr[2]))
+
+        return cls.from_tuple((addr, port))
+
+    @classmethod
+    def from_tuple(
+        cls, address_info: tuple[str, int] | tuple[str, int, int, int]
+    ) -> "AddressInformation":
+        """Return the a new AddressInformation instance from `address_info`
+
+        Parameters
+        ----------
+        address_info : tuple[str, int] | tuple[str, int, int, int]
+            One of the following:
+
+            * `tuple[str, int]`: an IPv4 or IPv6 address and port. If the latter then
+              the `flowinfo` and `scope_id` will be set to ``0``
+            * `tuple[str, int, int, int]`: an IPv6 address as ``(address, port,
+              flowinfo, scope_id)``
+        """
+        host = address_info[0]
+        port = address_info[1]
+        flowinfo = address_info[2] if len(address_info) == 4 else 0
+        scope_id = address_info[3] if len(address_info) == 4 else 0
+
+        return cls(host, port, flowinfo, scope_id)
+
+    @property
+    def is_ipv4(self) -> bool:
+        """Return ``True`` if the address belongs to the IPv4 family, ``False``
+        otherwise.
+        """
+        if "." in self.address:
+            return True
+
+        return False
+
+    @property
+    def is_ipv6(self) -> bool:
+        """Return ``True`` if the address belongs to the IPv6 family, ``False``
+        otherwise.
+        """
+        if ":" in self.address:
+            return True
+
+        return False
+
+
 class T_CONNECT:
     """A TRANSPORT CONNECTION primitive
 
@@ -73,7 +221,15 @@ def __init__(self, request: "A_ASSOCIATE") -> None:
     @property
     def address(self) -> tuple[str, int]:
         """Return the peer's ``(str: IP address, int: port)``."""
-        return cast(tuple[str, int], self.request.called_presentation_address)
+        return self.address_info.as_tuple[:2]
+
+    @property
+    def address_info(self) -> AddressInformation:
+        """Return the peer's IP address information.
+
+        .. versionadded:: 3.0
+        """
+        return cast(AddressInformation, self.request.called_presentation_address)
 
     @property
     def result(self) -> str:
@@ -108,12 +264,14 @@ class AssociationSocket:
     """A wrapper for a `socket
     <https://docs.python.org/3/library/socket.html#socket-objects>`_ object.
 
-    .. versionadded:: 1.2
-
     Provides an interface for ``socket`` that is integrated
     nicely with an :class:`~pynetdicom.association.Association` instance
     and the state machine.
 
+    .. versionchanged:: 3.0
+
+        Added support for IPv6 addresses.
+
     Attributes
     ----------
     select_timeout : float or None
@@ -129,36 +287,44 @@ def __init__(
         self,
         assoc: "Association",
         client_socket: socket.socket | None = None,
-        address: tuple[str, int] = BIND_ADDRESS,
+        address: AddressInformation | None = None,
     ) -> None:
         """Create a new :class:`AssociationSocket`.
 
+        .. versionchanged:: 3.0
+
+            `address` now takes an :class:`~AddressInformation` instance.
+
         Parameters
         ----------
         assoc : association.Association
             The :class:`~pynetdicom.association.Association` instance that will
             be using the socket to communicate.
         client_socket : socket.socket, optional
-            The ``socket.socket`` to wrap,
-            if not supplied then a new socket will be created instead.
-        address : 2-tuple, optional
-            If *client_socket* is ``None`` then this is the ``(host, port)`` to
-            bind the newly created socket to, which by default will be
-            ``("", 0)``.
+            Required if `address` is ``None``, the ``socket.socket`` to wrap.
+        address : pynetdicom.transport.AddressInformation, optional
+            Required if `client_socket` is ``None`` then create a new socket and
+            bind it to this address.
         """
         self._assoc = assoc
 
-        if client_socket is not None and address != BIND_ADDRESS:
+        if client_socket is not None and address is not None:
             LOGGER.warning(
                 "AssociationSocket instantiated with both a 'client_socket' "
                 "and bind 'address'. The original socket will not be rebound"
             )
 
+        if client_socket is None and address is None:
+            raise ValueError(
+                "Either 'client_socket' or 'address' must be used when creating a new "
+                "AssociationSocket instance"
+            )
+
         self._ready = threading.Event()
         self.socket: socket.socket | None
 
         if client_socket is None:
-            self.socket = self._create_socket(address)
+            self.socket = self._create_socket(cast(AddressInformation, address))
             self._is_connected = False
         else:
             self.socket = client_socket
@@ -211,7 +377,9 @@ def connect(self, primitive: T_CONNECT) -> None:
             The TRANSPORT CONNECT primitive to use when connecting to a peer.
         """
         if self.socket is None:
-            self.socket = self._create_socket()
+            raise ValueError(
+                "A socket must be created before calling AssociationSocket.connect()"
+            )
 
         try:
             if self.tls_args:
@@ -224,16 +392,26 @@ def connect(self, primitive: T_CONNECT) -> None:
                         server_hostname=server_hostname,
                     ),
                 )
+
             # Set ae connection timeout
             self.socket.settimeout(self.assoc.connection_timeout)
             # Try and connect to remote at (address, port)
             #   raises OSError if connection refused
-            self.socket.connect(primitive.address)
+            self.socket.connect(primitive.address_info.as_tuple)
             # Clear ae connection timeout
             self.socket.settimeout(None)
+
+            # Update the Association.requestor's host and port with the actual values
+            conn_info = self.socket.getsockname()
+            self.assoc.requestor.address_info = AddressInformation.from_tuple(conn_info)
+
             # Trigger event - connection open
-            evt.trigger(self.assoc, evt.EVT_CONN_OPEN, {"address": primitive.address})
             self._is_connected = True
+            evt.trigger(
+                self.assoc,
+                evt.EVT_CONN_OPEN,
+                {"address": primitive.address_info.as_tuple},
+            )
             # Evt2: Transport connection confirmation
             primitive.result = "Evt2"
             self.provider_queue.put(primitive)
@@ -248,11 +426,7 @@ def connect(self, primitive: T_CONNECT) -> None:
             # Don't be tempted to replace this with a self.close() call -
             #   it doesn't work because `_is_connected` is False
             if self.socket:
-                try:
-                    self.socket.shutdown(socket.SHUT_RDWR)
-                except Exception:
-                    pass
-                self.socket.close()
+                self._shutdown_socket()
                 self.socket = None
 
             primitive.result = "Evt17"
@@ -260,8 +434,8 @@ def connect(self, primitive: T_CONNECT) -> None:
         finally:
             self._ready.set()
 
-    def _create_socket(self, address: tuple[str, int] = BIND_ADDRESS) -> socket.socket:
-        """Create a new IPv4 TCP socket and set it up for use.
+    def _create_socket(self, address: AddressInformation) -> socket.socket:
+        """Create a new IPv4 or IPv6 TCP socket and set it up for use.
 
         *Socket Options*
 
@@ -271,17 +445,18 @@ def _create_socket(self, address: tuple[str, int] = BIND_ADDRESS) -> socket.sock
 
         Parameters
         ----------
-        address : 2-tuple, optional
-            The ``(host: str, port: int)`` to bind the socket to. By default the socket
-            is bound to ``("", 0)``, i.e. the first available port.
+        address : pynetdicom.transport.AddressInformation
+            The IPv4 or IPv6 address to bind the socket to.
 
         Returns
         -------
         socket.socket
             A bound and unconnected socket instance.
         """
-        # AF_INET: IPv4, SOCK_STREAM: TCP socket
-        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        # AF_INET: IPv4, AF_INET6: IPv6, SOCK_STREAM: TCP socket
+        family = socket.AF_INET if address.is_ipv4 else socket.AF_INET6
+        sock = socket.socket(family, socket.SOCK_STREAM)
+
         # SO_REUSEADDR: reuse the socket in TIME_WAIT state without
         #   waiting for its natural timeout to expire
         #   Allows local address reuse
@@ -291,7 +466,7 @@ def _create_socket(self, address: tuple[str, int] = BIND_ADDRESS) -> socket.sock
         if self.assoc.network_timeout is not None:
             sock.settimeout(self.assoc.network_timeout)
 
-        sock.bind(address)
+        sock.bind(address.as_tuple)
 
         self._is_connected = False
 
@@ -307,12 +482,21 @@ def event_queue(self) -> "queue.Queue[str]":
     def get_local_addr(self, host: tuple[str, int] = ("10.255.255.255", 1)) -> str:
         """Return an address for the local computer as :class:`str`.
 
+        .. deprecated:: 3.0
+
+            This method will be removed in v4.0.
+
         Parameters
         ----------
         host : tuple[str, int]
-            The host's ``(addr: str, port: int)`` when trying to determine the local
-            address.
+            The host's IPv4 ``(addr: str, port: int)`` used when trying to determine
+            the local address.
         """
+        warnings.warn(
+            "AssociationSocket.get_local_addr() will be removed in v4.0",
+            DeprecationWarning,
+        )
+
         # Solution from https://stackoverflow.com/a/28950776
         with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
             try:
@@ -480,12 +664,10 @@ def tls_args(self, tls_args: tuple["ssl.SSLContext", str] | None) -> None:
 class RequestHandler(BaseRequestHandler):
     """Connection request handler for the ``AssociationServer``.
 
-    .. versionadded:: 1.2
-
     Attributes
     ----------
-    client_address : 2-tuple
-        The ``(host, port)`` of the remote.
+    client_address : tuple[str, int] | tuple[str, int, int, int]
+        The ``(host, port)`` or ``(host, port, flowinfo, scope_id)`` of the remote.
     request : socket.socket
         The (unaccepted) client socket.
     server : transport.AssociationServer or transport.ThreadedAssociationServer
@@ -514,20 +696,27 @@ def handle(self) -> None:
         assoc.start()
 
     @property
-    def local(self) -> tuple[str, int]:
-        """Return a 2-tuple of the local server's ``(host, port)`` address."""
-        return self.server.server_address
+    def local(self) -> AddressInformation:
+        """Return the local server's address information.
+
+        .. versionchanged:: 3.0
+
+            Now returns a :class:`~AddressInformation` instance.
+        """
+        return AddressInformation.from_tuple(self.server.server_address)
 
     @property
-    def remote(self) -> tuple[str, int]:
-        """Return a 2-tuple of the remote client's ``(host, port)`` address."""
-        return cast(tuple[str, int], self.client_address)
+    def remote(self) -> AddressInformation:
+        """Return the remote client's address information.
 
-    def _create_association(self) -> "Association":
-        """Create an :class:`Association` object for the current request.
+        .. versionchanged:: 3.0
 
-        .. versionadded:: 1.5
+            Now returns a :class:`~AddressInformation` instance.
         """
+        return AddressInformation.from_tuple(self.client_address)
+
+    def _create_association(self) -> "Association":
+        """Create an :class:`Association` object for the current request."""
         from pynetdicom.association import Association
 
         assoc = Association(self.ae, MODE_ACCEPTOR)
@@ -543,15 +732,13 @@ def _create_association(self) -> "Association":
         # Association Acceptor object -> local AE
         assoc.acceptor.maximum_length = self.ae.maximum_pdu_size
         assoc.acceptor.ae_title = self.server.ae_title
-        assoc.acceptor.address = self.local[0]
-        assoc.acceptor.port = self.local[1]
+        assoc.acceptor.address_info = self.local
         assoc.acceptor.implementation_class_uid = self.ae.implementation_class_uid
         assoc.acceptor.implementation_version_name = self.ae.implementation_version_name
         assoc.acceptor.supported_contexts = deepcopy(self.server.contexts)
 
         # Association Requestor object -> remote AE
-        assoc.requestor.address = self.remote[0]
-        assoc.requestor.port = self.remote[1]
+        assoc.requestor.address_info = self.remote
 
         # Bind events to handlers
         for event in self.server._handlers:
@@ -570,12 +757,6 @@ def _create_association(self) -> "Association":
 class AssociationServer(TCPServer):
     """An Association server implementation.
 
-    .. versionadded:: 1.2
-
-    .. versionchanged:: 1.5
-
-        Added `request_handler` keyword parameter.
-
     Any attempts to connect will be assumed to be from association requestors.
 
     The server should be started with
@@ -585,20 +766,25 @@ class AssociationServer(TCPServer):
     ``0`` specifies a poll and never blocks. A value of ``None`` blocks until
     a connection is ready.
 
+    .. versionchanged:: 3.0
+
+        Added support for IPv6.
+
     Attributes
     ----------
     ae : ae.ApplicationEntity
         The parent AE that is running the server.
     request_queue_size : int
         Default ``5``.
-    server_address : tuple[str, int]
-        The ``(host: str, port: int)`` that the server is running on.
+    server_address : tuple[str, int] | tuple[str, int, int, int]
+        The ``(host: str, port: int)`` or ``(host: str, port: int, flowinfo: int,
+        scope_id: int)`` that the server is running on.
     """
 
     def __init__(
         self,
         ae: "ApplicationEntity",
-        address: tuple[str, int],
+        address: tuple[str, int] | tuple[str, int, int, int],
         ae_title: str,
         contexts: list[PresentationContext],
         ssl_context: "ssl.SSLContext | None" = None,
@@ -612,8 +798,9 @@ def __init__(
         ----------
         ae : ae.ApplicationEntity
             The parent AE that's running the server.
-        address : tuple[str, int]
-            The ``(host: str, port: int)`` that the server should run on.
+        address : tuple[str, int] | tuple[str, int, int, int]
+            The ``(host: str, port: int)`` or ``(host: str, port: int, flowinfo: int,
+            scope_id: int)`` that the server should run on.
         ae_title : str
             The AE title of the SCP.
         contexts : list of presentation.PresentationContext
@@ -635,13 +822,15 @@ def __init__(
         self.ae_title = ae_title
         self.contexts = contexts
         self.ssl_context = ssl_context
+        self.address_info = AddressInformation.from_tuple(address)
+        self.address_family = self.address_info.address_family
         self.allow_reuse_address = True
-        self.server_address: tuple[str, int] = address
+        self.server_address: tuple[str, int] | tuple[str, int, int, int] = address  # type: ignore[assignment]
         self.socket: socket.socket | None = None  # type: ignore[assignment]
 
         request_handler = request_handler or RequestHandler
 
-        super().__init__(address, request_handler, bind_and_activate=True)
+        super().__init__(address, request_handler, bind_and_activate=True)  # type: ignore[arg-type]
 
         self.timeout = 60
 
@@ -664,12 +853,6 @@ def bind(
     ) -> None:
         """Bind a callable `handler` to an `event`.
 
-        .. versionadded:: 1.3
-
-        .. versionchanged:: 1.5
-
-            Added `args` keyword parameter.
-
         Parameters
         ----------
         event : namedtuple
@@ -713,21 +896,12 @@ def active_associations(self) -> list["Association"]:
         return [tt for tt in threads if tt._server is self]
 
     def get_events(self) -> list[evt.EventType]:
-        """Return a list of currently bound events.
-
-        .. versionadded:: 1.3
-        """
+        """Return a list of currently bound events."""
         return sorted(self._handlers.keys(), key=lambda x: x.name)
 
     def get_handlers(self, event: evt.EventType) -> evt.HandlerArgType:
         """Return handlers bound to a specific `event`.
 
-        .. versionadded:: 1.3
-
-        .. versionchanged:: 1.5
-
-            Returns a 2-tuple of (callable, args) or list of 2-tuple.
-
         Parameters
         ----------
         event : namedtuple
@@ -857,8 +1031,6 @@ def ssl_context(self, context: "ssl.SSLContext | None") -> None:
     def unbind(self, event: evt.EventType, handler: Callable) -> None:
         """Unbind a callable `handler` from an `event`.
 
-        .. versionadded:: 1.3
-
         Parameters
         ----------
         event : 3-tuple
@@ -874,10 +1046,7 @@ def unbind(self, event: evt.EventType, handler: Callable) -> None:
 
 
 class ThreadedAssociationServer(ThreadingMixIn, AssociationServer):
-    """An :class:`AssociationServer` suitable for threading.
-
-    .. versionadded:: 1.2
-    """
+    """An :class:`AssociationServer` suitable for threading."""
 
     def process_request_thread(
         self,


Test patch:
diff --git a/pynetdicom/tests/test_acse.py b/pynetdicom/tests/test_acse.py
index 50cc6659f..973234e58 100644
--- a/pynetdicom/tests/test_acse.py
+++ b/pynetdicom/tests/test_acse.py
@@ -42,7 +42,7 @@
 )
 from pynetdicom.pdu import P_DATA_TF
 from pynetdicom.sop_class import Verification, CTImageStorage
-from pynetdicom.transport import AssociationSocket
+from pynetdicom.transport import AssociationSocket, AddressInformation
 from .encoded_pdu_items import (
     a_associate_rq,
     a_associate_ac,
@@ -99,14 +99,12 @@ def __init__(self):
         self.mode = None
         self.dul = DummyDUL()
         self.requestor = ServiceUser(self, "requestor")
-        self.requestor.port = 11112
         self.requestor.ae_title = "TEST_LOCAL"
-        self.requestor.address = "127.0.0.1"
+        self.requestor.address_info = AddressInformation("127.0.0.1", 11112)
         self.requestor.maximum_length = 31682
         self.acceptor = ServiceUser(self, "acceptor")
         self.acceptor.ae_title = "TEST_REMOTE"
-        self.acceptor.port = 11113
-        self.acceptor.address = "127.0.0.2"
+        self.acceptor.address_info = AddressInformation("127.0.0.2", 11113)
         self.acse_timeout = 11
         self.dimse_timeout = 12
         self.network_timeout = 13
@@ -409,8 +407,8 @@ def test_send_request(self):
         assert primitive.application_context_name == "1.2.840.10008.3.1.1.1"
         assert primitive.calling_ae_title == "TEST_LOCAL"
         assert primitive.called_ae_title == "TEST_REMOTE"
-        assert primitive.calling_presentation_address == ("127.0.0.1", 11112)
-        assert primitive.called_presentation_address == ("127.0.0.2", 11113)
+        assert primitive.calling_presentation_address.as_tuple == ("127.0.0.1", 11112)
+        assert primitive.called_presentation_address.as_tuple == ("127.0.0.2", 11113)
 
         cx = primitive.presentation_context_definition_list
         assert len(cx) == 1
@@ -1955,16 +1953,14 @@ def create_assoc(self):
 
         assoc = Association(ae, mode="requestor")
 
-        assoc.set_socket(AssociationSocket(assoc))
+        assoc.set_socket(AssociationSocket(assoc, address=AddressInformation("", 0)))
 
         # Association Acceptor object -> remote AE
         assoc.acceptor.ae_title = "ANY_SCU"
-        assoc.acceptor.address = "localhost"
-        assoc.acceptor.port = 11112
+        assoc.acceptor.address_info = AddressInformation("localhost", 11112)
 
         # Association Requestor object -> local AE
-        assoc.requestor.address = ""
-        assoc.requestor.port = 11113
+        assoc.requestor.address_info = AddressInformation("", 11113)
         assoc.requestor.ae_title = ae.ae_title
         assoc.requestor.maximum_length = 16382
         assoc.requestor.implementation_class_uid = ae.implementation_class_uid
@@ -1994,12 +1990,10 @@ def create_assoc_acc(self):
 
         # Association Acceptor object -> remote AE
         assoc.acceptor.ae_title = "ANY_SCU"
-        assoc.acceptor.address = "localhost"
-        assoc.acceptor.port = 11112
+        assoc.acceptor.address_info = AddressInformation("localhost", 11112)
 
         # Association Requestor object -> local AE
-        assoc.requestor.address = ""
-        assoc.requestor.port = 11113
+        assoc.requestor.address_info = AddressInformation("", 11113)
         assoc.requestor.ae_title = ae.ae_title
         assoc.requestor.maximum_length = 16382
         assoc.requestor.implementation_class_uid = ae.implementation_class_uid
diff --git a/pynetdicom/tests/test_ae.py b/pynetdicom/tests/test_ae.py
index 9c648a2d5..2066185d5 100644
--- a/pynetdicom/tests/test_ae.py
+++ b/pynetdicom/tests/test_ae.py
@@ -3,6 +3,7 @@
 import logging
 import os
 import signal
+import sys
 import threading
 import time
 
@@ -33,6 +34,9 @@
     PYD_CONFIG.settings.reading_validation_mode = 0
 
 
+ON_WINDOWS = sys.platform == "win32"
+
+
 # debug_logger()
 
 
@@ -170,6 +174,30 @@ def test_contexts(self):
 
         server.shutdown()
 
+    def test_ipv6(self):
+        """Test starting an IPv6 server."""
+        self.ae = ae = AE()
+        ae.acse_timeout = 5
+        ae.dimse_timeout = 5
+        ae.network_timeout = 5
+        ae.ae_title = "TESTAET"
+        assert ae.ae_title == "TESTAET"
+
+        ae.add_supported_context(Verification)
+        server = ae.start_server(("::1", 11112), block=False)
+        assert server.address_info.is_ipv6
+
+        server.shutdown()
+
+        # Windows doesn't like the flowinfo/scope_id
+        if not ON_WINDOWS:
+            server = ae.start_server(("::1", 11112, 1, 2), block=False)
+            assert server.address_info.is_ipv6
+            assert server.address_info.flowinfo == 1
+            assert server.address_info.scope_id == 2
+
+            server.shutdown()
+
 
 class TestAEVerificationSCP:
     """Check verification SCP"""
@@ -705,6 +733,45 @@ def test_invalid_ae_title(self):
         with pytest.raises(TypeError, match=msg):
             ae.associate("localhost", 11112, ae_title=12345)
 
+    def test_invalid_addr_raises(self):
+        ae = AE()
+        with pytest.raises(TypeError, match="'addr' must be str or tuple"):
+            ae.associate(12, 14)
+
+        with pytest.raises(TypeError, match="'addr' must be str or tuple"):
+            ae.associate((12, 0, 0), 14)
+
+        with pytest.raises(TypeError, match="'addr' must be str or tuple"):
+            ae.associate(("localhost", "foo", 0), 14)
+
+        with pytest.raises(TypeError, match="'addr' must be str or tuple"):
+            ae.associate(("localhost", 0, "foo"), 14)
+
+    def test_invalid_port_raises(self):
+        ae = AE()
+        with pytest.raises(TypeError, match="'port' must be int"):
+            ae.associate("localhost", "foo")
+
+    def test_invalid_bind_raises(self):
+        ae = AE()
+        with pytest.raises(TypeError, match="'bind_address' must be tuple"):
+            ae.associate("localhost", 11112, bind_address=12)
+
+        with pytest.raises(TypeError, match="'bind_address' must be tuple"):
+            ae.associate("localhost", 11112, bind_address=(12,))
+
+        with pytest.raises(TypeError, match="'bind_address' must be tuple"):
+            ae.associate("localhost", 11112, bind_address=(12, 13))
+
+        with pytest.raises(TypeError, match="'bind_address' must be tuple"):
+            ae.associate("localhost", 11112, bind_address=(12, 13, 0, 0))
+
+        with pytest.raises(TypeError, match="'bind_address' must be tuple"):
+            ae.associate("localhost", 11112, bind_address=("localhost", 13, "foo", 0))
+
+        with pytest.raises(TypeError, match="'bind_address' must be tuple"):
+            ae.associate("localhost", 11112, bind_address=("localhost", 13, 0, "foo"))
+
 
 class TestAEGoodMiscSetters:
     def setup_method(self):
diff --git a/pynetdicom/tests/test_assoc.py b/pynetdicom/tests/test_assoc.py
index b9c797e6f..abe2e0a68 100644
--- a/pynetdicom/tests/test_assoc.py
+++ b/pynetdicom/tests/test_assoc.py
@@ -620,6 +620,45 @@ def handle_req(event):
 
         scp.shutdown()
 
+    def test_ipv6(self):
+        """Test Association release with IPv6"""
+        self.ae = ae = AE()
+        ae.acse_timeout = 5
+        ae.dimse_timeout = 5
+        ae.network_timeout = 5
+        ae.add_supported_context(Verification)
+        scp = ae.start_server(("::1", 11112), block=False)
+
+        # Simple release
+        ae.add_requested_context(Verification)
+        assoc = ae.associate("::1", 11112)
+        assert assoc.is_established
+        assoc.release()
+        assert assoc.is_released
+        assert not assoc.is_established
+
+        # Simple release, then release again
+        assoc = ae.associate("::1", 11112)
+        assert assoc.is_established
+        assoc.release()
+        assert assoc.is_released
+        assert not assoc.is_established
+        assert assoc.is_released
+        assoc.release()
+        assert assoc.is_released
+
+        # Simple release, then abort
+        assoc = ae.associate("::1", 11112)
+        assert assoc.is_established
+        assoc.release()
+        assert assoc.is_released
+        assert assoc.is_released
+        assert not assoc.is_established
+        assoc.abort()
+        assert not assoc.is_aborted
+
+        scp.shutdown()
+
 
 class TestCStoreSCP:
     """Tests for Association._c_store_scp()."""
diff --git a/pynetdicom/tests/test_assoc_user.py b/pynetdicom/tests/test_assoc_user.py
index e37b2babe..de09558c7 100644
--- a/pynetdicom/tests/test_assoc_user.py
+++ b/pynetdicom/tests/test_assoc_user.py
@@ -21,6 +21,7 @@
     AsynchronousOperationsWindowNegotiation,
 )
 from pynetdicom.sop_class import Verification
+from pynetdicom.transport import AddressInformation
 
 
 # debug_logger()
@@ -65,8 +66,8 @@ def setup_method(self):
         primitive.application_context_name = "1.2.840.10008.3.1.1.1"
         primitive.calling_ae_title = "LOCAL_AE_TITLE  "
         primitive.called_ae_title = "REMOTE_AE_TITLE "
-        primitive.calling_presentation_address = ("127.0.0.1", 11112)
-        primitive.called_presentation_address = ("127.0.0.2", 11113)
+        primitive.calling_presentation_address = AddressInformation("127.0.0.1", 11112)
+        primitive.called_presentation_address = AddressInformation("127.0.0.2", 11113)
 
         # Presentation Contexts
         cx = build_context("1.2.840.10008.1.1")
@@ -90,8 +91,13 @@ def test_init(self):
 
         assert user.primitive is None
         assert user.ae_title == ""
-        assert user.port is None
-        assert user.address == ""
+        with pytest.raises(ValueError, match="No address info"):
+            user.port
+
+        with pytest.raises(ValueError, match="No address info"):
+            user.address
+
+        assert user.address_info is None
         assert user._contexts == []
         assert user.mode == "acceptor"
         assert user.maximum_length == 16382
@@ -140,8 +146,13 @@ def test_assignment(self):
 
         assert user.primitive is None
         assert user.ae_title == ""
-        assert user.port is None
-        assert user.address == ""
+        with pytest.raises(ValueError, match="No address info"):
+            user.port
+
+        with pytest.raises(ValueError, match="No address info"):
+            user.address
+
+        assert user.address_info is None
         assert user._contexts == []
         assert user.mode == "acceptor"
         assert user.maximum_length == 16382
@@ -149,14 +160,14 @@ def test_assignment(self):
         assert user.implementation_class_uid == PYNETDICOM_IMPLEMENTATION_UID
 
         user.ae_title = "TEST_AE_TITLE"
-        user.port = 11112
-        user.address = "127.9.9.1"
+        user.address_info = AddressInformation("127.9.9.1", 11112)
         user._contexts = [1]
         user.maximum_length = 16383
 
         assert user.ae_title == "TEST_AE_TITLE"
         assert user.port == 11112
         assert user.address == "127.9.9.1"
+        assert user.address_info.as_tuple == ("127.9.9.1", 11112)
         assert user._contexts == [1]
         assert user.maximum_length == 16383
 
@@ -264,7 +275,7 @@ def test_info(self):
 
         assert info["port"] is None
         assert info["mode"] == "acceptor"
-        assert info["address"] == ""
+        assert info["address"] is None
         assert info["ae_title"] == ""
         with pytest.raises(KeyError):
             info["pdv_size"]
@@ -1389,8 +1400,8 @@ def setup_method(self):
         primitive.application_context_name = "1.2.840.10008.3.1.1.1"
         primitive.calling_ae_title = "LOCAL_AE_TITLE  "
         primitive.called_ae_title = "REMOTE_AE_TITLE "
-        primitive.calling_presentation_address = ("127.0.0.1", 11112)
-        primitive.called_presentation_address = ("127.0.0.2", 11113)
+        primitive.calling_presentation_address = AddressInformation("127.0.0.1", 11112)
+        primitive.called_presentation_address = AddressInformation("127.0.0.2", 11113)
 
         # Presentation Contexts
         cx = build_context("1.2.840.10008.1.1")
@@ -1437,8 +1448,13 @@ def test_init(self):
 
         assert user.primitive is None
         assert user.ae_title == ""
-        assert user.port is None
-        assert user.address == ""
+        with pytest.raises(ValueError, match="No address info"):
+            user.port
+
+        with pytest.raises(ValueError, match="No address info"):
+            user.address
+
+        assert user.address_info is None
         assert user._contexts == []
         assert user.mode == "requestor"
         assert user.maximum_length == 16382
@@ -1453,8 +1469,13 @@ def test_assignment(self):
 
         assert user.primitive is None
         assert user.ae_title == ""
-        assert user.port is None
-        assert user.address == ""
+        with pytest.raises(ValueError, match="No address info"):
+            user.port
+
+        with pytest.raises(ValueError, match="No address info"):
+            user.address
+
+        assert user.address_info is None
         assert user._contexts == []
         assert user.mode == "requestor"
         assert user.maximum_length == 16382
@@ -1462,14 +1483,14 @@ def test_assignment(self):
         assert user.implementation_class_uid == PYNETDICOM_IMPLEMENTATION_UID
 
         user.ae_title = "TEST_AE_TITLE"
-        user.port = 11112
-        user.address = "127.9.9.1"
+        user.address_info = AddressInformation("127.9.9.1", 11112)
         user._contexts = [1]
         user.maximum_length = 16383
 
         assert user.ae_title == "TEST_AE_TITLE"
         assert user.port == 11112
         assert user.address == "127.9.9.1"
+        assert user.address_info.as_tuple == ("127.9.9.1", 11112)
         assert user._contexts == [1]
         assert user.maximum_length == 16383
 
@@ -1568,7 +1589,7 @@ def test_info(self):
 
         assert info["port"] is None
         assert info["mode"] == "requestor"
-        assert info["address"] == ""
+        assert info["address"] is None
         assert info["ae_title"] == ""
         with pytest.raises(KeyError):
             info["pdv_size"]
diff --git a/pynetdicom/tests/test_dimse_provider.py b/pynetdicom/tests/test_dimse_provider.py
index 944ee8b14..9a1fc64fb 100644
--- a/pynetdicom/tests/test_dimse_provider.py
+++ b/pynetdicom/tests/test_dimse_provider.py
@@ -70,6 +70,7 @@
     n_create_rq_ds,
     n_create_rsp_ds,
 )
+from pynetdicom.transport import AddressInformation
 from .encoded_pdu_items import p_data_tf
 from pynetdicom.sop_class import (
     Verification,
@@ -87,14 +88,12 @@ def __init__(self):
         self.mode = None
         self.dul = DummyDUL()
         self.requestor = ServiceUser(self, "requestor")
-        self.requestor.port = 11112
         self.requestor.ae_title = "TEST_LOCAL      "
-        self.requestor.address = "127.0.0.1"
+        self.requestor.address_info = AddressInformation("127.0.0.1", 11112)
         self.requestor.maximum_length = 31682
         self.acceptor = ServiceUser(self, "acceptor")
         self.acceptor.ae_title = "TEST_REMOTE     "
-        self.acceptor.port = 11113
-        self.acceptor.address = "127.0.0.2"
+        self.acceptor.address_info = AddressInformation("127.0.0.2", 11113)
         self.acse_timeout = 11
         self.dimse_timeout = 1
         self.network_timeout = 13
diff --git a/pynetdicom/tests/test_fsm.py b/pynetdicom/tests/test_fsm.py
index f27fa2c69..afb5bb041 100644
--- a/pynetdicom/tests/test_fsm.py
+++ b/pynetdicom/tests/test_fsm.py
@@ -24,7 +24,7 @@
     ImplementationClassUIDNotification,
 )
 from pynetdicom.sop_class import Verification
-from pynetdicom.transport import AssociationSocket, T_CONNECT
+from pynetdicom.transport import AssociationSocket, T_CONNECT, AddressInformation
 from .encoded_pdu_items import (
     a_associate_ac,
     a_associate_rq,
@@ -201,16 +201,14 @@ def setup_method(self):
 
         assoc = Association(ae, mode="requestor")
 
-        assoc.set_socket(AssociationSocket(assoc))
+        assoc.set_socket(AssociationSocket(assoc, address=AddressInformation("", 0)))
 
         # Association Acceptor object -> remote AE
         assoc.acceptor.ae_title = "ANY_SCU"
-        assoc.acceptor.address = "localhost"
-        assoc.acceptor.port = 11112
+        assoc.acceptor.address_info = AddressInformation("localhost", 11112)
 
         # Association Requestor object -> local AE
-        assoc.requestor.address = ""
-        assoc.requestor.port = 11113
+        assoc.requestor.address_info = AddressInformation("", 11113)
         assoc.requestor.ae_title = ae.ae_title
         assoc.requestor.maximum_length = 16382
         assoc.requestor.implementation_class_uid = ae.implementation_class_uid
@@ -241,9 +239,11 @@ def get_associate(self, assoc_type):
             # Called AE Title is the destination DICOM AE title
             primitive.called_ae_title = "REMOTE_AE_TITLE "
             # The TCP/IP address of the source, pynetdicom includes port too
-            primitive.calling_presentation_address = ("localhost", 0)
+            primitive.calling_presentation_address = AddressInformation("localhost", 0)
             # The TCP/IP address of the destination, pynetdicom includes port too
-            primitive.called_presentation_address = ("localhost", 11112)
+            primitive.called_presentation_address = AddressInformation(
+                "localhost", 11112
+            )
             # Proposed presentation contexts
             cx = build_context(Verification)
             cx.context_id = 1
@@ -383,12 +383,10 @@ def get_acceptor_assoc(self):
 
         # Association Acceptor object -> remote AE
         assoc.acceptor.ae_title = "ANY_SCU"
-        assoc.acceptor.address = "localhost"
-        assoc.acceptor.port = 11112
+        assoc.acceptor.address_info = AddressInformation("localhost", 11112)
 
         # Association Requestor object -> local AE
-        assoc.requestor.address = ""
-        assoc.requestor.port = 11113
+        assoc.requestor.address_info = AddressInformation("", 11113)
         assoc.requestor.ae_title = ae.ae_title
         assoc.requestor.maximum_length = 16382
         assoc.requestor.implementation_class_uid = ae.implementation_class_uid
@@ -1838,13 +1836,12 @@ class TestState04(TestStateBase):
 
     def move_to_state(self, assoc, scp):
         def connect(primitive):
-            address = primitive.address
             """Override the socket's connect so no event gets added."""
             if assoc.dul.socket.socket is None:
                 assoc.dul.socket.socket = assoc.dul.socket._create_socket()
 
             try:
-                assoc.dul.socket.socket.connect(address)
+                assoc.dul.socket.socket.connect(primitive.address_info.as_tuple)
                 assoc.dul.socket._is_connected = True
             except (OSError, TimeoutError) as exc:
                 assoc.dul.socket.close()
@@ -8227,16 +8224,14 @@ def setup_method(self):
         ae.dimse_timeout = 5
 
         assoc = Association(ae, mode="requestor")
-        assoc.set_socket(AssociationSocket(assoc))
+        assoc.set_socket(AssociationSocket(assoc, address=AddressInformation("", 0)))
 
         # Association Acceptor object -> remote AE
         assoc.acceptor.ae_title = "ANY_SCU"
-        assoc.acceptor.address = "localhost"
-        assoc.acceptor.port = 11112
+        assoc.acceptor.address_info = AddressInformation("localhost", 11112)
 
         # Association Requestor object -> local AE
-        assoc.requestor.address = "localhost"
-        assoc.requestor.port = 11113
+        assoc.requestor.address_info = AddressInformation("localhost", 11113)
         assoc.requestor.ae_title = ae.ae_title
         assoc.requestor.maximum_length = 16382
         assoc.requestor.implementation_class_uid = ae.implementation_class_uid
@@ -8753,16 +8748,14 @@ def setup_method(self):
         ae.dimse_timeout = 5
 
         assoc = Association(ae, mode="requestor")
-        assoc.set_socket(AssociationSocket(assoc))
+        assoc.set_socket(AssociationSocket(assoc, address=AddressInformation("", 0)))
 
         # Association Acceptor object -> remote AE
         assoc.acceptor.ae_title = "ANY_SCU"
-        assoc.acceptor.address = "localhost"
-        assoc.acceptor.port = 11112
+        assoc.acceptor.address_info = AddressInformation("localhost", 11112)
 
         # Association Requestor object -> local AE
-        assoc.requestor.address = "localhost"
-        assoc.requestor.port = 11113
+        assoc.requestor.address_info = AddressInformation("localhost", 11113)
         assoc.requestor.ae_title = ae.ae_title
         assoc.requestor.maximum_length = 16382
         assoc.requestor.implementation_class_uid = ae.implementation_class_uid
diff --git a/pynetdicom/tests/test_logging.py b/pynetdicom/tests/test_logging.py
index 89f466ff3..131cfcd2e 100644
--- a/pynetdicom/tests/test_logging.py
+++ b/pynetdicom/tests/test_logging.py
@@ -53,6 +53,7 @@
     UserIdentityNegotiation,
 )
 from pynetdicom.sop_class import CTImageStorage, Verification
+from pynetdicom.transport import AddressInformation
 
 
 # debug_logger()
@@ -311,9 +312,11 @@ def setup_method(self):
         # Called AE Title is the destination DICOM AE title
         primitive.called_ae_title = "1234567890123456"
         # The TCP/IP address of the source, pynetdicom includes port too
-        primitive.calling_presentation_address = ("127.127.127.127", 111112)
+        primitive.calling_presentation_address = AddressInformation(
+            "127.127.127.127", 111112
+        )
         # The TCP/IP address of the destination, pynetdicom includes port too
-        primitive.called_presentation_address = ("0.0.0.0", 0)
+        primitive.called_presentation_address = AddressInformation("0.0.0.0", 0)
         # Proposed presentation contexts
         contexts = [
             build_context("1.2.3.4.5.6", JPEGBaseline8Bit),
@@ -342,9 +345,11 @@ def setup_method(self):
         # Called AE Title is the destination DICOM AE title
         primitive.called_ae_title = "1234567890123456"
         # The TCP/IP address of the source, pynetdicom includes port too
-        primitive.calling_presentation_address = ("127.127.127.127", 111112)
+        primitive.calling_presentation_address = AddressInformation(
+            "127.127.127.127", 111112
+        )
         # The TCP/IP address of the destination, pynetdicom includes port too
-        primitive.called_presentation_address = ("0.0.0.0", 0)
+        primitive.called_presentation_address = AddressInformation("0.0.0.0", 0)
         # Proposed presentation contexts
         contexts = [
             build_context("1.2.3.4.5.6", JPEGBaseline8Bit),
diff --git a/pynetdicom/tests/test_primitives.py b/pynetdicom/tests/test_primitives.py
index 276a37366..2f11788c4 100644
--- a/pynetdicom/tests/test_primitives.py
+++ b/pynetdicom/tests/test_primitives.py
@@ -25,6 +25,7 @@
     UserIdentityNegotiation,
 )
 from pynetdicom.presentation import PresentationContext
+from pynetdicom.transport import AddressInformation
 from pynetdicom.utils import pretty_bytes
 
 
@@ -830,13 +831,13 @@ def test_assignment(self):
 
         assoc.calling_presentation_address = None
         assert assoc.calling_presentation_address is None
-        assoc.calling_presentation_address = ("10.40.94.43", 105)
-        assert assoc.calling_presentation_address == ("10.40.94.43", 105)
+        assoc.calling_presentation_address = AddressInformation("10.40.94.43", 105)
+        assert assoc.calling_presentation_address.as_tuple == ("10.40.94.43", 105)
 
         assoc.called_presentation_address = None
         assert assoc.called_presentation_address is None
-        assoc.called_presentation_address = ("10.40.94.44", 106)
-        assert assoc.called_presentation_address == ("10.40.94.44", 106)
+        assoc.called_presentation_address = AddressInformation("10.40.94.44", 106)
+        assert assoc.called_presentation_address.as_tuple == ("10.40.94.44", 106)
 
         pc = PresentationContext()
         pc.context_id = 1
diff --git a/pynetdicom/tests/test_transport.py b/pynetdicom/tests/test_transport.py
index 232a3d45d..112a5c560 100644
--- a/pynetdicom/tests/test_transport.py
+++ b/pynetdicom/tests/test_transport.py
@@ -28,6 +28,7 @@
     AssociationServer,
     ThreadedAssociationServer,
     T_CONNECT,
+    AddressInformation,
 )
 from pynetdicom.sop_class import Verification, RTImageStorage
 from .encoded_pdu_items import p_data_tf_rq, a_associate_rq
@@ -56,6 +57,99 @@
 # debug_logger()
 
 
+class TestAddressInformation:
+    """Tests for AssociationInformation."""
+
+    def test_ipv4_init(self):
+        addr = AddressInformation("", 0)
+        assert addr.address == "0.0.0.0"
+        assert addr.port == 0
+        assert addr.is_ipv4 is True
+        assert addr.is_ipv6 is False
+        assert addr.address_family == socket.AF_INET
+        assert addr.flowinfo == 0
+        assert addr.scope_id == 0
+        assert addr.as_tuple == ("0.0.0.0", 0)
+
+    def test_ipv6_init_minimal(self):
+        addr = AddressInformation("::1", 0)
+        assert addr.address == "::1"
+        assert addr.port == 0
+        assert addr.is_ipv4 is False
+        assert addr.is_ipv6 is True
+        assert addr.address_family == socket.AF_INET6
+        assert addr.flowinfo == 0
+        assert addr.scope_id == 0
+        assert addr.as_tuple == ("::1", 0, 0, 0)
+
+    def test_ipv6_init_maximal(self):
+        addr = AddressInformation("::1", 0, 10, 11)
+        assert addr.address == "::1"
+        assert addr.port == 0
+        assert addr.is_ipv4 is False
+        assert addr.is_ipv6 is True
+        assert addr.address_family == socket.AF_INET6
+        assert addr.flowinfo == 10
+        assert addr.scope_id == 11
+        assert addr.as_tuple == ("::1", 0, 10, 11)
+
+    def test_from_tuple(self):
+        addr = AddressInformation.from_tuple(("localhost", 11112))
+        assert isinstance(addr, AddressInformation)
+        assert addr.address == "127.0.0.1"
+        assert addr.port == 11112
+
+        addr = AddressInformation.from_tuple(("<broadcast>", 104))
+        assert isinstance(addr, AddressInformation)
+        assert addr.address == "255.255.255.255"
+        assert addr.port == 104
+
+        addr = AddressInformation.from_tuple(("::0", 11113))
+        assert isinstance(addr, AddressInformation)
+        assert addr.address == "::0"
+        assert addr.port == 11113
+
+    def test_from_add_port(self):
+        addr = AddressInformation.from_addr_port("localhost", 11112)
+        assert isinstance(addr, AddressInformation)
+        assert addr.address == "127.0.0.1"
+        assert addr.port == 11112
+
+        addr = AddressInformation.from_addr_port("::0", 11113)
+        assert isinstance(addr, AddressInformation)
+        assert addr.address == "::0"
+        assert addr.port == 11113
+        assert addr.flowinfo == 0
+        assert addr.scope_id == 0
+
+        addr = AddressInformation.from_addr_port(("::0", 12, 13), 11113)
+        assert isinstance(addr, AddressInformation)
+        assert addr.address == "::0"
+        assert addr.port == 11113
+        assert addr.flowinfo == 12
+        assert addr.scope_id == 13
+
+    def test_address(self):
+        addr = AddressInformation("", 0)
+        assert addr.address == "0.0.0.0"
+        assert addr.address_family == socket.AF_INET
+        addr.address = "<broadcast>"
+        assert addr.address == "255.255.255.255"
+        assert addr.address_family == socket.AF_INET
+        addr.address = "localhost"
+        assert addr.address == "127.0.0.1"
+        assert addr.address_family == socket.AF_INET
+        addr.address = "192.168.0.1"
+        assert addr.address == "192.168.0.1"
+        assert addr.address_family == socket.AF_INET
+        addr.address = "::0"
+        assert addr.address == "::0"
+        assert addr.address_family == socket.AF_INET6
+        addr.address = "192.168.0.1"
+        assert addr.address == "192.168.0.1"
+        assert addr.address_family == socket.AF_INET
+
+
 class TestTConnect:
     """Tests for T_CONNECT."""
 
@@ -70,9 +164,9 @@ def test_bad_addr_raises(self):
     def test_address_request(self):
         """Test init with an A-ASSOCIATE primitive"""
         request = A_ASSOCIATE()
-        request.called_presentation_address = ("123", 12)
+        request.called_presentation_address = AddressInformation("123.4", 12)
         conn = T_CONNECT(request)
-        assert conn.address == ("123", 12)
+        assert conn.address == ("123.4", 12)
         assert conn.request is request
 
         msg = r"A connection attempt has not yet been made"
@@ -82,7 +176,7 @@ def test_address_request(self):
     def test_result_setter(self):
         """Test setting the result value."""
         request = A_ASSOCIATE()
-        request.called_presentation_address = ("123", 12)
+        request.called_presentation_address = AddressInformation("123.4", 12)
         conn = T_CONNECT(request)
 
         msg = r"Invalid connection result 'foo'"
@@ -95,6 +189,12 @@ def test_result_setter(self):
             conn.result = result
             assert conn.result == result
 
+    def test_address_inf(self):
+        request = A_ASSOCIATE()
+        request.called_presentation_address = AddressInformation("123.4", 12)
+        conn = T_CONNECT(request)
+        assert conn.address_info is request.called_presentation_address
+
 
 class TestAssociationSocket:
     """Tests for the transport.AssociationSocket class."""
@@ -103,42 +203,38 @@ def setup_method(self):
         ae = AE()
         self.assoc = Association(ae, MODE_REQUESTOR)
 
-    def get_listen_socket(self):
-        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-        sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO, pack("ll", 1, 0))
-        sock.bind(("localhost", 11112))
-        sock.listen(5)
-        return sock
-
     def test_init_new(self):
         """Test creating a new AssociationSocket instance."""
-        sock = AssociationSocket(self.assoc)
+        sock = AssociationSocket(self.assoc, address=AddressInformation("", 11112))
 
         assert sock.tls_args is None
         assert sock.select_timeout == 0.5
         assert sock._assoc == self.assoc
         assert isinstance(sock.socket, socket.socket)
+        assert sock.socket.getsockname()[0] == "0.0.0.0"
+        assert sock.socket.getsockname()[1] == 11112
         assert sock._is_connected is False
 
         with pytest.raises(queue.Empty):
             sock.event_queue.get(block=False)
 
-    def test_init_address(self):
-        """Test creating a new bound AssociationSocket instance."""
-        sock = AssociationSocket(self.assoc, address=("127.0.0.1", 11112))
+        sock.close()
+
+        sock = AssociationSocket(self.assoc, address=AddressInformation("::1", 11112))
 
         assert sock.tls_args is None
         assert sock.select_timeout == 0.5
         assert sock._assoc == self.assoc
         assert isinstance(sock.socket, socket.socket)
-        assert sock.socket.getsockname()[0] == "127.0.0.1"
+        assert sock.socket.getsockname()[0] == "::1"
         assert sock.socket.getsockname()[1] == 11112
         assert sock._is_connected is False
 
         with pytest.raises(queue.Empty):
             sock.event_queue.get(block=False)
 
+        sock.close()
+
     def test_init_existing(self):
         """Test creating a new AssociationSocket around existing socket."""
         sock = AssociationSocket(self.assoc, client_socket="abc")
@@ -151,8 +247,8 @@ def test_init_existing(self):
 
         assert sock.event_queue.get(block=False) == "Evt5"
 
-    def test_init_raises(self, caplog):
-        """Test exception is raised if init with client_socket and address."""
+    def test_init_warns(self, caplog):
+        """Test warning is logged if init with client_socket and address."""
         msg = (
             r"AssociationSocket instantiated with both a 'client_socket' "
             r"and bind 'address'. The original socket will not be rebound"
@@ -164,9 +260,18 @@ def test_init_raises(self, caplog):
 
             assert msg in caplog.text
 
+    def test_init_raises(self):
+        """Test warning is logged if init with client_socket and address."""
+        msg = (
+            "Either 'client_socket' or 'address' must be used when creating a new "
+            "AssociationSocket instance"
+        )
+        with pytest.raises(ValueError, match=msg):
+            AssociationSocket(self.assoc)
+
     def test_close_connect(self):
         """Test closing and connecting."""
-        sock = AssociationSocket(self.assoc)
+        sock = AssociationSocket(self.assoc, address=AddressInformation("", 0))
         sock._is_connected = True
         assert sock.socket is not None
         sock.close()
@@ -175,14 +280,15 @@ def test_close_connect(self):
         # Ensure we fail if *something* is listening
         self.assoc.connection_timeout = 1
         request = A_ASSOCIATE()
-        request.called_presentation_address = ("123", 12)
+        request.called_presentation_address = AddressInformation("", 11112)
+        sock.socket = sock._create_socket(AddressInformation("", 0))
         sock.connect(T_CONNECT(request))
         assert sock.event_queue.get() == "Evt17"
         assert sock.socket is None
 
     def test_ready_error(self):
         """Test AssociationSocket.ready."""
-        sock = AssociationSocket(self.assoc, address=("localhost", 0))
+        sock = AssociationSocket(self.assoc, address=AddressInformation("localhost", 0))
         assert sock.ready is False
         sock._is_connected = True
         if platform.system() in ["Windows", "Darwin"]:
@@ -195,7 +301,7 @@ def test_ready_error(self):
 
     def test_print(self):
         """Test str(AssociationSocket)."""
-        sock = AssociationSocket(self.assoc)
+        sock = AssociationSocket(self.assoc, address=AddressInformation("", 0))
         assert sock.__str__() == sock.socket.__str__()
 
     def test_close_socket_none(self):
@@ -224,19 +330,11 @@ def handle_close(event):
 
     def test_get_local_addr(self):
         """Test get_local_addr()."""
-        # Normal use
-        self.ae = ae = AE()
-        ae.acse_timeout = 5
-        ae.dimse_timeout = 5
-        ae.network_timeout = 5
-        ae.add_requested_context(Verification)
-        assoc = ae.associate("localhost", 11113)
-        assert not assoc.is_established
-        assert isinstance(assoc.requestor.address, str)
-        # Exceptional use
-        assert not assoc.is_established
-        addr = assoc.dul.socket.get_local_addr(("", 111111))
-        assert "127.0.0.1" == addr
+        sock = AssociationSocket(self.assoc, address=AddressInformation("", 11112))
+        with pytest.warns(DeprecationWarning, match="get_local_addr"):
+            addr = sock.get_local_addr(("", 111111))
+
+        assert addr == "127.0.0.1"
 
     def test_multiple_pdu_req(self):
         """Test what happens if two PDUs are sent before the select call."""
@@ -313,6 +411,16 @@ def handle_echo(event):
 
         assert 2 == len(events)
 
+    def test_no_socket_connect_raises(self):
+        sock = AssociationSocket(self.assoc, address=AddressInformation("", 0))
+        sock._is_connected = True
+        sock.close()
+        assert sock.socket is None
+
+        msg = r"A socket must be created before calling AssociationSocket.connect\(\)"
+        with pytest.raises(ValueError, match=msg):
+            sock.connect(None)
+
 
 def server_context_v1_2():
     """Return a good TLs v1.2 server SSLContext."""
@@ -374,6 +482,7 @@ def teardown_method(self):
         import importlib
 
         importlib.reload(pynetdicom.transport)
+        importlib.reload(pynetdicom.ae)
 
     def test_tls_not_server_not_client(self):
         """Test associating with no TLS on either end."""