diff --git a/docs/source/index.rst b/docs/source/index.rst index 9bcee52..48ba3e5 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,6 +6,7 @@ RIA Toolkit OSS Documentation Introduction Datatypes Package + SDR Package IO Package Transforms Package Utils Package diff --git a/docs/source/ria_toolkit_oss/sdr/blade.rst b/docs/source/ria_toolkit_oss/sdr/blade.rst new file mode 100644 index 0000000..630e7d7 --- /dev/null +++ b/docs/source/ria_toolkit_oss/sdr/blade.rst @@ -0,0 +1,81 @@ +.. _blade: + +Intro to the BladeRF family +=========================== + +The BladeRF is a versatile software-defined radio (SDR) platform developed by Nuand. It is designed for a wide +range of applications, from wireless communication research to field deployments. BladeRF devices are known +for their high performance, flexibility, and extensive open-source support, making them suitable for both +hobbyists and professionals. The BladeRF is based on the Analog Devices AD9361 RF transceiver, which provides +wide frequency coverage and high bandwidth. + +Supported Models +---------------- + - BladeRF 2.0 Micro xA4: A compact model with a 49 kLE FPGA, ideal for portable applications. + - BladeRF 2.0 Micro xA9: A higher-end version of the Micro with a 115 kLE FPGA, offering more processing power + in a small form factor. + +Key Features +------------ + - Frequency Range: Typically from 47 MHz to 6 GHz, covering a wide range of wireless communication bands. + - Bandwidth: Up to 56 MHz, allowing for wideband signal processing. + - FPGA: Integrated FPGA (varies by model) for real-time processing and custom logic development. + - Connectivity: USB 3.0 interface for high-speed data transfer, with options for GPIO, SPI, and other I/O. + +Hackability: +------------ + - Expansion: The BladeRF features GPIO, expansion headers, and add-on boards, allowing users to extend the + functionality of the device for specific applications, such as additional RF front ends. + - Frequency and Bandwidth Modification: Advanced users can modify the BladeRF's settings and firmware to + explore different frequency bands and optimize the bandwidth for their specific use cases. + +Limitations +----------- + - The complexity of FPGA development may present a steep learning curve for users unfamiliar with hardware + description languages (HDL). + - Bandwidth is capped at 56 MHz, which might not be sufficient for ultra-wideband applications. + - USB 3.0 connectivity is required for optimal performance; using USB 2.0 will significantly limit data + transfer rates. + +Further Information +------------------- + - Official Website: https://www.nuand.com/ + - Documentation: https://www.nuand.com/documentation/ + - GitHub Repository: https://github.com/Nuand/bladeRF + +Installation Instructions (Linux) +--------------------------------- +Step 1: Install the base dependancies and drivers ('Easy method') + + +.. code-block:: bash + + sudo add-apt-repository ppa:nuandllc/bladerf + sudo apt-get update + sudo apt-get install bladerf + sudo apt-get install libbladerf-dev + sudo apt-get install bladerf-fpga-hostedxa4 # Necessary for installation of bladeRF 2.0 Micro A4. + + +Step 2: Create and/or activate your virtual environment + +.. code-block:: bash + + python3 -m venv venv + pip install -r requirements.txt # If relevant + source venv/bin/activate + + +Step 3: from within the virtual environment, clone the bladerf host repo, then build and install the wheel for +bladerf python bindings. + +.. code-block:: bash + + cd ~ + mkdir workarea + cd workarea + git clone --depth 1 https://github.com/Nuand/bladeRF.git + cd bladeRF/host + cd libraries/libbladeRF_bindings/python + sudo python3 setup.py bdist_wheel + pip install dist/*.whl diff --git a/docs/source/ria_toolkit_oss/sdr/hackrf.rst b/docs/source/ria_toolkit_oss/sdr/hackrf.rst new file mode 100644 index 0000000..4487567 --- /dev/null +++ b/docs/source/ria_toolkit_oss/sdr/hackrf.rst @@ -0,0 +1,46 @@ +.. _hackrf: + +Intro to the HackRF +=================== + +The HackRF One is a portable and affordable software-defined radio developed by Great Scott Gadgets. It is an +open source hardware platform that is designed to enable test and development of modern and next generation +radio technologies. + +The HackRF is based on the Analog Devices MAX2839 transceiver chip, which supports both transmission and +reception of signals across a wide frequency range, combined with a MAX5864 RF front-end chip and a +RFFC5072 wideband synthesizer/VCO. + +Supported Models +---------------- + - HackRF One: The standard model with a frequency range of 1 MHz to 6 GHz and a bandwidth of up to 20 MHz. + - Opera Cake for HackRF: An antenna switching add-on board for HackRF One that is configured with command-line software. + +Key Features +------------ + - Frequency Range: 1 MHz to 6 GHz. + - Bandwidth: 2 MHz to 20 MHz. + - Connectivity: USB 2.0 interface with support for power, data, and firmware updates. + - Software Support: Compatible with GNU Radio, SDR#, and other SDR frameworks. + - Onboard Processing: ARM-based LPC4320 processor for digital signal processing and interfacing over USB. + +Hackability: +------------ +TODO + +Limitations +----------- + - Bandwidth is limited to 20 MHz. + - USB 2.0 connectivity might limit data transfer rates compared to USB 3.0 or Ethernet-based SDRs. + +Further Information +------------------- + - Official Website: https://greatscottgadgets.com/hackrf/ + - Documentation: https://hackrf.readthedocs.io/en/latest/ + - GitHub Repository: https://github.com/greatscottgadgets/hackrf + +Installation Instructions (Linux) +--------------------------------- +TODO + +From documentation: https://hackrf.readthedocs.io/en/latest/installing_hackrf_software.html diff --git a/docs/source/ria_toolkit_oss/sdr/pluto.rst b/docs/source/ria_toolkit_oss/sdr/pluto.rst new file mode 100644 index 0000000..59f68e2 --- /dev/null +++ b/docs/source/ria_toolkit_oss/sdr/pluto.rst @@ -0,0 +1,73 @@ +.. _pluto: + +Intro to the Pluto +================== + +The ADALM-PLUTO (PlutoSDR) is a portable and affordable software-defined radio developed by Analog Devices. +It is designed for learning, experimenting, and prototyping in the field of wireless communication. The PlutoSDR +is popular among students, educators, and hobbyists due to its versatility and ease of use. + +The PlutoSDR is based on the AD9363 transceiver chip, which supports both transmission and reception of signals +across a wide frequency range. The device is supported by a robust open-source ecosystem, making it ideal for +hands-on learning and rapid prototyping. + +Supported Models +---------------- + - ADALM-PLUTO: The standard model with a frequency range of 325 MHz to 3.8 GHz and a bandwidth of up to 20 MHz. + - Modified ADALM-PLUTO: Some users modify their PlutoSDR to extend the frequency range to approximately 70 MHz + to 6 GHz by applying firmware patches with unqualified RF performance. + +Key Features +------------ + - Frequency Range: 325 MHz to 3.8 GHz (standard), expandable with modifications. + - Bandwidth: Up to 20 MHz, can be increased to 56 MHz with firmware modifications. + - Connectivity: USB 2.0 interface with support for power, data, and firmware updates. + - Software Support: Compatible with GNU Radio, MATLAB, Simulink, and other SDR frameworks. + - Onboard Processing: Integrated ARM Cortex-A9 processor for custom applications and signal processing. + - Hackability: + - Frequency Range and Bandwidth: The default frequency range of 325 MHz to 3.8 GHz can be expanded to + approximately 70 MHz to 6 GHz, and the bandwidth can be increased from 20 MHz to 56 MHz by modifying + the device's firmware. + - 2x2 MIMO: On Rev C models, users can unlock 2x2 MIMO (Multiple Input Multiple Output) functionality by + wiring UFL to SMA connectors to the device's PCB, effectively turning the device into a dual-channel SDR. + +Limitations +----------- + - Bandwidth is limited to 20 MHz by default, but can be increased to 56 MHz with modifications, which may + affect stability. + - USB 2.0 connectivity might limit data transfer rates compared to USB 3.0 or Ethernet-based SDRs. + +Further Information +------------------- + - Documentation: https://wiki.analog.com/university/tools/pluto + +This module provides tools for interfacing with ADALM-PLUTO devices, allowing for configuration, data streaming, +signal processing, custom application development, and advanced hardware modifications. + +Installation Instructions (Linux) +--------------------------------- + +The pluto is generally usable with Pip. To build and install the drivers from source, see below: + +.. code-block:: bash + + sudo apt-get install build-essential git libxml2-dev bison flex libcdk5-dev cmake python3-pip libusb-1.0-0-dev libavahi-client-dev libavahi-common-dev libaio-dev + cd ~ + git clone --branch v0.23 https://github.com/analogdevicesinc/libiio.git + cd libiio + mkdir build + cd build + cmake -DPYTHON_BINDINGS=ON .. + make -j$(nproc) + sudo make install + sudo ldconfig + + cd ~ + git clone https://github.com/analogdevicesinc/libad9361-iio.git + cd libad9361-iio + mkdir build + cd build + cmake .. + make -j$(nproc) + sudo make install + pip install pyadi-iio diff --git a/docs/source/ria_toolkit_oss/sdr/ria_toolkit_oss.sdr.rst b/docs/source/ria_toolkit_oss/sdr/ria_toolkit_oss.sdr.rst new file mode 100644 index 0000000..071e881 --- /dev/null +++ b/docs/source/ria_toolkit_oss/sdr/ria_toolkit_oss.sdr.rst @@ -0,0 +1,29 @@ +SDR Package (ria_toolkit_oss.sdr) +================================= + +.. automodule:: ria_toolkit_oss.sdr + :members: + :undoc-members: + :inherited-members: + :show-inheritance: + +Radio Classes +------------- + +.. autoclass:: ria_toolkit_oss.sdr.hackrf.HackRF +.. autoclass:: ria_toolkit_oss.sdr.pluto.Pluto +.. autoclass:: ria_toolkit_oss.sdr.usrp.USRP + + +Additional Information +---------------------- + +.. toctree:: + :maxdepth: 2 + + Tx-Rx Example + Blade + HackRF + Pluto + USRP + RTL-SDR diff --git a/docs/source/ria_toolkit_oss/sdr/rtl.rst b/docs/source/ria_toolkit_oss/sdr/rtl.rst new file mode 100644 index 0000000..da7c3ef --- /dev/null +++ b/docs/source/ria_toolkit_oss/sdr/rtl.rst @@ -0,0 +1,39 @@ +.. _rtl: + +Intro to the RTL +================ + + RTL-SDR (RTL2832U Software Defined Radio) is a low-cost USB dongle originally designed for digital TV reception + that has been repurposed as a wideband software-defined radio. RTL-SDR devices are popular for hobbyist use due to + their affordability and wide range of applications. + + The RTL-SDR is based on the Realtek RTL2832U chipset, which features direct sampling and demodulation of RF + signals. These devices are commonly used for tasks such as listening to FM radio, monitoring aircraft traffic + (ADS-B), receiving weather satellite images, and more. + +Supported Models +---------------- + - Generic RTL-SDR Dongle: The most common variant, usually featuring an R820T or R820T2 tuner. + - RTL-SDR Blog V3: An enhanced version with additional features like direct sampling mode and a bias tee for + powering external devices. + +Key Features +------------ + - Frequency Range: Typically from 24 MHz to 1.7 GHz, depending on the tuner chip. + - Bandwidth: Limited to about 2.4 MHz, making it suitable for narrowband applications. + - Connectivity: USB 2.0 interface, plug-and-play on most platforms. + - Software Support: Compatible with SDR software like SDR#, GQRX, and GNU Radio. + +Limitations +----------- + - Narrow bandwidth compared to more expensive SDRs, which may limit some applications. + - Sensitivity and performance can vary depending on the specific model and components. + - Requires external software for signal processing and analysis. + +Further Information +------------------- + - Official Website: https://www.rtl-sdr.com/ + - Documentation: https://www.rtl-sdr.com/rtl-sdr-quick-start-guide/ + +This module provides tools for interfacing with RTL-SDR devices, allowing for tuning, data acquisition, and basic +signal processing. diff --git a/docs/source/ria_toolkit_oss/sdr/txrx.rst b/docs/source/ria_toolkit_oss/sdr/txrx.rst new file mode 100644 index 0000000..0ef244f --- /dev/null +++ b/docs/source/ria_toolkit_oss/sdr/txrx.rst @@ -0,0 +1,86 @@ +.. _txrx: + +Rx-Tx Example +============= + +This section provides an overview of the example code for working with SDRs using the ``Blade`` class from the +`utils.sdr` module. The example can be used for other radios as well, as ``utils.sdr`` has a common set of interfaces. + +.. contents:: + :local: + +Introduction +------------ + +The following examples demonstrate how to initialize an SDR, record a signal, and transmit a custom waveform. +These examples assume familiarity with Python and SDR concepts. + +Example 1: Recording a Signal +----------------------------- + +In this example, we initialize the `Blade` SDR, configure it to record a signal for a specified duration, and then +visualize the recorded data. + +.. code-block:: python + + from utils.sdr.blade import Blade + from utils.data.recording import Recording + import time + + my_radio = Blade() + print(my_radio) + print(type(my_radio)) + my_radio.init_rx( + sample_rate=1e6, + center_frequency=2.44e9, + gain=50, + channel=0 + ) + rx_time = 0.01 + start = time.time() + my_rec = my_radio.record(rx_time=rx_time) + end = time.time() + print(f"Total time: {end-start} seconds") + print(len(my_rec)) + my_rec.view() + +Example 2: Transmitting a Custom Waveform +----------------------------------------- + +This example illustrates how to generate a custom chirp signal and transmit it using the ``Blade`` SDR. The waveform is +created using the ``numpy`` library and encapsulated in a ``Recording`` object. + +.. code-block:: python + + from utils.sdr.blade import Blade + from utils.data.recording import Recording + import time + import numpy as np + + num_samples = 1_000_000 # Total number of samples + num_chirps = 10 # Number of upchirps + sample_rate = 1e6 # Sample rate in Hz (arbitrary choice for normalization) + chirp_duration = num_samples // num_chirps / sample_rate # Duration of each chirp in seconds + f_start = 0 # Start frequency of the chirp (normalized) + f_end = 0.5 * sample_rate # End frequency of the chirp (normalized to Nyquist) + iq_data = np.tile(np.exp(2j * np.pi * (np.linspace(0, num_samples // num_chirps / sample_rate, num_samples // num_chirps, endpoint=False) * f_start + (f_end - f_start) / (2 * (num_samples // num_chirps / sample_rate)) * np.linspace(0, num_samples // num_chirps / sample_rate, num_samples // num_chirps, endpoint=False)**2)), num_chirps)[:num_samples].astype("complex64") + + iq_data = Recording(data=iq_data) + + my_radio = Blade() + my_radio.init_tx( + sample_rate=1e6, + center_frequency=2.44e9, + gain=50, + channel=0 + ) + start = time.time() + my_radio.transmit_recording(recording=iq_data, tx_time=10,) + end = time.time() + print(f"Total time: {end-start} seconds") + +Conclusion +---------- + +These examples provide a foundation for working with SDRs using the ``Blade`` class. By customizing the parameters, +you can adapt these scripts to various signal processing and SDR tasks. diff --git a/docs/source/ria_toolkit_oss/sdr/usrp.rst b/docs/source/ria_toolkit_oss/sdr/usrp.rst new file mode 100644 index 0000000..a2e1c85 --- /dev/null +++ b/docs/source/ria_toolkit_oss/sdr/usrp.rst @@ -0,0 +1,83 @@ +.. _usrp: + +Intro to the USRP family +======================== + +The USRP (Universal Software Radio Peripheral) product line is a series of software-defined radios (SDRs) +developed by Ettus Research. These devices are widely used in academia, industry, and research for various +wireless communication applications, ranging from simple experimentation to complex signal processing tasks. + +USRP devices offer a flexible platform that can be used with various software frameworks, including GNU Radio +and the USRP Hardware Driver (UHD). The product line includes both entry-level models for hobbyists and +advanced models for professional and research use. + +Supported Models +---------------- + - USRP B200/B210: Compact, single-board, full-duplex, with a wide frequency range. + - USRP N200/N210: High-performance models with increased bandwidth and connectivity options. + - USRP X300/X310: High-end models featuring large bandwidth, multiple MIMO channels, and support for GPSDO. + - USRP E310/E320: Embedded devices with onboard processing capabilities. + - USRP B200mini: Ultra-compact model for portable and embedded applications. + +Key Features +------------ + - Frequency Range: Typically covers from DC to 6 GHz, depending on the model and daughter boards used. + - Bandwidth: Varies by model, up to 160 MHz in some high-end versions. + - Connectivity: Includes USB 3.0, Ethernet, and PCIe interfaces depending on the model. + - Software Support: Compatible with UHD, GNU Radio, and other SDR frameworks. + +Hackability +----------- + - The UHD library is fully open source and can be modified to meet user untention. + - Certain USRP models have "RFNoC" which streamlines the inclusion of custom FPGA processing in a USRP. + +Limitations +----------- + - Some models may have limited bandwidth or processing capabilities. + - Compatibility with certain software tools may vary depending on the version of the UHD. + - Price range can be a consideration, especially for high-end models. + +Further Information +------------------- + - Official Website: https://www.ettus.com/ + - Documentation: https://kb.ettus.com/USRP_Hardware_Driver_and_Interfaces + +Installation Instructions (Linux) +--------------------------------- + +Simple apt installations (may not work for all targets): + +.. code-block:: bash + + sudo apt-get install libuhd-dev uhd-host python3-uhd + +Simple build instructions: + +.. code-block:: bash + + sudo apt-get install git cmake libboost-all-dev libusb-1.0-0-dev python3-docutils python3-mako python3-numpy python3-requests python3-ruamel.yaml python3-setuptools build-essential + cd ~ + git clone https://github.com/EttusResearch/uhd.git + cd uhd/host + mkdir build + cd build + cmake -DENABLE_TESTS=OFF -DENABLE_C_API=OFF -DENABLE_MANUAL=OFF .. + make -j8 + sudo make install + sudo ldconfig + + +Getting UHD working in a python virtual environment: + +.. code-block:: bash + + python3 -m venv venv + pip install -r requirements.txt # If relevant + source venv/bin/activate + +Find your dist packages and add to `PYTHONPATH`. Example: + +.. code-block:: bash + + export PYTHONPATH='/usr/local/lib/python3.10/site-packages/' + export PYTHONPATH='/usr/local/lib/python3.10/dist-packages/:$PYTHONPATH' diff --git a/poetry.lock b/poetry.lock index 1cdcac7..6c194a1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -154,6 +154,104 @@ files = [ {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, ] +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "implementation_name == \"pypy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "chardet" version = "5.2.0" @@ -877,6 +975,19 @@ files = [ {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, ] +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "implementation_name == \"pypy\"" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + [[package]] name = "pyflakes" version = "3.4.0" @@ -1005,6 +1116,111 @@ files = [ {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, ] +[[package]] +name = "pyzmq" +version = "27.1.0" +description = "Python bindings for 0MQ" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4"}, + {file = "pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556"}, + {file = "pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b"}, + {file = "pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e"}, + {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526"}, + {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1"}, + {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386"}, + {file = "pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda"}, + {file = "pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f"}, + {file = "pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32"}, + {file = "pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86"}, + {file = "pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581"}, + {file = "pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f"}, + {file = "pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e"}, + {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e"}, + {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2"}, + {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394"}, + {file = "pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f"}, + {file = "pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97"}, + {file = "pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07"}, + {file = "pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc"}, + {file = "pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113"}, + {file = "pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233"}, + {file = "pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31"}, + {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28"}, + {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856"}, + {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496"}, + {file = "pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd"}, + {file = "pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf"}, + {file = "pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f"}, + {file = "pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5"}, + {file = "pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6"}, + {file = "pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7"}, + {file = "pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05"}, + {file = "pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9"}, + {file = "pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128"}, + {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39"}, + {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97"}, + {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db"}, + {file = "pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c"}, + {file = "pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2"}, + {file = "pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e"}, + {file = "pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a"}, + {file = "pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea"}, + {file = "pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96"}, + {file = "pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d"}, + {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146"}, + {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd"}, + {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a"}, + {file = "pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92"}, + {file = "pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0"}, + {file = "pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7"}, + {file = "pyzmq-27.1.0-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:18339186c0ed0ce5835f2656cdfb32203125917711af64da64dbaa3d949e5a1b"}, + {file = "pyzmq-27.1.0-cp38-cp38-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:753d56fba8f70962cd8295fb3edb40b9b16deaa882dd2b5a3a2039f9ff7625aa"}, + {file = "pyzmq-27.1.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b721c05d932e5ad9ff9344f708c96b9e1a485418c6618d765fca95d4daacfbef"}, + {file = "pyzmq-27.1.0-cp38-cp38-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be883ff3d722e6085ee3f4afc057a50f7f2e0c72d289fd54df5706b4e3d3a50"}, + {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b2e592db3a93128daf567de9650a2f3859017b3f7a66bc4ed6e4779d6034976f"}, + {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ad68808a61cbfbbae7ba26d6233f2a4aa3b221de379ce9ee468aa7a83b9c36b0"}, + {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e2687c2d230e8d8584fbea433c24382edfeda0c60627aca3446aa5e58d5d1831"}, + {file = "pyzmq-27.1.0-cp38-cp38-win32.whl", hash = "sha256:a1aa0ee920fb3825d6c825ae3f6c508403b905b698b6460408ebd5bb04bbb312"}, + {file = "pyzmq-27.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:df7cd397ece96cf20a76fae705d40efbab217d217897a5053267cd88a700c266"}, + {file = "pyzmq-27.1.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:96c71c32fff75957db6ae33cd961439f386505c6e6b377370af9b24a1ef9eafb"}, + {file = "pyzmq-27.1.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:49d3980544447f6bd2968b6ac913ab963a49dcaa2d4a2990041f16057b04c429"}, + {file = "pyzmq-27.1.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:849ca054d81aa1c175c49484afaaa5db0622092b5eccb2055f9f3bb8f703782d"}, + {file = "pyzmq-27.1.0-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3970778e74cb7f85934d2b926b9900e92bfe597e62267d7499acc39c9c28e345"}, + {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:da96ecdcf7d3919c3be2de91a8c513c186f6762aa6cf7c01087ed74fad7f0968"}, + {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9541c444cfe1b1c0156c5c86ece2bb926c7079a18e7b47b0b1b3b1b875e5d098"}, + {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e30a74a39b93e2e1591b58eb1acef4902be27c957a8720b0e368f579b82dc22f"}, + {file = "pyzmq-27.1.0-cp39-cp39-win32.whl", hash = "sha256:b1267823d72d1e40701dcba7edc45fd17f71be1285557b7fe668887150a14b78"}, + {file = "pyzmq-27.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0c996ded912812a2fcd7ab6574f4ad3edc27cb6510349431e4930d4196ade7db"}, + {file = "pyzmq-27.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:346e9ba4198177a07e7706050f35d733e08c1c1f8ceacd5eb6389d653579ffbc"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74"}, + {file = "pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271"}, + {file = "pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:50081a4e98472ba9f5a02850014b4c9b629da6710f8f14f3b15897c666a28f1b"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:510869f9df36ab97f89f4cff9d002a89ac554c7ac9cadd87d444aa4cf66abd27"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f8426a01b1c4098a750973c37131cf585f61c7911d735f729935a0c701b68d3"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:726b6a502f2e34c6d2ada5e702929586d3ac948a4dbbb7fed9854ec8c0466027"}, + {file = "pyzmq-27.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:bd67e7c8f4654bef471c0b1ca6614af0b5202a790723a58b79d9584dc8022a78"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:722ea791aa233ac0a819fc2c475e1292c76930b31f1d828cb61073e2fe5e208f"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:01f9437501886d3a1dd4b02ef59fb8cc384fa718ce066d52f175ee49dd5b7ed8"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4a19387a3dddcc762bfd2f570d14e2395b2c9701329b266f83dd87a2b3cbd381"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c618fbcd069e3a29dcd221739cacde52edcc681f041907867e0f5cc7e85f172"}, + {file = "pyzmq-27.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ff8d114d14ac671d88c89b9224c63d6c4e5a613fe8acd5594ce53d752a3aafe9"}, + {file = "pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540"}, +] + +[package.dependencies] +cffi = {version = "*", markers = "implementation_name == \"pypy\""} + [[package]] name = "quantiphy" version = "2.20" @@ -1917,7 +2133,22 @@ files = [ {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, ] +[[package]] +name = "zmq" +version = "0.0.0" +description = "You are probably looking for pyzmq." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "zmq-0.0.0.tar.gz", hash = "sha256:6b1a1de53338646e8c8405803cffb659e8eb7bb02fff4c9be62a7acfac8370c9"}, + {file = "zmq-0.0.0.zip", hash = "sha256:21cfc6be254c9bc25e4dabb8a3b2006a4227966b7b39a637426084c8dc6901f7"}, +] + +[package.dependencies] +pyzmq = "*" + [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "8fafbb6cdc3f1490399a4cea9520376c1743cf30b3430d9c3ad35ff5754bb850" +content-hash = "c351530d2d67ae2302b24199aaf6c78fa2bdbaaa39b40e172540f0b97b381b8f" diff --git a/pyproject.toml b/pyproject.toml index f1ccea1..adcb9de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,8 @@ dependencies = [ "quantiphy (>=2.20,<3.0)", "plotly (>=6.3.0,<7.0.0)", "h5py (>=3.14.0,<4.0.0)", - "pandas (>=2.3.2,<3.0.0)" + "pandas (>=2.3.2,<3.0.0)", + "zmq (>=0.0.0,<0.0.1)" ] [tool.poetry] diff --git a/src/ria_toolkit_oss/sdr/__init__.py b/src/ria_toolkit_oss/sdr/__init__.py new file mode 100644 index 0000000..907b121 --- /dev/null +++ b/src/ria_toolkit_oss/sdr/__init__.py @@ -0,0 +1,12 @@ +""" +The SDR package provides a unified API for working with a variety of software-defined radios. +It streamlines tasks involving signal reception and transmission, as well as common administrative +operations such as detecting and configuring available devices. + +To add support for a new radio, subclass the ``SDR`` interface and implement all abstract methods. If you experience difficulties, please +`contact us `_, we are happy to provide additional direction and/or help with the implementation details. +""" + +__all__ = ["SDR"] + +from .sdr import SDR diff --git a/src/ria_toolkit_oss/sdr/blade.py b/src/ria_toolkit_oss/sdr/blade.py new file mode 100644 index 0000000..f7a3703 --- /dev/null +++ b/src/ria_toolkit_oss/sdr/blade.py @@ -0,0 +1,383 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.datatypes import Recording + +from bladerf import _bladerf + + +class Blade(SDR): + + def __init__(self, identifier=""): + """ + Initialize a BladeRF device object and connect to the SDR hardware. + + :param identifier: Not used for BladeRF. + + BladeRF devices cannot currently be selected with and identifier value. + If there are multiple connected devices, the device in use may be selected randomly. + """ + + if identifier != "": + print(f"Warning, radio identifier {identifier} provided for Blade but will not be used.") + + uut = self._probe_bladerf() + + if uut is None: + print("No bladeRFs detected. Exiting.") + self._shutdown(error=-1, board=None) + + print(uut) + + self.device = _bladerf.BladeRF(uut) + self._print_versions(device=self.device) + + super().__init__() + + def _shutdown(self, error=0, board=None): + print("Shutting down with error code: " + str(error)) + if board is not None: + board.close() + + # TODO why does this create an error under any conditions? + raise OSError("Shutdown initiated with error code: {}".format(error)) + + def _probe_bladerf(self): + device = None + print("Searching for bladeRF devices...") + try: + devinfos = _bladerf.get_device_list() + if len(devinfos) == 1: + device = "{backend}:device={usb_bus}:{usb_addr}".format(**devinfos[0]._asdict()) + print("Found bladeRF device: " + str(device)) + if len(devinfos) > 1: + print("Unsupported feature: more than one bladeRFs detected.") + print("\n".join([str(devinfo) for devinfo in devinfos])) + self._shutdown(error=-1, board=None) + except _bladerf.BladeRFError: + print("No bladeRF devices found.") + pass + return device + + def _print_versions(self, device=None): + print("libbladeRF version:\t" + str(_bladerf.version())) + if device is not None: + print("Firmware version:\t" + str(device.get_fw_version())) + print("FPGA version:\t\t" + str(device.get_fpga_version())) + return 0 + + def close(self): + self.device.close() + + def init_rx( + self, + sample_rate: int | float, + center_frequency: int | float, + gain: int, + channel: int, + buffer_size: Optional[int] = 8192, + gain_mode: Optional[str] = "absolute", + ): + """ + Initializes the BladeRF for receiving. + + :param sample_rate: The sample rate for receiving. + :type sample_rate: int or float + :param center_frequency: The center frequency of the recording. + :type center_frequency: int or float + :param gain: The gain set for receiving on the BladeRF + :type gain: int + :param channel: The channel the BladeRF is set to. + :type channel: int + :param buffer_size: The buffer size during receive. Defaults to 8192. + :type buffer_size: int + """ + print("Initializing RX") + + # Configure BladeRF + self._set_rx_channel(channel) + self._set_rx_sample_rate(sample_rate) + self._set_rx_center_frequency(center_frequency) + self._set_rx_gain(channel, gain, gain_mode) + self._set_rx_buffer_size(buffer_size) + + bw = self.rx_sample_rate + if bw < 200000: + bw = 200000 + elif bw > 56000000: + bw = 56000000 + self.rx_ch.bandwidth = bw + + self._rx_initialized = True + self._tx_initialized = False + + def init_tx( + self, + sample_rate: int | float, + center_frequency: int | float, + gain: int, + channel: int, + buffer_size: Optional[int] = 8192, + gain_mode: Optional[str] = "absolute", + ): + """ + Initializes the BladeRF for transmitting. + + :param sample_rate: The sample rate for transmitting. + :type sample_rate: int or float + :param center_frequency: The center frequency of the recording. + :type center_frequency: int or float + :param gain: The gain set for transmitting on the BladeRF + :type gain: int + :param channel: The channel the BladeRF is set to. + :type channel: int + :param buffer_size: The buffer size during transmission. Defaults to 8192. + :type buffer_size: int + """ + + # Configure BladeRF + self._set_tx_channel(channel) + self._set_tx_sample_rate(sample_rate) + self._set_tx_center_frequency(center_frequency) + self._set_tx_gain(channel=channel, gain=gain, gain_mode=gain_mode) + self._set_tx_buffer_size(buffer_size) + + bw = self.tx_sample_rate + if bw < 200000: + bw = 200000 + elif bw > 56000000: + bw = 56000000 + self.tx_ch.bandwidth = bw + + if self.device is None: + print("TX: Invalid device handle.") + return -1 + + if self.tx_channel is None: + print("TX: Invalid channel.") + return -1 + + self._tx_initialized = True + self._rx_initialized = False + return 0 + + def _stream_rx(self, callback): + if not self._rx_initialized: + raise RuntimeError("RX was not initialized. init_rx() must be called before _stream_rx() or record()") + + # Setup synchronous stream + self.device.sync_config( + layout=_bladerf.ChannelLayout.RX_X1, + fmt=_bladerf.Format.SC16_Q11, + num_buffers=16, + buffer_size=self.rx_buffer_size, + num_transfers=8, + stream_timeout=3500000000, + ) + + self.rx_ch.enable = True + self.bytes_per_sample = 4 + + print("Blade Starting RX...") + self._enable_rx = True + + while self._enable_rx: + # Create receive buffer and read in samples to buffer + # Add them to a list to convert and save after stream is finished + buffer = bytearray(self.rx_buffer_size * self.bytes_per_sample) + self.device.sync_rx(buffer, self.rx_buffer_size) + signal = self._convert_rx_samples(buffer) + # samples = convert_to_2xn(signal) + self.buffer = buffer + # send callback complex signal + callback(buffer=signal, metadata=None) + + # Disable module + print("Blade RX Completed.") + self.rx_ch.enable = False + + def record(self, num_samples): + if not self._rx_initialized: + raise RuntimeError("RX was not initialized. init_rx() must be called before _stream_rx() or record()") + + # Setup synchronous stream + self.device.sync_config( + layout=_bladerf.ChannelLayout.RX_X1, + fmt=_bladerf.Format.SC16_Q11, + num_buffers=16, + buffer_size=self.rx_buffer_size, + num_transfers=8, + stream_timeout=3500000000, + ) + + self.rx_ch.enable = True + self.bytes_per_sample = 4 + + print("Blade Starting RX...") + self._enable_rx = True + + store_array = np.zeros((1, (num_samples // self.rx_buffer_size + 1) * self.rx_buffer_size), dtype=np.complex64) + + for i in range(num_samples // self.rx_buffer_size + 1): + # Create receive buffer and read in samples to buffer + # Add them to a list to convert and save after stream is finished + buffer = bytearray(self.rx_buffer_size * self.bytes_per_sample) + self.device.sync_rx(buffer, self.rx_buffer_size) + signal = self._convert_rx_samples(buffer) + # samples = convert_to_2xn(signal) + store_array[:, i * self.rx_buffer_size : (i + 1) * self.rx_buffer_size] = signal + + # Disable module + print("Blade RX Completed.") + self.rx_ch.enable = False + metadata = { + "source": self.__class__.__name__, + "sample_rate": self.rx_sample_rate, + "center_frequency": self.rx_center_frequency, + "gain": self.rx_gain, + } + + return Recording(data=store_array[:, :num_samples], metadata=metadata) + + def _stream_tx(self, callback): + + # Setup stream + self.device.sync_config( + layout=_bladerf.ChannelLayout.TX_X1, + fmt=_bladerf.Format.SC16_Q11, + num_buffers=16, + buffer_size=8192, + num_transfers=8, + stream_timeout=3500, + ) + + # Enable module + self.tx_ch.enable = True + self._enable_tx = True + + print("Blade Starting TX...") + + while self._enable_tx: + buffer = callback(self.tx_buffer_size) # [0] + byte_array = self._convert_tx_samples(buffer) + self.device.sync_tx(byte_array, len(buffer)) + + # Disable module + print("Blade TX Completed.") + self.tx_ch.enable = False + + def _convert_rx_samples(self, samples): + samples = np.frombuffer(samples, dtype=np.int16).astype(np.float32) + samples /= 2048 + samples = samples[::2] + 1j * samples[1::2] + return samples + + def _convert_tx_samples(self, samples): + tx_samples = np.empty(samples.size * 2, dtype=np.float32) + tx_samples[::2] = np.real(samples) # Real part + tx_samples[1::2] = np.imag(samples) # Imaginary part + + tx_samples *= 2048 + tx_samples = tx_samples.astype(np.int16) + byte_array = tx_samples.tobytes() + + return byte_array + + def _set_rx_channel(self, channel): + self.rx_channel = channel + self.rx_ch = self.device.Channel(_bladerf.CHANNEL_RX(channel)) + print(f"\nBlade channel = {self.rx_ch}") + + def _set_rx_sample_rate(self, sample_rate): + self.rx_sample_rate = sample_rate + self.rx_ch.sample_rate = self.rx_sample_rate + print(f"Blade sample rate = {self.rx_ch.sample_rate}") + + def _set_rx_center_frequency(self, center_frequency): + self.rx_center_frequency = center_frequency + self.rx_ch.frequency = center_frequency + print(f"Blade center frequency = {self.rx_ch.frequency}") + + def _set_rx_gain(self, channel, gain, gain_mode): + + rx_gain_min = self.device.get_gain_range(channel)[0] + rx_gain_max = self.device.get_gain_range(channel)[1] + + if gain_mode == "relative": + if gain > 0: + raise ValueError( + "When gain_mode = 'relative', gain must be < 0. This sets \ + the gain relative to the maximum possible gain." + ) + else: + abs_gain = rx_gain_max + gain + else: + abs_gain = gain + + if abs_gain < rx_gain_min or abs_gain > rx_gain_max: + abs_gain = min(max(gain, rx_gain_min), rx_gain_max) + print(f"Gain {abs_gain} out of range for Blade.") + print(f"Gain range: {rx_gain_min} to {rx_gain_max} dB") + + self.rx_gain = abs_gain + self.rx_ch.gain = abs_gain + + print(f"Blade gain = {self.rx_ch.gain}") + + def _set_rx_buffer_size(self, buffer_size): + self.rx_buffer_size = buffer_size + + def _set_tx_channel(self, channel): + self.tx_channel = channel + self.tx_ch = self.device.Channel(_bladerf.CHANNEL_TX(self.tx_channel)) + print(f"\nBlade channel = {self.tx_ch}") + + def _set_tx_sample_rate(self, sample_rate): + self.tx_sample_rate = sample_rate + self.tx_ch.sample_rate = self.tx_sample_rate + print(f"Blade sample rate = {self.tx_ch.sample_rate}") + + def _set_tx_center_frequency(self, center_frequency): + self.tx_center_frequency = center_frequency + self.tx_ch.frequency = center_frequency + print(f"Blade center frequency = {self.tx_ch.frequency}") + + def _set_tx_gain(self, channel, gain, gain_mode): + + tx_gain_min = self.device.get_gain_range(channel)[0] + tx_gain_max = self.device.get_gain_range(channel)[1] + + if gain_mode == "relative": + if gain > 0: + raise ValueError( + "When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain." + ) + else: + abs_gain = tx_gain_max + gain + else: + abs_gain = gain + + if abs_gain < tx_gain_min or abs_gain > tx_gain_max: + abs_gain = min(max(gain, tx_gain_min), tx_gain_max) + print(f"Gain {abs_gain} out of range for Blade.") + print(f"Gain range: {tx_gain_min} to {tx_gain_max} dB") + + self.tx_gain = abs_gain + self.tx_ch.gain = abs_gain + + print(f"Blade gain = {self.tx_ch.gain}") + + def _set_tx_buffer_size(self, buffer_size): + self.tx_buffer_size = buffer_size + + def set_clock_source(self, source): + if source.lower() == "external": + self.device.set_pll_enable(True) + elif source.lower() == "internal": + print("Disabling PLL") + self.device.set_pll_enable(False) + + print(f"Clock source set to {self.device.get_clock_select()}") + print(f"PLL Reference set to {self.device.get_pll_refclk()}") diff --git a/src/ria_toolkit_oss/sdr/hackrf.py b/src/ria_toolkit_oss/sdr/hackrf.py new file mode 100644 index 0000000..2e2943e --- /dev/null +++ b/src/ria_toolkit_oss/sdr/hackrf.py @@ -0,0 +1,159 @@ +import time +import warnings +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.sdr.sdr import SDR + +from .libhackrf import HackRF as hrf + + +class HackRF(SDR): + def __init__(self, identifier=""): + """ + Initialize a HackRF device object and connect to the SDR hardware. + + :param identifier: Not used for HackRF. + + HackRF devices cannot currently be selected with and identifier value. + If there are multiple connected devices, the device in use may be selected randomly. + """ + + if identifier != "": + print(f"Warning, radio identifier {identifier} provided for HackRF but will not be used.") + + print("Initializing HackRF radio.") + try: + super().__init__() + + self.radio = hrf() + print("Successfully found HackRF radio.") + except Exception as e: + print("Failed to find HackRF radio.") + raise e + + super().__init__() + + def init_rx(self, sample_rate, center_frequency, gain, channel, gain_mode): + self._tx_initialized = False + self._rx_initialized = True + return NotImplementedError("RX not yet implemented for HackRF") + + def init_tx( + self, + sample_rate: int | float, + center_frequency: int | float, + gain: int, + channel: int, + gain_mode: Optional[str] = "absolute", + ): + """ + Initializes the HackRF for transmitting. + + :param sample_rate: The sample rate for transmitting. + :type sample_rate: int or float + :param center_frequency: The center frequency of the recording. + :type center_frequency: int or float + :param gain: The gain set for transmitting on the HackRF + :type gain: int + :param channel: The channel the HackRF is set to. (Not actually used) + :type channel: int + :param buffer_size: The buffer size during transmit. Defaults to 10000. + :type buffer_size: int + """ + + print("Initializing TX") + self.tx_sample_rate = sample_rate + self.radio.sample_rate = int(sample_rate) + print(f"HackRF sample rate = {self.radio.sample_rate}") + + self.tx_center_frequency = center_frequency + self.radio.center_freq = int(center_frequency) + print(f"HackRF center frequency = {self.radio.center_freq}") + + self.radio.enable_amp() + + tx_gain_min = 0 + tx_gain_max = 47 + if gain_mode == "relative": + if gain > 0: + raise ValueError( + "When gain_mode = 'relative', gain must be < 0. This \ + sets the gain relative to the maximum possible gain." + ) + else: + abs_gain = tx_gain_max + gain + else: + abs_gain = gain + + if abs_gain < tx_gain_min or abs_gain > tx_gain_max: + abs_gain = min(max(gain, tx_gain_min), tx_gain_max) + print(f"Gain {gain} out of range for Pluto.") + print(f"Gain range: {tx_gain_min} to {tx_gain_max} dB") + + self.radio.txvga_gain = abs_gain + print(f"HackRF gain = {self.radio.txvga_gain}") + + self._tx_initialized = True + self._rx_initialized = False + + def tx_recording( + self, + recording: Recording | np.ndarray, + num_samples: Optional[int] = None, + tx_time: Optional[int | float] = None, + ): + """ + Transmit the given iq samples from the provided recording. + init_tx() must be called before this function. + + :param recording: The recording to transmit. + :type recording: Recording or np.ndarray + :param num_samples: The number of samples to transmit, will repeat or + truncate the recording to this length. Defaults to None. + :type num_samples: int, optional + :param tx_time: The time to transmit, will repeat or truncate the + recording to this length. Defaults to None. + :type tx_time: int or float, optional + """ + if num_samples is not None and tx_time is not None: + raise ValueError("Only input one of num_samples or tx_time") + elif num_samples is not None: + tx_time = num_samples / self.tx_sample_rate + elif tx_time is not None: + pass + else: + tx_time = len(recording) / self.tx_sample_rate + + if isinstance(recording, np.ndarray): + samples = recording + elif isinstance(recording, Recording): + if len(recording.data) > 1: + warnings.warn("Recording object is multichannel, only channel 0 data was used for transmission") + + samples = recording.data[0] + + samples = samples.astype(np.complex64, copy=False) + + print("HackRF Starting TX...") + self.radio.start_tx(samples=samples, repeat=True) + time.sleep(tx_time) + self.radio.stop_tx() + print("HackRF Tx Completed.") + + def set_clock_source(self, source): + + self.radio.set_clock_source(source) + + def close(self): + self.radio.close() + + def _stream_rx(self, callback): + if not self._rx_initialized: + raise RuntimeError("RX was not initialized. init_rx() must be called before _stream_rx() or record()") + return NotImplementedError("RX not yet implemented for HackRF") + + def _stream_tx(self, callback): + return super()._stream_tx(callback) diff --git a/src/ria_toolkit_oss/sdr/libhackrf.py b/src/ria_toolkit_oss/sdr/libhackrf.py new file mode 100644 index 0000000..6c6f6cd --- /dev/null +++ b/src/ria_toolkit_oss/sdr/libhackrf.py @@ -0,0 +1,715 @@ +# TODO: only use transfer->valid_length in callbacks +# TODO: make error messages more informative + +import logging +import os +import time +from ctypes import * + +import numpy as np + +try: + from itertools import izip +except ImportError: + izip = zip + +path = os.path.dirname(__file__) +logging.basicConfig() +logger = logging.getLogger("HackRf Core") +logger.setLevel(logging.DEBUG) + +# libhackrf = CDLL('/usr/local/lib/libhackrf.so') +libhackrf = CDLL("libhackrf.so.0") + + +def enum(*sequential, **named): + enums = dict(zip(sequential, range(len(sequential))), **named) + return type("Enum", (), enums) + + +HackRfVendorRequest = enum( + HACKRF_VENDOR_REQUEST_SET_TRANSCEIVER_MODE=1, + HACKRF_VENDOR_REQUEST_MAX2837_WRITE=2, + HACKRF_VENDOR_REQUEST_MAX2837_READ=3, + HACKRF_VENDOR_REQUEST_SI5351C_WRITE=4, + HACKRF_VENDOR_REQUEST_SI5351C_READ=5, + HACKRF_VENDOR_REQUEST_SAMPLE_RATE_SET=6, + HACKRF_VENDOR_REQUEST_BASEBAND_FILTER_BANDWIDTH_SET=7, + HACKRF_VENDOR_REQUEST_RFFC5071_WRITE=8, + HACKRF_VENDOR_REQUEST_RFFC5071_READ=9, + HACKRF_VENDOR_REQUEST_SPIFLASH_ERASE=10, + HACKRF_VENDOR_REQUEST_SPIFLASH_WRITE=11, + HACKRF_VENDOR_REQUEST_SPIFLASH_READ=12, + HACKRF_VENDOR_REQUEST_CPLD_WRITE=13, + HACKRF_VENDOR_REQUEST_BOARD_ID_READ=14, + HACKRF_VENDOR_REQUEST_VERSION_STRING_READ=15, + HACKRF_VENDOR_REQUEST_SET_FREQ=16, + HACKRF_VENDOR_REQUEST_AMP_ENABLE=17, + HACKRF_VENDOR_REQUEST_BOARD_PARTID_SERIALNO_READ=18, + HACKRF_VENDOR_REQUEST_SET_LNA_GAIN=19, + HACKRF_VENDOR_REQUEST_SET_VGA_GAIN=20, + HACKRF_VENDOR_REQUEST_SET_TXVGA_GAIN=21, +) + +HackRfConstants = enum( + LIBUSB_ENDPOINT_IN=0x80, + LIBUSB_ENDPOINT_OUT=0x00, + HACKRF_DEVICE_OUT=0x40, + HACKRF_DEVICE_IN=0xC0, + HACKRF_USB_VID=0x1D50, + HACKRF_USB_PID=0x6089, +) + +HackRfError = enum( + HACKRF_SUCCESS=0, + HACKRF_TRUE=1, + HACKRF_ERROR_INVALID_PARAM=-2, + HACKRF_ERROR_NOT_FOUND=-5, + HACKRF_ERROR_BUSY=-6, + HACKRF_ERROR_NO_MEM=-11, + HACKRF_ERROR_LIBUSB=-1000, + HACKRF_ERROR_THREAD=-1001, + HACKRF_ERROR_STREAMING_THREAD_ERR=-1002, + HACKRF_ERROR_STREAMING_STOPPED=-1003, + HACKRF_ERROR_STREAMING_EXIT_CALLED=-1004, + HACKRF_ERROR_OTHER=-9999, + # Python defaults to returning none + HACKRF_ERROR=None, +) + +HackRfTranscieverMode = enum( + HACKRF_TRANSCEIVER_MODE_OFF=0, HACKRF_TRANSCEIVER_MODE_RECEIVE=1, HACKRF_TRANSCEIVER_MODE_TRANSMIT=2 +) + +# Data structures +_libusb_device_handle = c_void_p +_pthread_t = c_ulong + +p_hackrf_device = c_void_p + + +class hackrf_transfer(Structure): + _fields_ = [ + ("device", p_hackrf_device), + ("buffer", POINTER(c_byte)), + ("buffer_length", c_int), + ("valid_length", c_int), + ("rx_ctx", c_void_p), + ("tx_ctx", c_void_p), + ] + + +class read_partid_serialno_t(Structure): + _fields_ = [("part_id", c_uint32 * 2), ("serial_no", c_uint32 * 4)] + + +class hackrf_device_list_t(Structure): + _fields_ = [ + ("serial_numbers", POINTER(c_char_p)), + ("usb_board_ids", c_void_p), + ("usb_device_index", POINTER(c_int)), + ("devicecount", c_int), + ("usb_devices", POINTER(c_void_p)), + ("usb_devicecount", c_int), + ] + + +# +# _callback = CFUNCTYPE(c_int, POINTER(hackrf_transfer)) +_callback = CFUNCTYPE(c_int, POINTER(hackrf_transfer)) + + +# extern ADDAPI int ADDCALL hackrf_init(); +libhackrf.hackrf_init.restype = c_int +libhackrf.hackrf_init.argtypes = [] +# extern ADDAPI int ADDCALL hackrf_exit(); +libhackrf.hackrf_exit.restype = c_int +libhackrf.hackrf_exit.argtypes = [] +# extern ADDAPI int ADDCALL hackrf_open(hackrf_device** device); +libhackrf.hackrf_open.restype = c_int +libhackrf.hackrf_open.argtypes = [POINTER(p_hackrf_device)] +# extern ADDAPI int ADDCALL hackrf_open_by_serial +# (const char* const desired_serial_number, hackrf_device** device); +# TODO: check that this one works +f = libhackrf.hackrf_open_by_serial +f.restype = c_int +f.argtypes = [POINTER(p_hackrf_device)] + +# extern ADDAPI int ADDCALL hackrf_device_list_open +# (hackrf_device_list_t *list, int idx, hackrf_device** device); +f = libhackrf.hackrf_device_list_open +f.restype = c_int +f.arg_types = [POINTER(hackrf_device_list_t), c_int, POINTER(p_hackrf_device)] +# f.arg_types = [hackrf_device_list_t, c_int, POINTER(p_hackrf_device)] + +# extern ADDAPI int ADDCALL hackrf_close(hackrf_device* device); +libhackrf.hackrf_close.restype = c_int +libhackrf.hackrf_close.argtypes = [p_hackrf_device] + + +# extern ADDAPI int ADDCALL hackrf_set_sample_rate(hackrf_device* +# device, const double freq_hz); +libhackrf.hackrf_set_sample_rate.restype = c_int +libhackrf.hackrf_set_sample_rate.argtypes = [p_hackrf_device, c_double] + +# GAIN SETTINGS +# extern ADDAPI int ADDCALL hackrf_set_amp_enable(hackrf_device* +# device, const uint8_t value); +libhackrf.hackrf_set_amp_enable.restype = c_int +libhackrf.hackrf_set_amp_enable.argtypes = [p_hackrf_device, c_uint8] +# extern ADDAPI int ADDCALL hackrf_set_lna_gain(hackrf_device* device, +# uint32_t value); +libhackrf.hackrf_set_lna_gain.restype = c_int +libhackrf.hackrf_set_lna_gain.argtypes = [p_hackrf_device, c_uint32] +# extern ADDAPI int ADDCALL hackrf_set_vga_gain(hackrf_device* device, +# uint32_t value); +libhackrf.hackrf_set_vga_gain.restype = c_int +libhackrf.hackrf_set_vga_gain.argtypes = [p_hackrf_device, c_uint32] + +# START AND STOP RX +# extern ADDAPI int ADDCALL hackrf_start_rx(hackrf_device* device, +# hackrf_sample_block_cb_fn callback, void* rx_ctx); +libhackrf.hackrf_start_rx.restype = c_int +libhackrf.hackrf_start_rx.argtypes = [p_hackrf_device, _callback, c_void_p] +# extern ADDAPI int ADDCALL hackrf_stop_rx(hackrf_device* device); +libhackrf.hackrf_stop_rx.restype = c_int +libhackrf.hackrf_stop_rx.argtypes = [p_hackrf_device] + +# extern ADDAPI hackrf_device_list_t* ADDCALL hackrf_device_list(); +f = libhackrf.hackrf_device_list +f.restype = POINTER(hackrf_device_list_t) +f.argtypes = [] + + +def hackrf_device_list(): + return libhackrf.hackrf_device_list() + + +# dictionary containing all hackrf_devices in use +_hackrf_dict = dict() + + +def get_dict(): + return _hackrf_dict + + +def read_samples_cb(hackrf_transfer): + + # let's access the contents + c = hackrf_transfer.contents + + # c.device is an int representing the pointer to the hackrf device + # we can get the pointer with p_hackrf_device(c.device) + this_hackrf = _hackrf_dict[c.device] + + if len(this_hackrf.buffer) == this_hackrf.num_bytes: + this_hackrf.still_sampling = False + return 0 + + # like == case, but cut down the buffer to size + if len(this_hackrf.buffer) > this_hackrf.num_bytes: + this_hackrf.still_sampling = False + this_hackrf.buffer = this_hackrf.buffer[0 : this_hackrf.num_bytes] + return 0 + + # grab the buffer data and concatenate it + values = cast(c.buffer, POINTER(c_byte * c.buffer_length)).contents + this_hackrf.buffer = this_hackrf.buffer + bytearray(values) + + # print("len(bd) = ",len(this_hackrf.buffer)) + + return 0 + + +rs_callback = _callback(read_samples_cb) + + +## extern ADDAPI int ADDCALL hackrf_start_tx(hackrf_device* device, +## hackrf_sample_block_cb_fn callback, void* tx_ctx); +# libhackrf.hackrf_start_tx.restype = c_int +# libhackrf.hackrf_start_tx.argtypes = [POINTER(hackrf_device), _callback, c_void_p] +## extern ADDAPI int ADDCALL hackrf_stop_tx(hackrf_device* device); +# libhackrf.hackrf_stop_tx.restype = c_int +# libhackrf.hackrf_stop_tx.argtypes = [POINTER(hackrf_device)] +# extern ADDAPI int ADDCALL hackrf_is_streaming(hackrf_device* device); +libhackrf.hackrf_is_streaming.restype = c_int +libhackrf.hackrf_is_streaming.argtypes = [p_hackrf_device] +## extern ADDAPI int ADDCALL hackrf_max2837_read(hackrf_device* device, +## uint8_t register_number, uint16_t* value); +# libhackrf.hackrf_max2837_read.restype = c_int +# libhackrf.hackrf_max2837_read.argtypes = [ +# POINTER(hackrf_device), c_uint8, POINTER(c_uint16)] +## extern ADDAPI int ADDCALL hackrf_max2837_write(hackrf_device* device, +## uint8_t register_number, uint16_t value); +# libhackrf.hackrf_max2837_write.restype = c_int +# libhackrf.hackrf_max2837_write.argtypes = [POINTER(hackrf_device), c_uint8, c_uint16] +## extern ADDAPI int ADDCALL hackrf_si5351c_read(hackrf_device* device, +## uint16_t register_number, uint16_t* value); +# libhackrf.hackrf_si5351c_read.restype = c_int +# libhackrf.hackrf_si5351c_read.argtypes = [ +# POINTER(hackrf_device), c_uint16, POINTER(c_uint16)] +## extern ADDAPI int ADDCALL hackrf_si5351c_write(hackrf_device* device, +## uint16_t register_number, uint16_t value); +# libhackrf.hackrf_si5351c_write.restype = c_int +# libhackrf.hackrf_si5351c_write.argtypes = [POINTER(hackrf_device), c_uint16, c_uint16] +## extern ADDAPI int ADDCALL +## hackrf_set_baseband_filter_bandwidth(hackrf_device* device, const +## uint32_t bandwidth_hz); +# libhackrf.hackrf_set_baseband_filter_bandwidth.restype = c_int +# libhackrf.hackrf_set_baseband_filter_bandwidth.argtypes = [ +# POINTER(hackrf_device), c_uint32] +## extern ADDAPI int ADDCALL hackrf_rffc5071_read(hackrf_device* device, +## uint8_t register_number, uint16_t* value); +# libhackrf.hackrf_rffc5071_read.restype = c_int +# libhackrf.hackrf_rffc5071_read.argtypes = [ +# POINTER(hackrf_device), c_uint8, POINTER(c_uint16)] +## extern ADDAPI int ADDCALL hackrf_rffc5071_write(hackrf_device* +## device, uint8_t register_number, uint16_t value); +# libhackrf.hackrf_rffc5071_write.restype = c_int +# libhackrf.hackrf_rffc5071_write.argtypes = [POINTER(hackrf_device), c_uint8, c_uint16] +## extern ADDAPI int ADDCALL hackrf_spiflash_erase(hackrf_device* +## device); +# libhackrf.hackrf_spiflash_erase.restype = c_int +# libhackrf.hackrf_spiflash_erase.argtypes = [POINTER(hackrf_device)] +## extern ADDAPI int ADDCALL hackrf_spiflash_write(hackrf_device* +## device, const uint32_t address, const uint16_t length, unsigned char* +## const data); +# libhackrf.hackrf_spiflash_write.restype = c_int +# libhackrf.hackrf_spiflash_write.argtypes = [ +# POINTER(hackrf_device), c_uint32, c_uint16, POINTER(c_ubyte)] +## extern ADDAPI int ADDCALL hackrf_spiflash_read(hackrf_device* device, +## const uint32_t address, const uint16_t length, unsigned char* data); +# libhackrf.hackrf_spiflash_read.restype = c_int +# libhackrf.hackrf_spiflash_read.argtypes = [ +# POINTER(hackrf_device), c_uint32, c_uint16, POINTER(c_ubyte)] +## extern ADDAPI int ADDCALL hackrf_cpld_write(hackrf_device* device, +## unsigned char* const data, const unsigned int total_length); +# libhackrf.hackrf_cpld_write.restype = c_int +# libhackrf.hackrf_cpld_write.argtypes = [POINTER(hackrf_device), POINTER(c_ubyte), c_uint] +## extern ADDAPI int ADDCALL hackrf_board_id_read(hackrf_device* device, +## uint8_t* value); +# libhackrf.hackrf_board_id_read.restype = c_int +# libhackrf.hackrf_board_id_read.argtypes = [POINTER(hackrf_device), POINTER(c_uint8)] +## extern ADDAPI int ADDCALL hackrf_version_string_read(hackrf_device* +## device, char* version, uint8_t length); +# libhackrf.hackrf_version_string_read.restype = c_int +# libhackrf.hackrf_version_string_read.argtypes = [POINTER(hackrf_device), POINTER(c_char), c_uint8] +# extern ADDAPI int ADDCALL hackrf_set_freq(hackrf_device* device, +# const uint64_t freq_hz); +libhackrf.hackrf_set_freq.restype = c_int +libhackrf.hackrf_set_freq.argtypes = [p_hackrf_device, c_uint64] +# +## extern ADDAPI int ADDCALL hackrf_set_freq_explicit(hackrf_device* device, +## const uint64_t if_freq_hz, const uint64_t lo_freq_hz, +## const enum rf_path_filter path);, +## libhackrf.hackrf_set_freq_explicit.restype = c_int +## libhackrf.hackrf_set_freq_explicit.argtypes = [c_uint64, +## c_uint64, ] +# +## extern ADDAPI int ADDCALL +## hackrf_set_sample_rate_manual(hackrf_device* device, const uint32_t +## freq_hz, const uint32_t divider); +# libhackrf.hackrf_set_sample_rate_manual.restype = c_int +# libhackrf.hackrf_set_sample_rate_manual.argtypes = [ +# POINTER(hackrf_device), c_uint32, c_uint32] +# +# extern ADDAPI int ADDCALL +# hackrf_board_partid_serialno_read(hackrf_device* device, +# read_partid_serialno_t* read_partid_serialno); +f = libhackrf.hackrf_board_partid_serialno_read +f.restype = c_int +f.argtypes = [p_hackrf_device, POINTER(read_partid_serialno_t)] + +## extern ADDAPI int ADDCALL hackrf_set_txvga_gain(hackrf_device* +## device, uint32_t value); +# libhackrf.hackrf_set_txvga_gain.restype = c_int +# libhackrf.hackrf_set_txvga_gain.argtypes = [POINTER(hackrf_device), c_uint32] +## extern ADDAPI int ADDCALL hackrf_set_antenna_enable(hackrf_device* +## device, const uint8_t value); +# libhackrf.hackrf_set_antenna_enable.restype = c_int +# libhackrf.hackrf_set_antenna_enable.argtypes = [POINTER(hackrf_device), c_uint8] +# +## extern ADDAPI const char* ADDCALL hackrf_error_name(enum hackrf_error errcode); +## libhackrf.hackrf_error_name.restype = POINTER(c_char) +## libhackrf.hackrf_error_name.argtypes = [] +# +## extern ADDAPI const char* ADDCALL hackrf_board_id_name(enum hackrf_board_id board_id); +## libhackrf.hackrf_board_id_name.restype = POINTER(c_char) +## libhackrf.hackrf_board_id_name.argtypes = [] +# +## extern ADDAPI const char* ADDCALL hackrf_filter_path_name(const enum rf_path_filter path); +## libhackrf.hackrf_filter_path_name.restype = POINTER(c_char) +## libhackrf.hackrf_filter_path_name.argtypes = [] +# + +libhackrf.hackrf_si5351c_write.restype = c_int +libhackrf.hackrf_si5351c_write.argtypes = [POINTER(p_hackrf_device), c_uint16, c_uint16] + +PLL_SOURCE_CLKIN = 0x01 +PLL_SOURCE_INTERNAL = 0x00 + +# Callback type for transmit +_tx_callback = CFUNCTYPE(c_int, POINTER(hackrf_transfer)) + +# extern ADDAPI int ADDCALL hackrf_start_tx(hackrf_device* device, hackrf_sample_block_cb_fn callback, void* tx_ctx); +libhackrf.hackrf_start_tx.restype = c_int +libhackrf.hackrf_start_tx.argtypes = [p_hackrf_device, _tx_callback, c_void_p] + +# extern ADDAPI int ADDCALL hackrf_stop_tx(hackrf_device* device); +libhackrf.hackrf_stop_tx.restype = c_int +libhackrf.hackrf_stop_tx.argtypes = [p_hackrf_device] + +# Gain settings for transmit +# extern ADDAPI int ADDCALL hackrf_set_txvga_gain(hackrf_device* device, uint32_t value); +libhackrf.hackrf_set_txvga_gain.restype = c_int +libhackrf.hackrf_set_txvga_gain.argtypes = [p_hackrf_device, c_uint32] + + +# Helper function to convert complex64 samples to bytes +def iq2bytes(samples): + """ + Convert complex64 samples to interleaved int8 bytes for HackRF transmission. + + :param samples: NumPy array of complex64 samples. + :return: Bytes object containing interleaved I/Q samples as int8. + """ + # Normalize samples to the range -1 to +1 + samples = samples / np.max(np.abs(samples)) + + # Scale to the range -127 to +127 + samples_scaled = samples * 127.0 + + # Ensure the values are within the int8 range + samples_scaled = np.clip(samples_scaled, -127, 127) + + # Separate real and imaginary parts and convert to int8 + i_samples = np.real(samples_scaled).astype(np.int8) + q_samples = np.imag(samples_scaled).astype(np.int8) + + # Interleave I and Q samples + interleaved = np.empty(i_samples.size + q_samples.size, dtype=np.int8) + interleaved[0::2] = i_samples + interleaved[1::2] = q_samples + + return interleaved.tobytes() + + +# Helper function to get error names +def get_error_name(error_code): + libhackrf.hackrf_error_name.restype = c_char_p + libhackrf.hackrf_error_name.argtypes = [c_int] + return libhackrf.hackrf_error_name(error_code).decode("utf-8") + + +class HackRF(object): + + _center_freq = 100e6 + _sample_rate = 20e6 + device_opened = False + + def __init__(self, device_index=0): + self.open(device_index) + + # TODO: initialize defaults here + self.disable_amp() + self.set_lna_gain(16) + self.set_vga_gain(16) + self.set_txvga_gain(0) + + self.active_clock_source = None + + self.buffer = bytearray() + self.num_bytes = 16 * 262144 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def open(self, device_index=0): + + # pointer to device structure + self.dev_p = p_hackrf_device(None) + + hdl = hackrf_device_list() + result = libhackrf.hackrf_device_list_open(hdl, device_index, pointer(self.dev_p)) + if result != 0: + raise IOError("Error code %d when opening HackRF" % (result)) + + # This is how I used to do it... + # Note I only pass in the dev_p here, but it worked. + # But above, I have to pass in a pointer(self.dev_p) + # They should both take the same thing + # result = libhackrf.hackrf_open(self.dev_p) + # if result != 0: + # raise IOError('Error code %d when opening HackRF' % (result)) + + # self.dev_p.value returns the integer value of the pointer + + _hackrf_dict[self.dev_p.value] = self + # print("self.dev_p.value = ", self.dev_p.value) + + self.device_opened = True + + def close(self): + if not self.device_opened: + return + + libhackrf.hackrf_close(self.dev_p) + self.device_opened = False + + def __del__(self): + print("del function is being called") + self.close() + + # sleep_time in seconds + # I used to have just pass in the while loop + def read_samples(self, num_samples=131072, sleep_time=0.05): + + num_bytes = 2 * num_samples + self.num_bytes = int(num_bytes) + + self.buffer = bytearray() + + # start receiving + result = libhackrf.hackrf_start_rx(self.dev_p, rs_callback, None) + if result != 0: + raise IOError("Error in hackrf_start_rx") + self.still_sampling = True # this does get called + + while self.still_sampling: + if sleep_time: + time.sleep(sleep_time) + + # stop receiving + result = libhackrf.hackrf_stop_rx(self.dev_p) + if result != 0: + raise IOError("Error in hackrf_stop_rx") + + # convert samples to iq + iq = bytes2iq(self.buffer) + + return iq + + # setting the center frequency + def set_freq(self, freq): + freq = int(freq) + result = libhackrf.hackrf_set_freq(self.dev_p, freq) + if result != 0: + raise IOError("Error code %d when setting frequency to %d Hz" % (result, freq)) + + self._center_freq = freq + return + + def get_freq(self): + return self._center_freq + + center_freq = property(get_freq, set_freq) + + # sample rate + def set_sample_rate(self, rate): + result = libhackrf.hackrf_set_sample_rate(self.dev_p, rate) + if result != 0: + # TODO: make this error message better + raise IOError("Sample rate set failure") + self._sample_rate = rate + return + + def get_sample_rate(self): + return self._sample_rate + + sample_rate = property(get_sample_rate, set_sample_rate) + + def get_serial_no(self): + return get_serial_no(self.dev_p) + + def enable_amp(self): + result = libhackrf.hackrf_set_amp_enable(self.dev_p, 1) + if result != 0: + # TODO: make this a better message + raise IOError("error enabling amp") + return 0 + + def disable_amp(self): + result = libhackrf.hackrf_set_amp_enable(self.dev_p, 0) + if result != 0: + # TODO: make this a better message + raise IOError("error disabling amp") + return 0 + + # rounds down to multiple of 8 (15 -> 8, 39 -> 32), etc. + # internally, hackrf_set_lna_gain does the same thing + # But we take care of it so we can keep track of the correct gain + def set_lna_gain(self, gain): + gain -= gain % 8 # round DOWN to multiple of 8 + result = libhackrf.hackrf_set_lna_gain(self.dev_p, gain) + if result != 0: + # TODO: make this a better message + raise IOError("error setting lna gain") + self._lna_gain = gain + print("LNA gain set to", gain, "dB.") + return 0 + + def get_lna_gain(self): + return self._lna_gain + + lna_gain = property(get_lna_gain, set_lna_gain) + + def set_vga_gain(self, gain): + gain -= gain % 2 + result = libhackrf.hackrf_set_vga_gain(self.dev_p, gain) + if result != 0: + # TODO: make this a better message + raise IOError("error setting vga gain") + self._vga_gain = gain + print("VGA gain set to", gain, "dB.") + return 0 + + def get_vga_gain(self): + return self._vga_gain + + vga_gain = property(get_vga_gain, set_vga_gain) + + # rx_cb_fn is a callback function (in python) + def start_rx(self, rx_cb_fn): + rx_cb = _callback(rx_cb_fn) + result = libhackrf.hackrf_start_rx(self.dev_p, rx_cb, None) + if result != 0: + raise IOError("start_rx failure") + + def stop_rx(self): + result = libhackrf.hackrf_stop_rx(self.dev_p) + if result != 0: + raise IOError("stop_rx failure") + + # Add transmit gain property + def set_txvga_gain(self, gain): + if gain < 0 or gain > 47: + raise ValueError("TXVGA gain must be between 0 and 47 dB") + result = libhackrf.hackrf_set_txvga_gain(self.dev_p, gain) + if result != 0: + error_name = get_error_name(result) + raise IOError(f"Error setting TXVGA gain: {error_name} (Code {result})") + self._txvga_gain = gain + print(f"TXVGA gain set to {gain} dB.") + return 0 + + def get_txvga_gain(self): + return self._txvga_gain + + txvga_gain = property(get_txvga_gain, set_txvga_gain) + + # Method to start transmission + def start_tx(self, samples, repeat=False): + """ + Start transmitting samples. + + :param samples: A numpy array of complex64 samples to transmit. + :param repeat: If True, the samples will be transmitted in a loop. + """ + if not isinstance(samples, np.ndarray) or samples.dtype != np.complex64: + raise ValueError("Samples must be a numpy array of complex64") + + # Scale samples to int8 range + if np.max(samples) > 1: + raise ValueError + samples_scaled = samples * 127.0 + samples_scaled = np.clip(samples_scaled, -127, 127) # Ensure values are within int8 range + samples_scaled = samples_scaled.astype(np.complex64) # Ensure correct data type + + self.tx_buffer = samples + self.tx_repeat = repeat + self.tx_index = 0 # Index to keep track of where we are in the buffer + + # Convert the callback function to the required C callback + self._tx_cb = _tx_callback(self._tx_callback) + result = libhackrf.hackrf_start_tx(self.dev_p, self._tx_cb, None) + if result != 0: + error_name = get_error_name(result) + raise IOError(f"Error starting transmission: {error_name} (Code {result})") + + # Method to stop transmission + def stop_tx(self): + result = libhackrf.hackrf_stop_tx(self.dev_p) + if result != 0: + error_name = get_error_name(result) + raise IOError(f"Error stopping transmission: {error_name} (Code {result})") + + # The transmit callback function + def _tx_callback(self, hackrf_transfer): + c = hackrf_transfer.contents + + # Determine how many bytes we need to send + bytes_to_send = c.valid_length + + # Prepare the data to send + end_index = self.tx_index + bytes_to_send // 2 # Each sample is 2 bytes (I and Q) + + if end_index > len(self.tx_buffer): + if self.tx_repeat: + # Loop back to the start + end_index = end_index % len(self.tx_buffer) + data = np.concatenate((self.tx_buffer[self.tx_index :], self.tx_buffer[:end_index])) + self.tx_index = end_index + else: + # Fill the remaining buffer with zeros + data = self.tx_buffer[self.tx_index :] + padding = np.zeros(end_index - len(self.tx_buffer), dtype=np.complex64) + data = np.concatenate((data, padding)) + self.tx_index = len(self.tx_buffer) + else: + data = self.tx_buffer[self.tx_index : end_index] + self.tx_index = end_index + + # Convert complex64 samples to bytes + iq_bytes = iq2bytes(data) + + # Copy data to the buffer + memmove(c.buffer, iq_bytes, len(iq_bytes)) + + # If we've reached the end of the buffer and not repeating, stop transmission + if self.tx_index >= len(self.tx_buffer) and not self.tx_repeat: + return -1 # Returning -1 stops the transmission + + return 0 # Continue transmission + + def set_clock_source(self, source_str): + pass + + +# returns serial number as a string +# it is too big to be a single number, so make it a string +# the returned string matches the hackrf_info output +def get_serial_no(dev_p): + sn = read_partid_serialno_t() + result = libhackrf.hackrf_board_partid_serialno_read(dev_p, sn) + if result != 0: + raise IOError("Error %d while getting serial number" % (result)) + + # convert the serial number to a string + sn_str = "" + for i in range(0, 4): + sni = sn.serial_no[i] + if sni == 0: + sn_str += "00000000" + else: + sn_str += hex(sni)[2:-1] + + return sn_str + + +# converts byte array to iq values +def bytes2iq(data): + values = np.array(data).astype(np.int8) + iq = values.astype(np.float64).view(np.complex128) + iq /= 127.5 + iq -= 1 + 1j + + return iq + + +# really, user shouldn't have to call this function at all +result = libhackrf.hackrf_init() +if result != 0: + print("error initializing the hackrf library") diff --git a/src/ria_toolkit_oss/sdr/pluto.py b/src/ria_toolkit_oss/sdr/pluto.py new file mode 100644 index 0000000..2735689 --- /dev/null +++ b/src/ria_toolkit_oss/sdr/pluto.py @@ -0,0 +1,502 @@ +import threading +import time +import traceback +import warnings +from typing import Optional + +import adi +import numpy as np + +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.sdr.sdr import SDR + + +class Pluto(SDR): + + def __init__(self, identifier=None): + """ + Initialize a Pluto SDR device object and connect to the SDR hardware. + + This software supports the ADALAM Pluto SDR created by Analog Devices. + + :param identifier: The value of the parameter that identifies the device. + :type identifier: str = "192.168.3.1", "pluto.local", etc + + If no identifier is provided, it will select the first device found, with a warning. + If more than one device is found with the identifier, it will select the first of those devices. + """ + print(f"Initializing Pluto radio with identifier [{identifier}].") + try: + super().__init__() + + if identifier is None: + uri = "ip:pluto.local" + else: + uri = f"ip:{identifier}" + + self.radio = adi.ad9361(uri) + print(f"Successfully found Pluto radio with identifier [{identifier}].") + except Exception as e: + print(f"Failed to find Pluto radio with identifier [{identifier}].") + raise e + + def init_rx( + self, + sample_rate: int | float, + center_frequency: int | float, + gain: int, + channel: int, + gain_mode: Optional[str] = "absolute", + ): + """ + Initializes the Pluto for receiving. + + :param sample_rate: The sample rate for receiving. + :type sample_rate: int or float + :param center_frequency: The center frequency of the recording. + :type center_frequency: int or float + :param gain: The gain set for receiving on the Pluto + :type gain: int + :param channel: The channel the Pluto is set to. Must be 0 or 1. 0 enables channel 1, 1 enables both channels. + :type channel: int + :param buffer_size: The buffer size during receive. Defaults to 10000. + :type buffer_size: int + """ + print("Initializing RX") + + self.set_rx_sample_rate(sample_rate=int(sample_rate)) + print(f"Pluto sample rate = {self.radio.sample_rate}") + + self.set_rx_center_frequency(center_frequency=int(center_frequency)) + print(f"Pluto center frequency = {self.radio.rx_lo}") + + if channel == 0: + self.radio.rx_enabled_channels = [0] + print(f"Pluto channel(s) = {self.radio.rx_enabled_channels}") + elif channel == 1: + self.radio.rx_enabled_channels = [0, 1] + print(f"Pluto channel(s) = {self.radio.rx_enabled_channels}") + else: + raise ValueError("Channel must be either 0 or 1.") + + rx_gain_min = 0 + rx_gain_max = 74 + + if gain_mode == "relative": + if gain > 0: + raise ValueError( + "When gain_mode = 'relative', gain must be < 0. This sets \ + the gain relative to the maximum possible gain." + ) + else: + abs_gain = rx_gain_max + gain + else: + abs_gain = gain + + if abs_gain < rx_gain_min or abs_gain > rx_gain_max: + abs_gain = min(max(gain, rx_gain_min), rx_gain_max) + print(f"Gain {gain} out of range for Pluto.") + print(f"Gain range: {rx_gain_min} to {rx_gain_max} dB") + + self.set_rx_gain(gain=abs_gain, channel=channel) + if channel == 0: + print(f"Pluto gain = {self.radio.rx_hardwaregain_chan0}") + elif channel == 1: + self.set_tx_gain(gain=abs_gain, channel=0) + print(f"Pluto gain = {self.radio.rx_hardwaregain_chan0}, {self.radio.rx_hardwaregain_chan1}") + + self.radio.rx_buffer_size = 1024 # TODO deal with this for zmq + self._rx_initialized = True + self._tx_initialized = False + + def init_tx( + self, + sample_rate: int | float, + center_frequency: int | float, + gain: int, + channel: int, + gain_mode: Optional[str] = "absolute", + ): + """ + Initializes the Pluto for transmitting. Will transmit garbage during + center frequency tuning and setting the sample rate. + + :param sample_rate: The sample rate for transmitting. + :type sample_rate: int or float + :param center_frequency: The center frequency of the recording. + :type center_frequency: int or float + :param gain: The gain set for transmitting on the Pluto + :type gain: int + :param channel: The channel the Pluto is set to. Must be 0 or 1. 0 enables channel 1, 1 enables both channels. + :type channel: int + :param buffer_size: The buffer size during transmit. Defaults to 10000. + :type buffer_size: int + """ + + print("Initializing TX") + + self.set_tx_sample_rate(sample_rate=int(sample_rate)) + print(f"Pluto sample rate = {self.radio.sample_rate}") + + self.set_tx_center_frequency(center_frequency=int(center_frequency)) + print(f"Pluto center frequency = {self.radio.tx_lo}") + + if channel == 1: + self.radio.tx_enabled_channels = [0, 1] + print(f"Pluto channel(s) = {self.radio.tx_enabled_channels}") + elif channel == 0: + self.radio.tx_enabled_channels = [0] + print(f"Pluto channel(s) = {self.radio.tx_enabled_channels}") + else: + raise ValueError("Channel must be either 0 or 1.") + + tx_gain_min = -89 + tx_gain_max = 0 + + if gain_mode == "relative": + if gain > 0: + raise ValueError( + "When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain." + ) + else: + abs_gain = tx_gain_max + gain + else: + abs_gain = gain + + if abs_gain < tx_gain_min or abs_gain > tx_gain_max: + abs_gain = min(max(gain, tx_gain_min), tx_gain_max) + print(f"Gain {gain} out of range for Pluto.") + print(f"Gain range: {tx_gain_min} to {tx_gain_max} dB") + + self.set_tx_gain(gain=abs_gain, channel=channel) + if channel == 0: + print(f"Pluto gain = {self.radio.tx_hardwaregain_chan0}") + elif channel == 1: + self.set_tx_gain(gain=abs_gain, channel=0) + print(f"Pluto gain = {self.radio.tx_hardwaregain_chan0}, {self.radio.tx_hardwaregain_chan1}") + + self._tx_initialized = True + self._rx_initialized = False + + def _stream_rx(self, callback): + if not self._rx_initialized: + raise RuntimeError("RX was not initialized. init_rx() must be called before _stream_rx() or record()") + + # print("Starting rx...") + + self._enable_rx = True + while self._enable_rx is True: + signal = self.radio.rx() + signal = self._convert_rx_samples(signal) + # send callback complex signal + callback(buffer=signal, metadata=None) + + def record(self, num_samples: Optional[int] = None, rx_time: Optional[int | float] = None): + """ + Create a radio recording (iq samples and metadata) of a given length from the SDR. + Either num_samples or rx_time must be provided. + init_rx() must be called before record() + + :param num_samples: The number of samples to record. Pluto max = 16M. + :type num_samples: int, optional + :param rx_time: The time to record. + :type rx_time: int or float, optional + + returns: Recording object (iq samples and metadata) + """ + if not self._rx_initialized: + raise RuntimeError("RX was not initialized. init_rx() must be called before _stream_rx() or record()") + + if num_samples is not None and rx_time is not None: + raise ValueError("Only input one of num_samples or rx_time") + elif num_samples is not None: + self._num_samples_to_record = num_samples + elif rx_time is not None: + self._num_samples_to_record = int(rx_time * self.rx_sample_rate) + else: + raise ValueError("Must provide input of one of num_samples or rx_time") + + if self._num_samples_to_record > 16000000: + raise NotImplementedError("Pluto record for num_samples>16M not implemented yet.") + self.radio.rx_buffer_size = self._num_samples_to_record + + print("Pluto Starting RX...") + samples = self.radio.rx() + if self.radio.tx_enabled_channels == [0]: + samples = self._convert_rx_samples(samples) + samples = [samples] + else: + channel1 = self._convert_rx_samples(samples[0]) + channel2 = self._convert_rx_samples(samples[1]) + samples = [channel1, channel2] + print("Pluto RX Completed.") + + metadata = { + "source": self.__class__.__name__, + "sample_rate": self.rx_sample_rate, + "center_frequency": self.rx_center_frequency, + "gain": self.rx_gain, + } + + recording = Recording(data=samples, metadata=metadata) + return recording + + def _format_tx_data(self, recording: Recording | np.ndarray | list): + if isinstance(recording, np.ndarray): + data = [self._convert_tx_samples(samples=recording)] + elif isinstance(recording, Recording): + if self.radio.tx_enabled_channels == [0]: + samples = recording.data[0] + data = [self._convert_tx_samples(samples=samples)] + + if len(recording.data) > 1: + warnings.warn("Recording object is multichannel, only channel 0 data was used for transmission") + + else: + if len(recording.data) == 1: + warnings.warn( + "Recording has only 1 channel, the same data will be transmitted over both Pluto channels" + ) + samples = recording.data[0] + data = [self._convert_tx_samples(samples), self._convert_tx_samples(samples)] + else: + if len(recording) > 2: + warnings.warn( + "More recordings were provided than channels in the Pluto. \ + Only the first two recordings will be used" + ) + sample0 = self._convert_tx_samples(recording.data[0]) + sample1 = self._convert_tx_samples(recording.data[1]) + data = [sample0, sample1] + + elif isinstance(recording, list): + if len(recording) > 2: + warnings.warn( + "More recordings were provided than channels in the Pluto. \ + Only the first two recordings will be used" + ) + + if isinstance(recording[0], np.ndarray): + data = [self._convert_tx_samples(recording[0]), self._convert_tx_samples(recording[1])] + elif isinstance(recording[0], Recording): + sample0 = self._convert_tx_samples(recording[0].data[0]) + sample1 = self._convert_tx_samples(recording[1].data[0]) + data = [sample0, sample1] + + return data + + def _timeout_cyclic_buffer(self, timeout): + time.sleep(timeout) + self.radio.tx_destroy_buffer() + self.radio.tx_cyclic_buffer = False + print("Pluto TX Completed.") + + def interrupt_transmit(self): + self.radio.tx_destroy_buffer() + self.radio.tx_cyclic_buffer = False + print("Pluto TX Completed.") + + def close(self): + if self.radio.tx_cyclic_buffer: + self.radio.tx_destroy_buffer() + del self.radio + + def tx_recording(self, recording: Recording | np.ndarray | list, num_samples=None, tx_time=None, mode="timed"): + """ + Transmit the given iq samples from the provided recording. + init_tx() must be called before this function. + + :param recording: The recording(s) to transmit. + :type recording: Recording, np.ndarray, list[Recording, np.ndarray] + :param num_samples: The number of samples to transmit, will repeat or + truncate the recording to this length. Defaults to None. + :type num_samples: int, optional + :param tx_time: The time to transmit, will repeat or truncate the + recording to this length. Defaults to None. + :type tx_time: int or float, optional + :param mode: The mode of transmission, either timed or continuous. Defaults to timed. + :type mode: str, optional + """ + if num_samples is not None and tx_time is not None: + raise ValueError("Only input one of num_samples or tx_time") + elif num_samples is not None: + tx_time = num_samples / self.tx_sample_rate + elif tx_time is not None: + pass + else: + tx_time = len(recording) / self.tx_sample_rate + + data = self._format_tx_data(recording=recording) + + try: + if self.radio.tx_cyclic_buffer: + print("Destroying existing TX buffer...") + self.radio.tx_destroy_buffer() + self.radio.tx_cyclic_buffer = False + except Exception as e: + print(f"Error while destroying TX buffer: {e}") + + self.radio.tx_cyclic_buffer = True + print("Pluto Starting TX...") + self.radio.tx(data_np=data) + if mode == "timed": + timeout_thread = threading.Thread(target=self._timeout_cyclic_buffer, args=([tx_time])) + timeout_thread.start() + timeout_thread.join() + + def _stream_tx(self, callback): + if self._tx_initialized is False: + raise RuntimeError("TX was not initialized, init_tx must be called before _stream_tx") + + num_samples = 10000 + # TODO remove hardcode + + self._enable_tx = True + while self._enable_tx is True: + buffer = self._convert_tx_samples(callback(num_samples)) + self.radio.tx(buffer[0]) + + def set_rx_center_frequency(self, center_frequency): + try: + self.radio.rx_lo = int(center_frequency) + self.rx_center_frequency = center_frequency + except OSError as e: + _handle_OSError(e) + except ValueError as e: + _handle_OSError(e) + + def set_rx_sample_rate(self, sample_rate): + self.rx_sample_rate = sample_rate + + # TODO add logic for limiting sample rate + + try: + self.radio.sample_rate = int(sample_rate) + + # set the front end filter width + self.radio.rx_rf_bandwidth = int(sample_rate) + except OSError as e: + _handle_OSError(e) + except ValueError as e: + _handle_OSError(e) + + def set_rx_gain(self, gain, channel=0): + self.rx_gain = gain + try: + if channel == 0: + + if gain is None: + self.radio.gain_control_mode_chan0 = "automatic" + print("Using Pluto Automatic Gain Control.") + + else: + self.radio.gain_control_mode_chan0 = "manual" + self.radio.rx_hardwaregain_chan0 = gain # dB + + elif channel == 1: + try: + if gain is None: + self.radio.gain_control_mode_chan1 = "automatic" + print("Using Pluto Automatic Gain Control.") + + else: + self.radio.gain_control_mode_chan1 = "manual" + self.radio.rx_hardwaregain_chan1 = gain # dB + + except Exception as e: + print("Failed to use channel 1 on the PlutoSDR. \nThis is only available for revC versions.") + raise e + + else: + raise ValueError(f"Pluto channel must be 0 or 1 but was {channel}.") + + except OSError as e: + _handle_OSError(e) + except ValueError as e: + _handle_OSError(e) + + def set_rx_channel(self, channel): + self.rx_channel = channel + + def set_rx_buffer_size(self, buffer_size): + raise NotImplementedError + + def set_tx_center_frequency(self, center_frequency): + try: + self.radio.tx_lo = int(center_frequency) + self.tx_center_frequency = center_frequency + + except OSError as e: + _handle_OSError(e) + except ValueError as e: + _handle_OSError(e) + + def set_tx_sample_rate(self, sample_rate): + try: + self.radio.sample_rate = sample_rate + self.tx_sample_rate = sample_rate + + except OSError as e: + _handle_OSError(e) + except ValueError as e: + _handle_OSError(e) + + def set_tx_gain(self, gain, channel=0): + try: + self.tx_gain = gain + + if channel == 0: + self.radio.tx_hardwaregain_chan0 = int(gain) + elif channel == 1: + self.radio.tx_hardwaregain_chan1 = int(gain) + else: + raise ValueError(f"Pluto channel must be 0 or 1 but was {channel}.") + + except OSError as e: + _handle_OSError(e) + except ValueError as e: + _handle_OSError(e) + + def set_tx_channel(self, channel): + raise NotImplementedError + + def set_tx_buffer_size(self, buffer_size): + raise NotImplementedError + + def shutdown(self): + del self.radio + + def _convert_rx_samples(self, samples): + return samples / (2**11) + + def _convert_tx_samples(self, samples): + return samples.astype(np.complex64) * (2**14) + + def set_clock_source(self, source): + raise NotImplementedError + + +def _handle_OSError(e): + + # process a common difficult to read error message into a more intuitive format + + print("PlutoSDR valid arguments:") + + print("Standard: ") + print("\tCenter frequency: 325-3800Mhz") + print("\tSample rate: 521kHz-20Mhz") + print("\tGain: -90-0") + print("Hacked:") + print("\tCenter frequency: 70-6000Mhz") + print("\tSample rate: 521kHz-56Mhz") + print("\tGain: -90-0") + + stack_trace = traceback.format_exc() + print(stack_trace) + if "sampling_frequency" in stack_trace or "sample rates" in stack_trace: + raise ValueError("The sample rate was out of range for the Pluto SDR.\n") + if "tx_lo" in stack_trace or "rx_lo" in stack_trace: + raise ValueError("The center frequency was out of range for the Pluto SDR.\n") + if "hardwaregain" in stack_trace: + raise ValueError("The gain was out of range for the Pluto SDR.\n") diff --git a/src/ria_toolkit_oss/sdr/sdr.py b/src/ria_toolkit_oss/sdr/sdr.py new file mode 100644 index 0000000..a0fb392 --- /dev/null +++ b/src/ria_toolkit_oss/sdr/sdr.py @@ -0,0 +1,369 @@ +import math +import pickle +import warnings +from abc import ABC, abstractmethod +from typing import Optional + +import numpy as np +import zmq + +from ria_toolkit_oss.datatypes.recording import Recording + + +class SDR(ABC): + """ + SDR object to interface with radio hardware. + """ + + def __init__(self): + + self._rx_initialized = False + self._tx_initialized = False + self._enable_rx = False + self._enable_tx = False + self._accumulated_buffer = None + self._max_num_buffers = None + self._num_buffers_processed = 0 + self._accumulated_buffer = None + self._last_buffer = None + + def record(self, num_samples: Optional[int] = None, rx_time: Optional[int | float] = None) -> Recording: + """ + Create a radio recording of a given length. Either ``num_samples`` or ``rx_time`` must be provided. + + Note that ``init_rx()`` must be called before ``record()``. + + :param num_samples: The number of samples to record. + :type num_samples: int, optional + :param rx_time: The time to record. + :type rx_time: int or float, optional + + :return: The Recording object + :rtype: Recording + """ + + if not self._rx_initialized: + raise RuntimeError("RX was not initialized. init_rx() must be called before _stream_rx() or record()") + + if num_samples is not None and rx_time is not None: + raise ValueError("Only input one of num_samples or rx_time") + elif num_samples is not None: + self._num_samples_to_record = num_samples + elif rx_time is not None: + self._num_samples_to_record = int(rx_time * self.rx_sample_rate) + else: + raise ValueError("Must provide input of one of num_samples or rx_time") + + self.buffer_size = self.rx_buffer_size + num_buffers = self._num_samples_to_record // self.buffer_size + 1 + + self._max_num_buffers = num_buffers + self._num_buffers_processed = 0 + self._num_buffers_processed = 0 + self._last_buffer = None + self._accumulated_buffer = None + print("Starting stream") + + self._stream_rx( + callback=self._accumulate_buffers_callback, + ) + + print("Finished stream") + metadata = { + "source": self.__class__.__name__, + "sample_rate": self.rx_sample_rate, + "center_frequency": self.rx_center_frequency, + "gain": self.rx_gain, + } + + print("Creating recording") + # build recording, truncate to self._num_samples_to_record + recording = Recording(data=self._accumulated_buffer[:, : self._num_samples_to_record], metadata=metadata) + + # reset to record again + self._accumulated_buffer = None + return recording + + def stream_to_zmq(self, zmq_address, n_samples: int, buffer_size: Optional[int] = 10000): + """ + Stream iq samples as interleaved bytes via zmq. + + :param zmq_address: The zmq address. + :type zmq_address: + :param n_samples: The number of samples to stream. + :type n_samples: int + :param buffer_size: The buffer size during streaming. Defaults to 10000. + :type buffer_size: int, optional + + :return: The trimmed Recording. + :rtype: Recording + """ + + self._previous_buffer = None + self._max_num_buffers = np.inf if n_samples == np.inf else math.ceil(n_samples / buffer_size) + self._num_buffers_processed = 0 + self.zmq_address = _generate_full_zmq_address(str(zmq_address)) + self.context = zmq.Context() + self.socket = self.context.socket(zmq.PUB) + self.socket.bind(self.zmq_address) + + self._stream_rx( + self._zmq_bytestream_callback, + ) + + self.context.destroy() + self.socket.close() + + def _accumulate_buffers_callback(self, buffer, metadata=None): + """ + Receives a buffer and saves it to self.accumulated_buffer. + """ + # expected buffer is complex samples range -1 to 1 + # save the buffer until max reached + # return a recording + + buffer = np.array(buffer) # make it 1d + if len(buffer.shape) == 1: + buffer = np.array([buffer]) + + # it runs these checks each time, is that an efficiency issue? + + if self._max_num_buffers is None: + # default then + # this should probably print, but that would happen every buffer... + raise ValueError("Number of buffers for block capture not set.") + + # add the given buffer to the pre-allocated buffer + + if metadata is not None: + self.received_metadata = metadata + + # TODO optimize, pre-allocate + if self._accumulated_buffer is not None: + self._accumulated_buffer = np.concatenate((self._accumulated_buffer, buffer), axis=1) + else: + # the first time + self._accumulated_buffer = buffer.copy() + + self._num_buffers_processed = self._num_buffers_processed + 1 + if self._num_buffers_processed >= self._max_num_buffers: + self.stop() + + if self._last_buffer is not None: + if (buffer == self._last_buffer).all(): + print("\033[93mWarning: Buffer Overflow Detected\033[0m") + self._last_buffer = buffer.copy() + else: + self._last_buffer = buffer.copy() + + # print("Number of buffers received: " + str(self._num_buffers_processed)) + + def _zmq_bytestream_callback(self, buffer, metadata=None): + # push to ZMQ port + data = np.array(buffer).tobytes() # convert to bytes for transport + self.socket.send(data) + + # print(f"Sent {self._num_buffers_processed} ZMQ buffers to {self.zmq_address}") + + self._num_buffers_processed = self._num_buffers_processed + 1 + if self._max_num_buffers is not None: + if self._num_buffers_processed >= self._max_num_buffers: + self.pause_rx() + + if self._previous_buffer is not None: + if (buffer == self._previous_buffer).all(): + print("\033[93mWarning: Buffer Overflow Detected\033[0m") + # TODO: I suggest we think about moving this part to the top of this function + # and skip the rest of the function in case of overflow. + # like, it's not necessary to stream repeated IQ data anyways! + self._previous_buffer = buffer.copy() + + def pickle_buffer_to_zmq(self, zmq_address, buffer_size, num_buffers): + """ + Stream samples to a zmq address, packaged in binary buffers using numpy.pickle. + Useful for inference applications with a known input size. + May reduce transfer rates, but individual buffers will not have discontinuities. + + :param zmq_address: The tcp address to stream to. + :type zmq_address: str + :param buffer_size: The number of iq samples in a buffer. + :type buffer_size: int + :param num_buffers: The number of buffers to stream before stopping. + :type num_buffers: int + """ + self._max_num_buffers = num_buffers + self.buffer_size = buffer_size + self._num_buffers_processed = 0 + self.zmq_address = _generate_full_zmq_address(str(zmq_address)) + self.context = zmq.Context() + self.socket = self.context.socket(zmq.PUB) + self.socket.bind(self.zmq_address) + self.set_rx_buffer_size(buffer_size) + + self._stream_rx(self._zmq_pickle_buffer_callback) + + def _zmq_pickle_buffer_callback(self, buffer, metadata=None): + # push to ZMQ port + # data = np.array(buffer).tobytes() # convert to bytes for transport + # self.socket.send(data) + + self.socket.send(pickle.dumps(buffer)) + + # print(f"Sent {self._num_buffers_processed} ZMQ buffers to {self.zmq_address}") + + self._num_buffers_processed = self._num_buffers_processed + 1 + if self._max_num_buffers is not None: + if self._num_buffers_processed >= self._max_num_buffers: + self.stop() + + if self._last_buffer is not None: + if (buffer == self._last_buffer).all(): + print("\033[93mWarning: Buffer Overflow Detected\033[0m") + self._last_buffer = buffer.copy() + else: + self._last_buffer = buffer.copy() + + def tx_recording( + self, + recording: Recording | np.ndarray, + num_samples: Optional[int] = None, + tx_time: Optional[int | float] = None, + ): + """ + Transmit the given iq samples from the provided recording. + init_tx() must be called before this function. + + :param recording: The recording to transmit. + :type recording: Recording or np.ndarray + :param num_samples: The number of samples to transmit, will repeat or + truncate the recording to this length. Defaults to None. + :type num_samples: int, optional + :param tx_time: The time to transmit, will repeat or truncate the + recording to this length. Defaults to None. + :type tx_time: int or float, optional + """ + + if not self._tx_initialized: + raise RuntimeError( + "TX was not initialized. init_tx() must be called before _stream_tx() or transmit_recording()" + ) + + if num_samples is not None and tx_time is not None: + raise ValueError("Only input one of num_samples or tx_time") + elif num_samples is not None: + self._num_samples_to_transmit = num_samples + elif tx_time is not None: + self._num_samples_to_transmit = tx_time * self.tx_sample_rate + else: + self._num_samples_to_transmit = len(recording) + + if isinstance(recording, np.ndarray): + self._samples_to_transmit = recording + elif isinstance(recording, Recording): + if len(recording.data) > 1: + warnings.warn("Recording object is multichannel, only channel 0 data was used for transmission") + + self._samples_to_transmit = recording.data[0] + + self._num_samples_transmitted = 0 + + self._stream_tx(self._loop_recording_callback) + + def _loop_recording_callback(self, num_samples): + + samples_left = self._num_samples_to_transmit - self._num_samples_transmitted + # find where to start based on num_samples_transmitted + start_index = self._num_samples_transmitted % len(self._samples_to_transmit) + + # generates an array of indices that wrap around as many times as necessary. + indices = np.arange(start_index, start_index + num_samples) % len(self._samples_to_transmit) + samples = self._samples_to_transmit[indices] + + # zero pad at the end so we are still giving the requested buffer size + # while also giving the exact number of non zero samples + if len(samples) > samples_left: + samples[int(samples_left) :] = 0 + self.pause_tx() + + self._num_samples_transmitted = self._num_samples_transmitted + num_samples + + return samples + + def pause_rx(self): + self._enable_rx = False + + def pause_tx(self): + self._enable_tx = False + + def stop(self): + self.pause_rx() + + @abstractmethod + def close(self): + pass + + @abstractmethod + def init_rx(self, sample_rate, center_frequency, gain, channel, gain_mode): + pass + + @abstractmethod + def init_tx(self, sample_rate, center_frequency, gain, channel, gain_mode): + pass + + @abstractmethod + def _stream_rx(self, callback): + pass + + @abstractmethod + def _stream_tx(self, callback): + pass + + @abstractmethod + def set_clock_source(self, source): + """ + Sets the clock source to external or internal. + + :param source: The clock source + :type source: str + """ + pass + + +def _generate_full_zmq_address(input_address): + """ + Helper function for zmq streaming. + If given a port number like 5556, + return tcp localhost address at that port. + Otherwise, return the address untouched. + """ + + if ("://" not in str(input_address)) and _is_valid_port(input_address): + # If no transport protocol specified, assume TCP + return "tcp://*:" + str(input_address) + else: + # Otherwise, return the input unchanged + return input_address + + +def _is_valid_port(port): + """ + Helper function for zmq address. + """ + try: + port_num = int(port) + return 0 <= port_num <= 65535 + except ValueError: + return False + + +def _verify_sample_format(samples): + """ + Verify that the sample data is in the range -1 to 1. + + :param buffer: An array of samples. + + :Return: True if the buffer is in the correct format, false if not. + :rtype: bool + """ + + return np.max(np.abs(samples)) <= 1 diff --git a/src/ria_toolkit_oss/sdr/usrp.py b/src/ria_toolkit_oss/sdr/usrp.py new file mode 100644 index 0000000..29fa94a --- /dev/null +++ b/src/ria_toolkit_oss/sdr/usrp.py @@ -0,0 +1,552 @@ +import subprocess +import time +import warnings +from typing import Optional + +import numpy as np +import uhd + +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.sdr.sdr import SDR + + +class USRP(SDR): + def __init__(self, identifier: str = None): + """ + Initialize a USRP device object and connect to the SDR hardware. + + This software supports all USRP SDRs created by Ettus Research. + + :param identifier: Identifier of the device. Can be an IP address (e.g. "192.168.0.0"), + a device name (e.g. "MyB210"), or any name/address found via ``uhd_find_devices``. + If not provided, the first available device is selected with a warning. + If multiple devices match the identifier, the first one is selected. + :type identifier: str, optional + """ + super().__init__() + + self.default_buffer_size = 8000 + + # get all the info from only one of the parameters + self.device_dict = _create_device_dict(identifier) + + self._rx_initialized = False + self._tx_initialized = False + + def init_rx( + self, + sample_rate: int | float, + center_frequency: int | float, + channel: int, + gain: int, + gain_mode: Optional[str] = "absolute", + rx_buffer_size: int = 960000, + ): + """ + Initialize the USRP for receiving. + + :param sample_rate: The sample rate for receiving. + :type sample_rate: int or float + + :param center_frequency: The center frequency of the recording. + :type center_frequency: int or float + + :param channel: The channel the USRP is set to. + :type channel: int + + :param gain: The gain set for receiving on the USRP. + :type gain: int + + :param gain_mode: Gain mode setting. ``"absolute"`` passes gain directly to the SDR. + ``"relative"`` means gain should be a negative value, which will be subtracted + from the maximum gain. + :type gain_mode: str + + :param rx_buffer_size: Internal buffer size for receiving samples. Defaults to 960000. + :type rx_buffer_size: int + + :return: Dictionary with the actual RX parameters after configuration. + :rtype: dict + """ + + self.rx_buffer_size = rx_buffer_size + + # build USRP object + usrp_args = _generate_usrp_config_string(sample_rate=sample_rate, device_dict=self.device_dict) + self.usrp = uhd.usrp.MultiUSRP(usrp_args) + + # check if channel arg is valid + max_num_channels = self.usrp.get_rx_num_channels() + if channel + 1 > max_num_channels: + raise IOError(f"Channel {channel} not valid for device with {max_num_channels} channels.") + + # check if gain arg is valid + gain_range = self.usrp.get_rx_gain_range() + if gain_mode == "relative": + if gain > 0: + raise ValueError( + "When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain." + ) + else: + # set gain relative to max + abs_gain = gain_range.stop() + gain + else: + abs_gain = gain + if abs_gain < gain_range.start() or abs_gain > gain_range.stop(): + print(f"Gain {abs_gain} out of range for this USRP.") + print(f"Gain range: {gain_range.start()} to {gain_range.stop()} dB") + abs_gain = min(max(abs_gain, gain_range.start()), gain_range.stop()) + self.usrp.set_rx_gain(abs_gain, channel) + + # check if sample rate arg is valid + sample_rate_range = self.usrp.get_rx_rates() + if sample_rate < sample_rate_range.start() or sample_rate > sample_rate_range.stop(): + raise IOError( + f"Sample rate {sample_rate} not valid for this USRP.\nValid\ + range is {sample_rate_range.start()}\ + to {sample_rate_range.stop()}." + ) + self.usrp.set_rx_rate(sample_rate, channel) + + center_frequency_range = self.usrp.get_rx_freq_range() + if center_frequency < center_frequency_range.start() or center_frequency > center_frequency_range.stop(): + raise IOError( + f"Center frequency {center_frequency} out of range for USRP.\ + \nValid range is {center_frequency_range.start()} \ + to {center_frequency_range.stop()}." + ) + self.usrp.set_rx_freq(uhd.libpyuhd.types.tune_request(center_frequency), channel) + + # set internal variables for metadata + self.rx_sample_rate = self.usrp.get_rx_rate(channel) + self.rx_gain = self.usrp.get_rx_gain(channel) + self.rx_center_frequency = self.usrp.get_rx_freq(channel) + self.rx_channel = channel + + print(f"USRP RX Sample Rate = {self.rx_sample_rate}") + print(f"USRP RX Center Frequency = {self.rx_center_frequency}") + print(f"USRP RX Channel = {self.rx_channel}") + print(f"USRP RX Gain = {self.rx_gain}") + + # flag to prevent user from calling certain functions before this one. + self._rx_initialized = True + self._tx_initialized = False + + return {"sample_rate": self.rx_sample_rate, "center_frequency": self.rx_center_frequency, "gain": self.rx_gain} + + def get_rx_sample_rate(self): + """ + Retrieve the current sample rate of the receiver. + + Returns: + float: The receiver's sample rate in samples per second (Hz). + """ + return self.rx_sample_rate + + def get_rx_center_frequency(self): + """ + Retrieve the current center frequency of the receiver. + + Returns: + float: The receiver's center frequency in Hertz (Hz). + """ + return self.rx_center_frequency + + def get_rx_gain(self): + """ + Retrieve the current gain setting of the receiver. + + Returns: + float: The receiver's gain in decibels (dB). + """ + return self.rx_gain + + def _stream_rx(self, callback): + + if not self._rx_initialized: + raise RuntimeError("RX was not initialized. init_rx() must be called before _stream_rx() or record()") + + stream_args = uhd.usrp.StreamArgs("fc32", "sc16") + stream_args.channels = [self.rx_channel] + + self.metadata = uhd.types.RXMetadata() + self.rx_stream = self.usrp.get_rx_stream(stream_args) + + stream_command = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont) + stream_command.stream_now = True + self.rx_stream.issue_stream_cmd(stream_command) + + # receive loop + self._enable_rx = True + print("USRP Starting RX...") + receive_buffer = np.zeros((1, self.rx_buffer_size), dtype=np.complex64) + + while self._enable_rx: + + # 1 is the timeout #TODO maybe set this intelligently based on the desired sample rate + self.rx_stream.recv(receive_buffer, self.metadata, 1) + + # TODO set metadata correctly, sending real sample rate plus any error codes + # sending complex signal + callback(buffer=receive_buffer, metadata=self.metadata) + + if self.metadata.error_code != uhd.types.RXMetadataErrorCode.none: + print(f"Error while receiving samples: {self.metadata.strerror()}") + if self.metadata.error_code == uhd.types.RXMetadataErrorCode.timeout: + print("Stopping receive due to timeout error.") + self.stop() + wait_time = 0.1 + stop_time = self.usrp.get_time_now() + wait_time + stop_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont) + stop_cmd.stream_now = False + stop_cmd.time_spec = stop_time + self.rx_stream.issue_stream_cmd(stop_cmd) + time.sleep(wait_time) # TODO figure out what a realistic wait time is here. + del self.rx_stream + print("USRP RX Completed.") + + def record(self, num_samples): + if not self._rx_initialized: + raise RuntimeError("RX was not initialized. init_rx() must be called before _stream_rx() or record()") + + stream_args = uhd.usrp.StreamArgs("fc32", "sc16") + stream_args.channels = [self.rx_channel] + + self.metadata = uhd.types.RXMetadata() + self.rx_stream = self.usrp.get_rx_stream(stream_args) + + stream_command = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont) + stream_command.stream_now = True + self.rx_stream.issue_stream_cmd(stream_command) + + # receive loop + self._enable_rx = True + print("USRP Starting RX...") + store_array = np.zeros((1, (num_samples // self.rx_buffer_size + 1) * self.rx_buffer_size), dtype=np.complex64) + receive_buffer = np.zeros((1, self.rx_buffer_size), dtype=np.complex64) + for i in range(num_samples // self.rx_buffer_size + 1): + + # write samples to receive buffer + # they should already be complex + + # 1 is the timeout #TODO maybe set this intelligently based on the desired sample rate + self.rx_stream.recv(receive_buffer, self.metadata, 1) + + # TODO set metadata correctly, sending real sample rate plus any error codes + # sending complex signal + store_array[:, i * self.rx_buffer_size : (i + 1) * self.rx_buffer_size] = receive_buffer + + wait_time = 0.1 + stop_time = self.usrp.get_time_now() + wait_time + stop_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont) + stop_cmd.stream_now = False + stop_cmd.time_spec = stop_time + self.rx_stream.issue_stream_cmd(stop_cmd) + time.sleep(wait_time) # TODO figure out what a realistic wait time is here. + del self.rx_stream + print("USRP RX Completed.") + metadata = { + "source": self.__class__.__name__, + "sample_rate": self.rx_sample_rate, + "center_frequency": self.rx_center_frequency, + "gain": self.rx_gain, + } + + return Recording(data=store_array[:, :num_samples], metadata=metadata) + + def init_tx( + self, + sample_rate: int | float, + center_frequency: int | float, + gain: int, + channel: int, + gain_mode: Optional[str] = "absolute", + ): + """ + Initialize the USRP for transmitting. + + :param sample_rate: The sample rate for transmitting. + :type sample_rate: int or float + + :param center_frequency: The center frequency of the recording. + :type center_frequency: int or float + + :param gain: The gain set for transmitting on the USRP. + :type gain: int + + :param channel: The channel the USRP is set to. + :type channel: int + + :param gain_mode: Gain mode setting. ``"absolute"`` passes gain directly to the SDR. + ``"relative"`` means gain should be a negative value, which will be subtracted + from the maximum gain. + :type gain_mode: str + """ + + self.tx_buffer_size = 2000 + + print(f"USRP TX Gain Mode = '{gain_mode}'") + + config_str = _generate_usrp_config_string(sample_rate=sample_rate, device_dict=self.device_dict) + self.usrp = uhd.usrp.MultiUSRP(config_str) + + # check if channel arg is valid + max_num_channels = self.usrp.get_rx_num_channels() + if channel + 1 > max_num_channels: + raise IOError(f"Channel {channel} not valid for device with {max_num_channels} channels.") + + # Ensure gain is within valid range + gain_range = self.usrp.get_tx_gain_range() + if gain_mode == "relative": + if gain > 0: + raise ValueError( + "When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain." + ) + else: + # set gain relative to max + abs_gain = gain_range.stop() + gain + else: + abs_gain = gain + if abs_gain < gain_range.start() or abs_gain > gain_range.stop(): + print(f"Gain {abs_gain} out of range for this USRP.") + print(f"Gain range: {gain_range.start()} to {gain_range.stop()} dB") + abs_gain = min(max(abs_gain, gain_range.start()), gain_range.stop()) + + self.usrp.set_tx_gain(abs_gain, channel) + + # check if sample rate arg is valid + sample_rate_range = self.usrp.get_tx_rates() + if sample_rate < sample_rate_range.start() or sample_rate > sample_rate_range.stop(): + raise IOError( + f"Sample rate {sample_rate} not valid for this USRP.\nValid\ + range is {sample_rate_range.start()} to {sample_rate_range.stop()}." + ) + self.usrp.set_tx_rate(sample_rate, channel) + + center_frequency_range = self.usrp.get_tx_freq_range() + if center_frequency < center_frequency_range.start() or center_frequency > center_frequency_range.stop(): + raise IOError( + f"Center frequency {center_frequency} out of range for USRP.\ + \nValid range is {center_frequency_range.start()}\ + to {center_frequency_range.stop()}." + ) + self.usrp.set_tx_freq(uhd.libpyuhd.types.tune_request(center_frequency), channel) + + self.usrp.set_clock_source("internal") + self.usrp.set_time_source("internal") + self.usrp.set_tx_rate(sample_rate) + self.usrp.set_tx_freq(uhd.types.TuneRequest(center_frequency), channel) + self.usrp.set_tx_antenna("TX/RX", channel) + + # set internal variables for metadata + self.tx_sample_rate = self.usrp.get_tx_rate(channel) + self.tx_gain = self.usrp.get_tx_gain(channel) + self.tx_center_frequency = self.usrp.get_tx_freq(channel) + self.tx_channel = channel + + print(f"USRP TX Sample Rate = {self.tx_sample_rate}") + print(f"USRP TX Center Frequency = {self.tx_center_frequency}") + print(f"USRP TX Channel = {self.tx_channel}") + print(f"USRP TX Gain = {self.tx_gain}") + + self._tx_initialized = True + self._rx_initialized = False + + def close(self): + pass + + def _stream_tx(self, callback): + + stream_args = uhd.usrp.StreamArgs("fc32", "sc16") # wire and cpu data formats + stream_args.channels = [self.tx_channel] + tx_stream = self.usrp.get_tx_stream(stream_args) + + metadata = uhd.types.TXMetadata() + + metadata.start_of_burst = True + metadata.end_of_burst = False + self._enable_tx = True + print("USRP Starting TX...") + + while self._enable_tx: + buffer = callback(self.tx_buffer_size) + tx_stream.send(buffer, metadata) + metadata.start_of_burst = False + + print("USRP TX Completed.") + + def tx_recording( + self, + recording: Recording | np.ndarray, + num_samples: Optional[int] = None, + tx_time: Optional[int | float] = None, + ): + """ + Transmit the given iq samples from the provided recording. + init_tx() must be called before this function. + + :param recording: The recording to transmit. + :type recording: Recording or np.ndarray + :param num_samples: The number of samples to transmit, will repeat or + truncate the recording to this length. Defaults to None. + :type num_samples: int, optional + :param tx_time: The time to transmit, will repeat or truncate the + recording to this length. Defaults to None. + :type tx_time: int or float, optional + """ + + if num_samples is not None and tx_time is not None: + raise ValueError("Only input one of num_samples or tx_time") + elif num_samples is not None: + self._num_samples_to_transmit = num_samples + elif tx_time is not None: + self._num_samples_to_transmit = int(tx_time * self.tx_sample_rate) + else: + self._num_samples_to_transmit = len(recording) + + if isinstance(recording, np.ndarray): + samples = recording + elif isinstance(recording, Recording): + if len(recording.data) > 1: + warnings.warn("Recording object is multichannel, only channel 0 data was used for transmission") + + samples = recording.data[0] + + samples = samples.astype(np.complex64, copy=False) + + # This is extremely important + # Ensure array is contiguous + samples = np.ascontiguousarray(samples) + + # Ensure correct byte order + if samples.dtype.byteorder == ">": + samples = samples.byteswap().newbyteorder() + + self._samples_to_transmit = samples + self._num_samples_transmitted = 0 + + self._stream_tx(self._loop_recording_callback) + + def set_clock_source(self, source): + source = source.lower() + if source == "external": + self.usrp.set_clock_source(source) + + print(f"USRP clock source set to {self.usrp.get_clock_source(0)}") + + +def _create_device_dict(identifier_value=None): + """ + Get the dictionary of information corresponding to any unique identifier, + using uhd_find_devices. + """ + + available_devices = _parse_uhd_find_devices() + print(available_devices) + if identifier_value is None: + print("\033[93mWarning: No USRP device identifier provided. Defaulting to the first USRP device found.\033[0m") + if len(available_devices) > 0: + formatted_dict_str = "\n".join([f"\t{key}: {value}" for key, value in available_devices[0].items()]) + else: + raise IOError("\033[91mError: No USRP devices found.\033[0m") + print(f"Device information: \n{formatted_dict_str}") + return available_devices[0] + + identified_devices = [] + for device_dict in available_devices: + for key, value in device_dict.items(): + if identifier_value is not None and str(value).lower() == str(identifier_value).lower(): + identified_devices.append(device_dict) + break + + if len(identified_devices) > 1: + print(f"\033[93mWarning: Found multiple USRP devices with identifier '{identifier_value}'.\033[0m") + print("\033[93mDefaulting to the first USRP device found with this identifier.\033[0m") + formatted_dict_str = "\n".join([f"\t{key}: {value}" for key, value in identified_devices[0].items()]) + print(f"Device information: \n{formatted_dict_str}") + return identified_devices[0] + + elif len(identified_devices) == 1: + print(f"\033[92mSuccessfully found USRP device with identifier '{identifier_value}'\033[0m") + formatted_dict_str = "\n".join([f"\t{key}: {value}" for key, value in identified_devices[0].items()]) + print(f"Device information: \n{formatted_dict_str}") + return identified_devices[0] + + elif len(identified_devices) == 0: + raise IOError(f"\033[31mError: No USRP device found for identifier '{identifier_value}'.\033[0m") + + +def _generate_usrp_config_string(sample_rate, device_dict): + """ + Create a correctly formatted string as expected by + uhd.usrp.MultiUSRP constructor + + If it is a x300 there are two options for internal master clock settings + master_clock_rate_string = self.force_srate_xseries(sample_rate) + """ + + if "type" in device_dict and device_dict["type"] == "x300": + master_clock_rate_string = _force_srate_xseries(sample_rate) + else: + master_clock_rate_string = "" + + if "addr" in device_dict: + ip_address_string = f"addr={device_dict['addr']}," + else: + ip_address_string = "" + + if "name" in device_dict: + name_string = f"name={device_dict['name']}," + else: + name_string = "" + + config_string = ip_address_string + master_clock_rate_string + name_string + + return config_string + + +def _force_srate_xseries(sample_rate): + two_hundred_rates = [200.0e6 / i for i in range(1, 201)] # down to 1MHz wide + one_eighty_four_rates = [184.32e6 / i for i in range(1, 185)] # down to ~ 1MHz wide + + diff_two_hundred = min([abs(x - sample_rate) for x in two_hundred_rates]) + diff_one_eighty_four = min([abs(x - sample_rate) for x in one_eighty_four_rates]) + + closest_list = "two_hundred_rates" if diff_two_hundred < diff_one_eighty_four else "one_eighty_four_rates" + if closest_list == "one_eighty_four_rates": + mcr_str = "master_clock_rate=184.32e6," + # print("MCR set to 184.32 MHz") + else: + mcr_str = "" + return mcr_str + + +def _parse_uhd_find_devices(): + """ + Parse the uhd_find_devices subprocess command output into usable data. + Returns: an array length = num_devices of dicts containing the data. + """ + p = subprocess.Popen("uhd_find_devices", stdout=subprocess.PIPE) + output, err = p.communicate() + separate_devices = output.rsplit(b"--") + cleaned_separate_devices = [device for device in separate_devices if len(device) >= 20] + list_of_dicts = [] + for device_string in cleaned_separate_devices: + device_as_list = device_string.split(b"\n") + device_as_list = [device for device in device_as_list if len(device) >= 2] + for i in range(len(device_as_list)): + device_as_list[i] = device_as_list[i].strip(b" ") + + device_dict = {} + for i in range(len(device_as_list)): + [key, value] = device_as_list[i].split(b":") + key = key.strip() + value = value.strip() + key = key.decode("utf-8") # cast to string + value = value.decode("utf-8") + device_dict.update({key: value}) + + list_of_dicts.append(device_dict) + return list_of_dicts