diff --git a/src/infuse_iot/generated/rpc_definitions.py b/src/infuse_iot/generated/rpc_definitions.py
index 3b78489..45c1ae6 100644
--- a/src/infuse_iot/generated/rpc_definitions.py
+++ b/src/infuse_iot/generated/rpc_definitions.py
@@ -161,6 +161,18 @@ class rpc_struct_infuse_state(VLACompatLittleEndianStruct):
     _pack_ = 1
 
 
+class rpc_struct_sockaddr(VLACompatLittleEndianStruct):
+    """`struct sockaddr_in` or `struct sockaddr_in6` compatible address"""
+
+    _fields_ = [
+        ("sin_family", ctypes.c_uint8),
+        ("sin_port", ctypes.c_uint16),
+        ("sin_addr", 16 * ctypes.c_uint8),
+        ("scope_id", ctypes.c_uint8),
+    ]
+    _pack_ = 1
+
+
 class rpc_enum_bt_le_addr_type(enum.IntEnum):
     """Bluetooth LE address type"""
 
@@ -194,6 +206,16 @@ class rpc_enum_data_logger(enum.IntEnum):
     FLASH_REMOVABLE = 2
 
 
+class rpc_enum_zperf_data_source(enum.IntEnum):
+    """Source for zperf data upload"""
+
+    CONSTANT = 0
+    RANDOM = 1
+    FLASH_ONBOARD = 2
+    FLASH_REMOVABLE = 3
+    ENCRYPT = 128
+
+
 class reboot:
     """Reboot the device after a delay"""
 
@@ -634,6 +656,40 @@ class response(VLACompatLittleEndianStruct):
         _pack_ = 1
 
 
+class zperf_upload:
+    """Network upload bandwidth testing using zperf/iperf"""
+
+    HELP = "Network upload bandwidth testing using zperf/iperf"
+    DESCRIPTION = "Network upload bandwidth testing using zperf/iperf"
+    COMMAND_ID = 31
+
+    class request(VLACompatLittleEndianStruct):
+        _fields_ = [
+            ("peer_address", rpc_struct_sockaddr),
+            ("sock_type", ctypes.c_uint8),
+            ("data_source", ctypes.c_uint8),
+            ("duration_ms", ctypes.c_uint32),
+            ("rate_kbps", ctypes.c_uint32),
+            ("packet_size", ctypes.c_uint16),
+        ]
+        _pack_ = 1
+
+    class response(VLACompatLittleEndianStruct):
+        _fields_ = [
+            ("nb_packets_sent", ctypes.c_uint32),
+            ("nb_packets_rcvd", ctypes.c_uint32),
+            ("nb_packets_lost", ctypes.c_uint32),
+            ("nb_packets_outorder", ctypes.c_uint32),
+            ("total_len", ctypes.c_uint64),
+            ("time_in_us", ctypes.c_uint64),
+            ("jitter_in_us", ctypes.c_uint32),
+            ("client_time_in_us", ctypes.c_uint64),
+            ("packet_size", ctypes.c_uint32),
+            ("nb_packets_errors", ctypes.c_uint32),
+        ]
+        _pack_ = 1
+
+
 class file_write_basic:
     """Write a file to the device"""
 
