modifications to blade, hackrf, base sdr to include bias t, and tx, addition of rtlsdr, addition of dependancies
This commit is contained in:
parent
8a3c80b33f
commit
a9f8ad4bee
|
|
@ -47,6 +47,21 @@ dependencies = [
|
|||
"pyzmq (>=27.1.0,<28.0.0)",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# SDR hardware-specific dependencies (optional installs)
|
||||
rtlsdr = ["pyrtlsdr>=0.2.9"]
|
||||
pluto = ["pyadi-iio>=0.0.14"]
|
||||
usrp = [] # Requires system UHD installation
|
||||
hackrf = ["pyhackrf>=0.2.0"]
|
||||
bladerf = [] # Requires system libbladerf installation
|
||||
|
||||
# All SDR hardware support
|
||||
all-sdr = [
|
||||
"pyrtlsdr>=0.2.9",
|
||||
"pyadi-iio>=0.0.14",
|
||||
"pyhackrf>=0.2.0",
|
||||
]
|
||||
|
||||
[tool.poetry]
|
||||
packages = [
|
||||
{ include = "ria_toolkit_oss", from = "src" }
|
||||
|
|
|
|||
|
|
@ -325,8 +325,8 @@ f.argtypes = [p_hackrf_device, POINTER(read_partid_serialno_t)]
|
|||
# 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]
|
||||
libhackrf.hackrf_set_antenna_enable.restype = c_int
|
||||
libhackrf.hackrf_set_antenna_enable.argtypes = [p_hackrf_device, c_uint8]
|
||||
#
|
||||
## extern ADDAPI const char* ADDCALL hackrf_error_name(enum hackrf_error errcode);
|
||||
## libhackrf.hackrf_error_name.restype = POINTER(c_char)
|
||||
|
|
@ -537,6 +537,16 @@ class HackRF(object):
|
|||
raise IOError("error disabling amp")
|
||||
return 0
|
||||
|
||||
def set_antenna_enable(self, enable):
|
||||
value = 1 if enable else 0
|
||||
result = libhackrf.hackrf_set_antenna_enable(self.dev_p, value)
|
||||
if result != 0:
|
||||
error_name = get_error_name(result)
|
||||
raise IOError(f"Error setting antenna bias tee: {error_name} (Code {result})")
|
||||
state = "enabled" if enable else "disabled"
|
||||
print(f"HackRF antenna bias tee {state}.")
|
||||
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
|
||||
|
|
@ -582,6 +592,75 @@ class HackRF(object):
|
|||
if result != 0:
|
||||
raise IOError("stop_rx failure")
|
||||
|
||||
def _rx_capture_callback(self, hackrf_transfer):
|
||||
"""Instance method callback for RX capture - prevents garbage collection"""
|
||||
try:
|
||||
c = hackrf_transfer.contents
|
||||
|
||||
# Append bytes to buffer using string_at
|
||||
from ctypes import string_at
|
||||
byte_chunk = string_at(c.buffer, c.valid_length)
|
||||
self._capture_buffer.extend(byte_chunk)
|
||||
|
||||
# Check if we have enough
|
||||
if len(self._capture_buffer) >= self._capture_target:
|
||||
self._capture_done = True
|
||||
return 1 # Stop streaming
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error in RX capture callback: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._capture_done = True
|
||||
return 1
|
||||
|
||||
def read_samples(self, num_samples):
|
||||
"""
|
||||
Block capture mode for HackRF - captures exactly num_samples.
|
||||
This is safer than streaming for USB2 and avoids buffer overflow issues.
|
||||
|
||||
:param num_samples: Number of complex samples to capture
|
||||
:return: numpy array of complex64 samples
|
||||
"""
|
||||
# Initialize capture state as instance variables
|
||||
self._capture_buffer = bytearray()
|
||||
self._capture_target = num_samples * 2 # 2 bytes per complex sample (I+Q as int8)
|
||||
self._capture_done = False
|
||||
|
||||
# Store callback as instance variable to prevent garbage collection (like TX does)
|
||||
self._rx_cb = _callback(self._rx_capture_callback)
|
||||
|
||||
# Start RX with the callback
|
||||
result = libhackrf.hackrf_start_rx(self.dev_p, self._rx_cb, None)
|
||||
if result != 0:
|
||||
raise IOError("start_rx failure during read_samples")
|
||||
|
||||
# Wait for capture to complete
|
||||
import time
|
||||
timeout = num_samples / self.sample_rate + 5.0 # Add 5 second buffer
|
||||
start_time = time.time()
|
||||
|
||||
while not self._capture_done:
|
||||
if time.time() - start_time > timeout:
|
||||
print("HackRF capture timeout!")
|
||||
break
|
||||
time.sleep(0.01)
|
||||
|
||||
# Stop RX
|
||||
self.stop_rx()
|
||||
|
||||
# Convert bytes to complex samples
|
||||
byte_data = bytes(self._capture_buffer[:self._capture_target])
|
||||
all_samples = np.frombuffer(byte_data, dtype=np.int8).astype(np.float32).view(np.complex64)
|
||||
|
||||
# Clean up instance variables
|
||||
del self._capture_buffer
|
||||
del self._capture_target
|
||||
del self._capture_done
|
||||
del self._rx_cb
|
||||
|
||||
return all_samples[:num_samples]
|
||||
|
||||
# Add transmit gain property
|
||||
def set_txvga_gain(self, gain):
|
||||
if gain < 0 or gain > 47:
|
||||
|
|
|
|||
|
|
@ -1,383 +1,484 @@
|
|||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from bladerf import _bladerf
|
||||
|
||||
from ria_toolkit_oss.datatypes import Recording
|
||||
from ria_toolkit_oss.sdr import SDR
|
||||
|
||||
|
||||
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()}")
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from bladerf import _bladerf
|
||||
|
||||
from ria_toolkit_oss.datatypes import Recording
|
||||
from ria_toolkit_oss.sdr import SDR
|
||||
|
||||
|
||||
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 supports_bias_tee(self) -> bool:
|
||||
return True
|
||||
|
||||
def set_bias_tee(self, enable: bool, channel: Optional[int] = None):
|
||||
if channel is None:
|
||||
channel = getattr(self, "rx_channel", getattr(self, "tx_channel", 0))
|
||||
|
||||
try:
|
||||
bladerf_channel = _bladerf.CHANNEL_RX(channel)
|
||||
self.device.set_bias_tee(bladerf_channel, bool(enable))
|
||||
except AttributeError as exc: # pragma: no cover - depends on libbladeRF version
|
||||
raise NotImplementedError("bladeRF binding lacks bias-tee control") from exc
|
||||
|
||||
state = "enabled" if enable else "disabled"
|
||||
print(f"BladeRF bias tee {state} on channel {channel}.")
|
||||
|
||||
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 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
|
||||
"""
|
||||
import warnings
|
||||
import time
|
||||
from ria_toolkit_oss.datatypes.recording import 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:
|
||||
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]
|
||||
else:
|
||||
raise TypeError("recording must be np.ndarray or Recording")
|
||||
|
||||
samples = samples.astype(np.complex64, copy=False)
|
||||
|
||||
# Setup stream
|
||||
self.device.sync_config(
|
||||
layout=_bladerf.ChannelLayout.TX_X1,
|
||||
fmt=_bladerf.Format.SC16_Q11,
|
||||
num_buffers=16,
|
||||
buffer_size=self.tx_buffer_size,
|
||||
num_transfers=8,
|
||||
stream_timeout=3500,
|
||||
)
|
||||
|
||||
# Enable module
|
||||
self.tx_ch.enable = True
|
||||
|
||||
print("Blade Starting TX...")
|
||||
|
||||
# Transmit samples - repeat as needed for the duration
|
||||
start_time = time.time()
|
||||
sample_index = 0
|
||||
|
||||
try:
|
||||
while time.time() - start_time < tx_time:
|
||||
# Get next chunk
|
||||
chunk_size = min(self.tx_buffer_size, len(samples) - sample_index)
|
||||
if chunk_size == 0:
|
||||
# Reached end, loop back
|
||||
sample_index = 0
|
||||
chunk_size = min(self.tx_buffer_size, len(samples))
|
||||
|
||||
chunk = samples[sample_index:sample_index + chunk_size]
|
||||
sample_index += chunk_size
|
||||
|
||||
# Convert and transmit
|
||||
byte_array = self._convert_tx_samples(chunk)
|
||||
self.device.sync_tx(byte_array, len(chunk))
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nTransmission interrupted by user")
|
||||
|
||||
# Disable module
|
||||
print("Blade TX Completed.")
|
||||
self.tx_ch.enable = False
|
||||
|
||||
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()}")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import time
|
||||
import warnings
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
|
@ -35,10 +36,101 @@ class HackRF(SDR):
|
|||
|
||||
super().__init__()
|
||||
|
||||
def supports_bias_tee(self) -> bool:
|
||||
return True
|
||||
|
||||
def set_bias_tee(self, enable: bool):
|
||||
try:
|
||||
self.radio.set_antenna_enable(bool(enable))
|
||||
except AttributeError as exc: # pragma: no cover - defensive
|
||||
raise NotImplementedError("Underlying HackRF interface lacks bias-tee control") from exc
|
||||
|
||||
def init_rx(self, sample_rate, center_frequency, gain, channel, gain_mode):
|
||||
self._tx_initialized = False
|
||||
"""
|
||||
Initializes the HackRF for receiving.
|
||||
|
||||
HackRF has 3 gain stages:
|
||||
- 14 dB front-end amplifier (on/off)
|
||||
- LNA gain: 0-40 dB in 8 dB steps
|
||||
- VGA gain: 0-62 dB in 2 dB steps
|
||||
|
||||
: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 total gain set for receiving on the HackRF (distributed across stages)
|
||||
:type gain: int
|
||||
:param channel: The channel the HackRF is set to. (Not actually used)
|
||||
:type channel: int
|
||||
:param gain_mode: Gain mode setting. Currently only "absolute" is supported.
|
||||
:type gain_mode: str
|
||||
"""
|
||||
print("Initializing RX")
|
||||
|
||||
self.rx_sample_rate = sample_rate
|
||||
self.radio.sample_rate = int(sample_rate)
|
||||
print(f"HackRF sample rate = {self.radio.sample_rate}")
|
||||
|
||||
self.rx_center_frequency = center_frequency
|
||||
self.radio.center_freq = int(center_frequency)
|
||||
print(f"HackRF center frequency = {self.radio.center_freq}")
|
||||
|
||||
# Distribute gain across amplifier stages
|
||||
rx_gain_min = 0
|
||||
rx_gain_max = 116 # 14 (amp) + 40 (LNA) + 62 (VGA)
|
||||
|
||||
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(abs_gain, rx_gain_min), rx_gain_max)
|
||||
print(f"Gain {gain} out of range for HackRF.")
|
||||
print(f"Gain range: {rx_gain_min} to {rx_gain_max} dB")
|
||||
|
||||
# Distribute gain using the signal-testbed algorithm
|
||||
enable_amp = False
|
||||
remaining_gain = abs_gain
|
||||
|
||||
# Enable 14 dB pre-amp if gain is high enough
|
||||
if remaining_gain > 30:
|
||||
remaining_gain = remaining_gain - 14
|
||||
enable_amp = True
|
||||
print("HackRF: 14dB front-end amplifier enabled.")
|
||||
|
||||
# Distribute remaining gain between LNA and VGA
|
||||
# LNA gets 60% of remaining gain, rounded down to 8 dB steps
|
||||
lna_gain = math.floor(remaining_gain * 0.6)
|
||||
lna_gain = lna_gain - (lna_gain % 8) # Round to 8 dB steps
|
||||
if lna_gain > 40:
|
||||
lna_gain = 40
|
||||
|
||||
# VGA gets the rest
|
||||
vga_gain = remaining_gain - lna_gain
|
||||
if vga_gain > 62:
|
||||
vga_gain = 62
|
||||
|
||||
# Apply gain settings
|
||||
if enable_amp:
|
||||
self.radio.enable_amp()
|
||||
else:
|
||||
self.radio.disable_amp()
|
||||
|
||||
self.radio.set_lna_gain(lna_gain)
|
||||
self.radio.set_vga_gain(vga_gain)
|
||||
|
||||
self.rx_gain = abs_gain
|
||||
print(f"HackRF gain distribution: Amp={enable_amp}, LNA={lna_gain}dB, VGA={vga_gain}dB")
|
||||
|
||||
self._rx_initialized = True
|
||||
return NotImplementedError("RX not yet implemented for HackRF")
|
||||
self._tx_initialized = False
|
||||
|
||||
def init_tx(
|
||||
self,
|
||||
|
|
@ -151,10 +243,87 @@ class HackRF(SDR):
|
|||
def close(self):
|
||||
self.radio.close()
|
||||
|
||||
def _stream_rx(self, callback):
|
||||
def record(self, num_samples):
|
||||
"""
|
||||
Record a specified number of samples from the HackRF using block capture mode.
|
||||
This is more reliable than streaming for USB2 connections.
|
||||
|
||||
:param num_samples: Number of samples to capture
|
||||
:type num_samples: int
|
||||
:return: Recording object containing the captured data
|
||||
:rtype: Recording
|
||||
"""
|
||||
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")
|
||||
raise RuntimeError("RX was not initialized. init_rx() must be called before record()")
|
||||
|
||||
print("HackRF Starting RX...")
|
||||
|
||||
# Use libhackrf's block capture method
|
||||
all_samples = self.radio.read_samples(num_samples)
|
||||
|
||||
print("HackRF RX Completed.")
|
||||
|
||||
# Create 1xN array for single-channel recording
|
||||
store_array = np.zeros((1, num_samples), dtype=np.complex64)
|
||||
store_array[0, :] = all_samples
|
||||
|
||||
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, metadata=metadata)
|
||||
|
||||
def _stream_rx(self, callback):
|
||||
"""
|
||||
Stream samples from the HackRF using a callback function.
|
||||
|
||||
:param callback: Function to call for each buffer of samples
|
||||
:type callback: callable
|
||||
"""
|
||||
if not self._rx_initialized:
|
||||
raise RuntimeError("RX was not initialized. init_rx() must be called before _stream_rx()")
|
||||
|
||||
print("HackRF Starting RX stream...")
|
||||
|
||||
self._enable_rx = True
|
||||
|
||||
def rx_callback(hackrf_transfer):
|
||||
"""Internal callback that wraps the user's callback"""
|
||||
try:
|
||||
if not self._enable_rx:
|
||||
return 1 # Stop
|
||||
|
||||
c = hackrf_transfer.contents
|
||||
|
||||
# Use ctypes string_at to safely copy the buffer
|
||||
from ctypes import string_at
|
||||
byte_data = string_at(c.buffer, c.valid_length)
|
||||
|
||||
# Convert bytes to int8, then to float32, then view as complex64
|
||||
samples = np.frombuffer(byte_data, dtype=np.int8).astype(np.float32).view(np.complex64)
|
||||
|
||||
# Call user's callback
|
||||
callback(buffer=samples, metadata=None)
|
||||
|
||||
return 0 if self._enable_rx else 1
|
||||
except Exception as e:
|
||||
print(f"Error in rx_callback: {e}")
|
||||
return 1 # Stop on error
|
||||
|
||||
# Start RX
|
||||
self.radio.start_rx(rx_callback)
|
||||
|
||||
# Wait while streaming
|
||||
while self._enable_rx:
|
||||
time.sleep(0.1)
|
||||
|
||||
# Stop RX
|
||||
self.radio.stop_rx()
|
||||
|
||||
print("HackRF RX stream completed.")
|
||||
|
||||
def _stream_tx(self, callback):
|
||||
return super()._stream_tx(callback)
|
||||
|
|
|
|||
190
src/ria_toolkit_oss/sdr/rtlsdr.py
Normal file
190
src/ria_toolkit_oss/sdr/rtlsdr.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""RTL-SDR device integration for the RIA Toolkit."""
|
||||
|
||||
import time
|
||||
import warnings
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from rtlsdr import RtlSdr
|
||||
except ImportError as exc: # pragma: no cover - dependency provided by end user
|
||||
raise ImportError("pyrtlsdr is required to use the RTLSDR class") from exc
|
||||
|
||||
from ria_toolkit_oss.sdr.sdr import SDR
|
||||
|
||||
|
||||
class RTLSDR(SDR):
|
||||
"""SDR interface for RTL-SDR dongles using pyrtlsdr."""
|
||||
|
||||
def __init__(self, identifier: Optional[int | str] = None):
|
||||
super().__init__()
|
||||
|
||||
try:
|
||||
if identifier is None:
|
||||
self.radio = RtlSdr()
|
||||
else:
|
||||
self.radio = RtlSdr(identifier)
|
||||
print(f"Initialized RTL-SDR with identifier [{identifier}].")
|
||||
except Exception as exc:
|
||||
print(f"Failed to initialize RTL-SDR with identifier [{identifier}].")
|
||||
raise exc
|
||||
|
||||
self.rx_buffer_size = 256_000
|
||||
self.rx_channel = 0
|
||||
|
||||
def supports_bias_tee(self) -> bool:
|
||||
return True
|
||||
|
||||
def set_bias_tee(self, enable: bool):
|
||||
self.radio.set_bias_tee(bool(enable))
|
||||
state = "enabled" if enable else "disabled"
|
||||
print(f"RTL-SDR bias tee {state}.")
|
||||
|
||||
def init_rx(
|
||||
self,
|
||||
sample_rate: int | float,
|
||||
center_frequency: int | float,
|
||||
gain: Optional[int],
|
||||
channel: int,
|
||||
gain_mode: Optional[str] = "absolute",
|
||||
buffer_size: Optional[int] = 256_000,
|
||||
bias_t: bool = False,
|
||||
):
|
||||
if channel not in (0, None):
|
||||
raise ValueError("RTL-SDR supports only channel 0 for RX.")
|
||||
|
||||
self.radio.sample_rate = float(sample_rate)
|
||||
self.rx_sample_rate = self.radio.sample_rate
|
||||
|
||||
self.radio.center_freq = float(center_frequency)
|
||||
self.rx_center_frequency = self.radio.center_freq
|
||||
|
||||
available_gains = getattr(self.radio, "gains", [])
|
||||
if gain is None:
|
||||
self.radio.gain = "auto"
|
||||
self.rx_gain = "auto"
|
||||
else:
|
||||
if not available_gains:
|
||||
warnings.warn(
|
||||
"No gain table reported by RTL-SDR; applying requested gain directly.",
|
||||
RuntimeWarning,
|
||||
)
|
||||
target_gain = gain
|
||||
else:
|
||||
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."
|
||||
)
|
||||
target_gain = max(available_gains) + gain
|
||||
else:
|
||||
target_gain = gain
|
||||
|
||||
min_gain = min(available_gains)
|
||||
max_gain = max(available_gains)
|
||||
if target_gain < min_gain or target_gain > max_gain:
|
||||
print(f"Requested gain {target_gain} dB out of range; clamping to valid span {min_gain}-{max_gain} dB.")
|
||||
target_gain = min(max(target_gain, min_gain), max_gain)
|
||||
|
||||
target_gain = min(available_gains, key=lambda g: abs(g - target_gain))
|
||||
|
||||
self.radio.gain = target_gain
|
||||
self.rx_gain = self.radio.gain
|
||||
|
||||
self.rx_buffer_size = int(buffer_size or self.rx_buffer_size)
|
||||
self.rx_channel = 0
|
||||
|
||||
if bias_t:
|
||||
self.set_bias_tee(True)
|
||||
time.sleep(1)
|
||||
|
||||
self._rx_initialized = True
|
||||
self._tx_initialized = False
|
||||
|
||||
def init_tx(self, *args, **kwargs): # pragma: no cover - RTL-SDR is RX only
|
||||
raise NotImplementedError("RTL-SDR does not support transmit operations")
|
||||
|
||||
def record(self, num_samples):
|
||||
"""
|
||||
Record a fixed number of samples from RTL-SDR.
|
||||
|
||||
Args:
|
||||
num_samples: Number of samples to capture
|
||||
|
||||
Returns:
|
||||
Recording object with captured samples
|
||||
"""
|
||||
from ria_toolkit_oss.datatypes.recording import Recording
|
||||
|
||||
if not self._rx_initialized:
|
||||
raise RuntimeError("RX was not initialized. init_rx() must be called before record().")
|
||||
|
||||
print("RTL-SDR Starting RX...")
|
||||
|
||||
# RTL-SDR has USB buffer limitations - use consistent 256k chunks
|
||||
# Always read full chunks to avoid USB overflow issues with partial reads
|
||||
max_samples_per_read = 262144 # 256k samples = stable chunk size
|
||||
num_full_reads = num_samples // max_samples_per_read
|
||||
remainder = num_samples % max_samples_per_read
|
||||
signal = np.array([], dtype=np.complex64)
|
||||
|
||||
# Read full chunks
|
||||
for i in range(num_full_reads):
|
||||
try:
|
||||
chunk = self.radio.read_samples(max_samples_per_read)
|
||||
signal = np.append(signal, chunk)
|
||||
except Exception as e:
|
||||
print(f"Error while reading samples: {e}")
|
||||
break
|
||||
|
||||
# Read remainder if needed (round up to power of 2 for USB compatibility)
|
||||
if remainder > 0 and len(signal) == num_full_reads * max_samples_per_read:
|
||||
# Round up to next 16k boundary for USB stability
|
||||
padded_remainder = ((remainder + 16383) // 16384) * 16384
|
||||
try:
|
||||
chunk = self.radio.read_samples(padded_remainder)
|
||||
signal = np.append(signal, chunk[:remainder]) # Only keep what we need
|
||||
except Exception as e:
|
||||
print(f"Error while reading final chunk: {e}")
|
||||
|
||||
print("RTL-SDR RX Completed.")
|
||||
|
||||
# Create 1xN array for single-channel recording
|
||||
store_array = np.zeros((1, len(signal)), dtype=np.complex64)
|
||||
store_array[0, :] = signal
|
||||
|
||||
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, metadata=metadata)
|
||||
|
||||
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("RTL-SDR Starting RX...")
|
||||
self._enable_rx = True
|
||||
try:
|
||||
while self._enable_rx:
|
||||
samples = self.radio.read_samples(self.rx_buffer_size)
|
||||
callback(buffer=np.asarray(samples, dtype=np.complex64), metadata=None)
|
||||
finally:
|
||||
print("RTL-SDR RX Completed.")
|
||||
|
||||
def _stream_tx(self, callback): # pragma: no cover - RTL-SDR is RX only
|
||||
raise NotImplementedError("RTL-SDR does not support transmit operations")
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.radio.close()
|
||||
finally:
|
||||
self._enable_rx = False
|
||||
self._enable_tx = False
|
||||
|
||||
def set_clock_source(self, source): # pragma: no cover - not applicable to RTL-SDR
|
||||
raise NotImplementedError("RTL-SDR does not support external clock configuration")
|
||||
|
|
@ -301,12 +301,20 @@ class SDR(ABC):
|
|||
def pause_tx(self):
|
||||
self._enable_tx = False
|
||||
|
||||
def stop(self):
|
||||
self.pause_rx()
|
||||
|
||||
@abstractmethod
|
||||
def close(self):
|
||||
pass
|
||||
def stop(self):
|
||||
self.pause_rx()
|
||||
|
||||
def supports_bias_tee(self) -> bool:
|
||||
"""Return True when the radio supports bias-tee control."""
|
||||
return False
|
||||
|
||||
def set_bias_tee(self, enable: bool):
|
||||
"""Enable or disable bias-tee power when supported by the radio."""
|
||||
raise NotImplementedError(f"{self.__class__.__name__} does not support bias-tee control")
|
||||
|
||||
@abstractmethod
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def init_rx(self, sample_rate, center_frequency, gain, channel, gain_mode):
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user