diff --git a/src/infuse_iot/rpc_wrappers/zperf_upload.py b/src/infuse_iot/rpc_wrappers/zperf_upload.py
new file mode 100644
index 0000000..d2f4ffb
--- /dev/null
+++ b/src/infuse_iot/rpc_wrappers/zperf_upload.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+import ctypes
+import ipaddress
+import socket
+
+import tabulate
+
+import infuse_iot.generated.rpc_definitions as defs
+from infuse_iot.commands import InfuseRpcCommand
+from infuse_iot.zephyr.errno import errno
+from infuse_iot.zephyr.net import AddressFamily, SockType
+
+
+class zperf_upload(InfuseRpcCommand, defs.zperf_upload):
+    @classmethod
+    def add_parser(cls, parser):
+        parser.add_argument("--address", "-a", type=str, required=True, help="Peer IP address")
+        parser.add_argument("--port", "-p", type=int, default=5001, help="Peer port")
+        socket_group = parser.add_mutually_exclusive_group(required=True)
+        socket_group.add_argument(
+            "--tcp",
+            dest="sock_type",
+            action="store_const",
+            const=SockType.SOCK_STREAM,
+            help="TCP protocol",
+        )
+        socket_group.add_argument(
+            "--udp",
+            dest="sock_type",
+            action="store_const",
+            const=SockType.SOCK_DGRAM,
+            help="UDP protocol",
+        )
+        source_group = parser.add_mutually_exclusive_group()
+        source_group.add_argument(
+            "--constant",
+            dest="data_source",
+            action="store_const",
+            const=defs.rpc_enum_zperf_data_source.CONSTANT,
+            default=defs.rpc_enum_zperf_data_source.CONSTANT,
+            help="Constant data payload ('z')",
+        )
+        source_group.add_argument(
+            "--random",
+            dest="data_source",
+            action="store_const",
+            const=defs.rpc_enum_zperf_data_source.RANDOM,
+            help="Random data payload",
+        )
+        source_group.add_argument(
+            "--onboard",
+            dest="data_source",
+            action="store_const",
+            const=defs.rpc_enum_zperf_data_source.FLASH_ONBOARD,
+            help="Read from onboard flash logger",
+        )
+        source_group.add_argument(
+            "--removable",
+            dest="data_source",
+            action="store_const",
+            const=defs.rpc_enum_zperf_data_source.FLASH_REMOVABLE,
+            help="Read from removable flash logger",
+        )
+        parser.add_argument("--encrypt", action="store_true", help="Encrypt payloads before transmission")
+        parser.add_argument("--duration", type=int, default=5000, help="Duration to run test over in milliseconds")
+        parser.add_argument("--rate-kbps", type=int, default=0, help="Desired upload rate in kbps")
+        parser.add_argument("--payload-size", type=int, default=512, help="Payload size")
+
+    def __init__(self, args):
+        self.peer_addr = ipaddress.ip_address(args.address)
+        self.peer_port = args.port
+        self.sock_type = args.sock_type
+        self.data_source = args.data_source
+        if args.encrypt:
+            self.data_source |= defs.rpc_enum_zperf_data_source.ENCRYPT
+        self.duration = args.duration
+        self.rate = args.rate_kbps
+        self.packet_size = args.payload_size
+        if self.sock_type == SockType.SOCK_DGRAM:
+            # Add the UDP client header size to the requested payload size
+            self.packet_size += 40
+
+    def request_struct(self):
+        peer_family = (
+            AddressFamily.AF_INET if isinstance(self.peer_addr, ipaddress.IPv4Address) else AddressFamily.AF_INET6
+        )
+        addr_bytes = (16 * ctypes.c_uint8)(*self.peer_addr.packed)
+        peer = defs.rpc_struct_sockaddr(
+            sin_family=peer_family,
+            sin_port=socket.htons(self.peer_port),
+            sin_addr=addr_bytes,
+            scope_id=0,
+        )
+
+        return self.request(
+            peer_address=peer,
+            sock_type=self.sock_type,
+            data_source=self.data_source,
+            duration_ms=self.duration,
+            rate_kbps=self.rate,
+            packet_size=self.packet_size,
+        )
+
+    def request_json(self):
+        return {}
+
+    def handle_response(self, return_code, response):
+        if return_code != 0:
+            print(f"Failed to run zperf ({errno.strerror(-return_code)})")
+            return
+
+        throughput_bps = 8 * response.total_len / (response.client_time_in_us / 1000)
+        print(f"Average Throughput: {throughput_bps / 1000:.3f} kbps")
+        if self.sock_type == SockType.SOCK_DGRAM:
+            recv = 100 * response.nb_packets_rcvd / response.nb_packets_sent
+            loss = 100 * response.nb_packets_lost / response.nb_packets_sent
+            print(f"       Packet Recv: {recv:6.2f}%")
+            print(f"       Packet Loss: {loss:6.2f}%")
+        results = []
+        for field_name, _ in response._fields_:
+            results.append([field_name, getattr(response, field_name)])
+        print(tabulate.tabulate(results))
diff --git a/src/infuse_iot/tools/bt_log.py b/src/infuse_iot/tools/bt_log.py
index 4140e87..0226158 100644
--- a/src/infuse_iot/tools/bt_log.py
+++ b/src/infuse_iot/tools/bt_log.py
@@ -8,6 +8,7 @@
 
 from infuse_iot.commands import InfuseCommand
 from infuse_iot.common import InfuseType
+from infuse_iot.epacket import interface
 from infuse_iot.socket_comms import (
     ClientNotificationConnectionDropped,
     ClientNotificationEpacketReceived,
@@ -15,6 +16,7 @@
     LocalClient,
     default_multicast_address,
 )
+from infuse_iot.tdf import TDF
 
 
 class SubCommand(InfuseCommand):
@@ -24,24 +26,44 @@ class SubCommand(InfuseCommand):
 
     def __init__(self, args):
         self._client = LocalClient(default_multicast_address(), 60.0)
+        self._decoder = TDF()
         self._id = args.id
+        self._data = args.data
 
     @classmethod
     def add_parser(cls, parser):
         parser.add_argument("--id", type=lambda x: int(x, 0), help="Infuse ID to receive logs for")
+        parser.add_argument("--data", action="store_true", help="Subscribe to the data characteristic as well")
 
     def run(self):
         try:
-            with self._client.connection(self._id, GatewayRequestConnectionRequest.DataType.LOGGING) as _:
-                while rsp := self._client.receive():
-                    if isinstance(rsp, ClientNotificationConnectionDropped):
+            types = GatewayRequestConnectionRequest.DataType.LOGGING
+            if self._data:
+                types |= GatewayRequestConnectionRequest.DataType.DATA
+            with self._client.connection(self._id, types) as _:
+                while evt := self._client.receive():
+                    if evt is None:
+                        continue
+                    if isinstance(evt, ClientNotificationConnectionDropped):
                         print(f"Connection to {self._id:016x} lost")
                         break
-                    if (
-                        isinstance(rsp, ClientNotificationEpacketReceived)
-                        and rsp.epacket.ptype == InfuseType.SERIAL_LOG
-                    ):
-                        print(rsp.epacket.payload.decode("utf-8"), end="")
+                    if not isinstance(evt, ClientNotificationEpacketReceived):
+                        continue
+                    source = evt.epacket.route[0]
+                    if source.infuse_id != self._id:
+                        continue
+                    if source.interface != interface.ID.BT_CENTRAL:
+                        continue
+
+                    if evt.epacket.ptype == InfuseType.SERIAL_LOG:
+                        print(evt.epacket.payload.decode("utf-8"), end="")
+                    if evt.epacket.ptype == InfuseType.TDF:
+                        for tdf in self._decoder.decode(evt.epacket.payload):
+                            t = tdf.data[-1]
+                            if len(tdf.data) > 1:
+                                print(f"{tdf.time:.3f} TDF: {t.name}[{len(tdf.data)}]")
+                            else:
+                                print(f"{tdf.time:.3f} TDF: {t.name}")
 
         except KeyboardInterrupt:
             print(f"Disconnecting from {self._id:016x}")
diff --git a/src/infuse_iot/util/soc/nrf.py b/src/infuse_iot/util/soc/nrf.py
index c6317d7..5558aac 100644
--- a/src/infuse_iot/util/soc/nrf.py
+++ b/src/infuse_iot/util/soc/nrf.py
@@ -36,7 +36,7 @@ def soc(device_info):
 
 
 class nRF53(NRFFamily):
-    FICT_ADDRESS = 0x00FF0000
+    FICR_ADDRESS = 0x00FF0000
     DEVICE_ID_OFFSET = 0x204
     CUSTOMER_OFFSET = 0x100
 
diff --git a/src/infuse_iot/zephyr/net.py b/src/infuse_iot/zephyr/net.py
new file mode 100644
index 0000000..f305e4f
--- /dev/null
+++ b/src/infuse_iot/zephyr/net.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+
+import enum
+
+
+class IPProtocol(enum.IntEnum):
+    IPPROTO_IP = 0
+    IPPROTO_ICMP = 1
+    IPPROTO_IGMP = 2
+    IPPROTO_ETH_P_ALL = 3
+    IPPROTO_IPIP = 4
+    IPPROTO_TCP = 6
+    IPPROTO_UDP = 17
+    IPPROTO_IPV6 = 41
+    IPPROTO_ICMPV6 = 58
+    IPPROTO_RAW = 255
+
+
+class ProtocolFamily(enum.IntEnum):
+    PF_UNSPEC = 0
+    PF_INET = 1
+    PF_INET6 = 2
+    PF_PACKET = 3
+    PF_CAN = 4
+    PF_NET_MGMT = 5
+    PF_LOCAL = 6
+    PF_UNIX = PF_LOCAL
+
+
+class AddressFamily(enum.IntEnum):
+    AF_UNSPEC = ProtocolFamily.PF_UNSPEC
+    AF_INET = ProtocolFamily.PF_INET
+    AF_INET6 = ProtocolFamily.PF_INET6
+    AF_PACKET = ProtocolFamily.PF_PACKET
+    AF_CAN = ProtocolFamily.PF_CAN
+    AF_NET_MGMT = ProtocolFamily.PF_NET_MGMT
+    AF_LOCAL = ProtocolFamily.PF_LOCAL
+    AF_UNIX = ProtocolFamily.PF_UNIX
+
+
+class SockType(enum.IntEnum):
+    SOCK_STREAM = 1
+    SOCK_DGRAM = 2
+    SOCK_RAW = 3
