Compare commits

..

1 Commits

@ -227,22 +227,4 @@ curl -X POST 'http://127.0.0.1:5010/process_data' \
### Read_energy ### Read_energy
``` bash ``` bash
.venv-sdr/bin/python read_energy.py .venv-sdr/bin/python read_energy.py
``` ```
### Read_energy Neptune
``` bash
./.venv-sdr/bin/python read_energy.py --backend iio --uri ip:192.168.2.1 --only 2400 --refresh 0.5 --interval 0.2 --vec-len 1024
```
### Capture_hourly Neptune
``` bash
CAPTURE_ORDER=2400 \
CAPTURE_BACKEND_2400=iio \
CAPTURE_IIO_URI_2400=ip:192.168.2.1 \
RUN_ONCE=1 \
./capture_hourly.sh
```
Дополнительные параметры `Neptune` для `capture_hourly` читаются из `.env` или окружения в формате
`CAPTURE_IIO_<KEY>_2400`, например `CAPTURE_IIO_DEVICE_2400`, `CAPTURE_IIO_PHY_DEVICE_2400`,
`CAPTURE_IIO_GAIN_MODE_2400`, `CAPTURE_IIO_HARDWAREGAIN_2400`, `CAPTURE_IIO_BANDWIDTH_2400`.

@ -6,114 +6,91 @@ SCRIPT_OWNER="${CAPTURE_USER:-$(stat -c %U "$SCRIPT_DIR")}"
SCRIPT_OWNER_HOME="$(getent passwd "$SCRIPT_OWNER" | cut -d: -f6)" SCRIPT_OWNER_HOME="$(getent passwd "$SCRIPT_OWNER" | cut -d: -f6)"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.env" source "$SCRIPT_DIR/.env"
declare -A DOTENV_VALUES ############################
# НАСТРОЙКИ
trim_whitespace() { ############################
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
load_env_file() { BASE_DIR="${CAPTURE_BASE_DIR:-${SCRIPT_OWNER_HOME}/dataset/noise}"
local line key value quote_char
if [[ ! -f "$ENV_FILE" ]]; then # Путь к python из venv
echo "Не найден .env: $ENV_FILE" >&2 PYTHON_BIN="${PYTHON_BIN:-$SCRIPT_DIR/.venv-sdr/bin/python}"
exit 1
fi
while IFS= read -r line || [[ -n "$line" ]]; do # Путь к headless скрипту
line="${line%$'\r'}" SCRIPT_PATH="${SCRIPT_PATH:-$SCRIPT_DIR/scripts_nn/data_saver_headless.py}"
line="$(trim_whitespace "$line")"
[[ -z "$line" || "$line" == \#* ]] && continue RUN_ONCE="${RUN_ONCE:-0}"
CAPTURE_LOG_FILE="${CAPTURE_LOG_FILE:-$BASE_DIR/capture_hourly.log}"
if [[ "$line" == export\ * ]]; then SYSTEMCTL_BIN=(systemctl)
line="${line#export }" CURRENT_CAPTURE_PID=""
fi CURRENT_SERVICE_UNIT=""
[[ "$line" == *=* ]] || continue
key="$(trim_whitespace "${line%%=*}")"
value="$(trim_whitespace "${line#*=}")"
if [[ ${#value} -ge 2 ]]; then # Лимиты
quote_char="${value:0:1}"
if [[ ( "$quote_char" == "'" || "$quote_char" == '"' ) && "${value: -1}" == "$quote_char" ]]; then
value="${value:1:${#value}-2}"
fi
fi
DOTENV_VALUES["$key"]="$value" lim_all=10
done < "$ENV_FILE" PER_FREQ_LIMIT_BYTES=$((1 * 1024 * 1024 * 1024)) # GiB на частоту за запуск
} TOTAL_LIMIT_BYTES=$((lim_all * 1024 * 1024 * 1024)) # общий лимит GiB
CYCLE_SECONDS=1 # один цикл в час
# Параметры SDR
SAMP_RATE="20e6"
SPLIT_SIZE="400000"
DELAY="0.01"
RF_GAIN="12"
IF_GAIN="30"
BB_GAIN="36"
get_env_setting() { ############################
local key="$1" # ЧАСТОТЫ И SERIAL ИЗ ENV
local default="${2-}" ############################
if [[ -n "${!key+x}" ]]; then ORDER=(433)
printf '%s' "${!key}"
elif [[ -n "${DOTENV_VALUES[$key]+x}" ]]; then
printf '%s' "${DOTENV_VALUES[$key]}"
else
printf '%s' "$default"
fi
}
get_band_setting() {
local prefix="$1"
local band="$2"
local default="${3-}"
get_env_setting "${prefix}_${band}" "$(get_env_setting "$prefix" "$default")" declare -A SERIAL
} declare -A FREQ_HZ
declare -A SERVICE_UNIT
load_env_file SERIAL[433]="$hack_433"
FREQ_HZ[433]="433000000"
############################ SERIAL[750]="$hack_750"
# НАСТРОЙКИ FREQ_HZ[750]="750000000"
############################
BASE_DIR="$(get_env_setting CAPTURE_BASE_DIR "${SCRIPT_OWNER_HOME}/dataset/noise")"
PYTHON_BIN="$(get_env_setting PYTHON_BIN "$SCRIPT_DIR/.venv-sdr/bin/python")"
SCRIPT_PATH="$(get_env_setting SCRIPT_PATH "$SCRIPT_DIR/scripts_nn/data_saver_headless.py")"
RUN_ONCE="$(get_env_setting RUN_ONCE "0")" SERIAL[915]="$hack_915"
CAPTURE_LOG_FILE="$(get_env_setting CAPTURE_LOG_FILE "$BASE_DIR/capture_hourly.log")" FREQ_HZ[915]="915000000"
SYSTEMCTL_BIN=(systemctl) SERIAL[1200]="$hack_1200"
CURRENT_CAPTURE_PID="" FREQ_HZ[1200]="1200000000"
CURRENT_SERVICE_UNITS=()
lim_all=10 SERIAL[2400]="$hack_2400"
PER_FREQ_LIMIT_BYTES=$((1 * 1024 * 1024 * 1024)) FREQ_HZ[2400]="2400000000"
TOTAL_LIMIT_BYTES=$((lim_all * 1024 * 1024 * 1024))
CYCLE_SECONDS="$(get_env_setting CAPTURE_CYCLE_SECONDS "1")"
DEFAULT_SAMP_RATE="$(get_env_setting CAPTURE_SAMP_RATE "20e6")" SERIAL[3300]="$hack_3300"
DEFAULT_SPLIT_SIZE="$(get_env_setting CAPTURE_SPLIT_SIZE "400000")" FREQ_HZ[3300]="3300000000"
DEFAULT_DELAY="$(get_env_setting CAPTURE_DELAY "0.01")"
DEFAULT_RF_GAIN="$(get_env_setting CAPTURE_RF_GAIN "12")"
DEFAULT_IF_GAIN="$(get_env_setting CAPTURE_IF_GAIN "30")"
DEFAULT_BB_GAIN="$(get_env_setting CAPTURE_BB_GAIN "36")"
ORDER=(433) SERIAL[4500]="$hack_4500"
SUPPORTED_BANDS=(433 750 915 1200 1500 2400 3300 4500 5200 5800) FREQ_HZ[4500]="4500000000"
declare -A SERIAL SERIAL[5200]="$hack_5200"
declare -A FREQ_HZ FREQ_HZ[5200]="5200000000"
declare -A SERVICE_UNIT
declare -A BACKEND SERIAL[5800]="$hack_5800"
FREQ_HZ[5800]="5800000000"
for band in "${SUPPORTED_BANDS[@]}"; do SERVICE_UNIT[433]="dronedetector-sdr-433.service"
SERIAL["$band"]="$(get_env_setting "hack_${band}" "$(get_env_setting "HACKID_${band}")")" SERVICE_UNIT[750]="dronedetector-sdr-750.service"
FREQ_HZ["$band"]="$(get_env_setting "c_freq_${band}" "$band")e6" SERVICE_UNIT[915]="dronedetector-sdr-915.service"
SERVICE_UNIT["$band"]="dronedetector-sdr-${band}.service" SERVICE_UNIT[1200]="dronedetector-sdr-1200.service"
BACKEND["$band"]="$(tr '[:upper:]' '[:lower:]' <<<"$(get_band_setting CAPTURE_BACKEND "$band" "hackrf")")" SERVICE_UNIT[2400]="dronedetector-sdr-2400.service"
done SERVICE_UNIT[3300]="dronedetector-sdr-3300.service"
SERVICE_UNIT[4500]="dronedetector-sdr-4500.service"
SERVICE_UNIT[5200]="dronedetector-sdr-5200.service"
SERVICE_UNIT[5800]="dronedetector-sdr-5800.service"
############################ ############################
# ВСПОМОГАТЕЛЬНОЕ # ВСПОМОГАТЕЛЬНОЕ
@ -132,117 +109,36 @@ service_exists() {
systemctl_run show "$unit" >/dev/null 2>&1 systemctl_run show "$unit" >/dev/null 2>&1
} }
service_is_active() { stop_band_service() {
local unit="$1"
systemctl_run is-active --quiet "$unit"
}
remember_service_unit() {
local unit="$1"
local existing
for existing in "${CURRENT_SERVICE_UNITS[@]}"; do
if [[ "$existing" == "$unit" ]]; then
return 0
fi
done
CURRENT_SERVICE_UNITS+=("$unit")
}
stop_iio_band_service() {
local band="$1" local band="$1"
local unit="${SERVICE_UNIT[$band]:-}" local unit="${SERVICE_UNIT[$band]:-}"
CURRENT_SERVICE_UNITS=()
if [[ -z "$unit" ]]; then if [[ -z "$unit" ]]; then
log "Для band=$band не найден service unit" log "Для band=$band не найден service unit"
return 0 return 0
fi fi
if ! service_exists "$unit"; then if ! service_exists "$unit"; then
log "Service unit $unit не установлен, пропускаю" log "Service unit $unit не установлен, пропускаю stop/start"
return 0 return 0
fi fi
if service_is_active "$unit"; then log "Останавливаю service $unit перед записью band=$band"
remember_service_unit "$unit"
fi
log "Останавливаю service $unit перед записью band=$band backend=iio"
systemctl_run stop "$unit" systemctl_run stop "$unit"
} CURRENT_SERVICE_UNIT="$unit"
stop_hackrf_band_services() {
local band="$1"
local serial="${SERIAL[$band]:-}"
local other_band unit other_backend other_serial
if [[ -z "$serial" ]]; then
log "Для band=$band пустой serial, пропускаю stop/start сервисов"
return 0
fi
CURRENT_SERVICE_UNITS=()
for other_band in "${!SERVICE_UNIT[@]}"; do
unit="${SERVICE_UNIT[$other_band]:-}"
other_backend="${BACKEND[$other_band]:-hackrf}"
other_serial="${SERIAL[$other_band]:-}"
if [[ "$other_backend" != "hackrf" || -z "$unit" || -z "$other_serial" || "$other_serial" != "$serial" ]]; then
continue
fi
if ! service_exists "$unit"; then
log "Service unit $unit не установлен, пропускаю"
continue
fi
if service_is_active "$unit"; then
remember_service_unit "$unit"
fi
log "Останавливаю service $unit перед записью band=$band"
systemctl_run stop "$unit"
done
}
stop_band_service() {
local band="$1"
local backend="${BACKEND[$band]:-hackrf}"
case "$backend" in
hackrf)
stop_hackrf_band_services "$band"
;;
iio)
stop_iio_band_service "$band"
;;
*)
log "Неизвестный backend=$backend для band=$band"
return 1
;;
esac
} }
start_current_service() { start_current_service() {
local unit if [[ -z "$CURRENT_SERVICE_UNIT" ]]; then
if [[ "${#CURRENT_SERVICE_UNITS[@]}" -eq 0 ]]; then
return 0 return 0
fi fi
for unit in "${CURRENT_SERVICE_UNITS[@]}"; do log "Запускаю service $CURRENT_SERVICE_UNIT после записи"
log "Запускаю service $unit после записи" systemctl_run start "$CURRENT_SERVICE_UNIT"
systemctl_run start "$unit" CURRENT_SERVICE_UNIT=""
done
CURRENT_SERVICE_UNITS=()
} }
cleanup_capture() { cleanup_capture() {
local unit
if [[ -n "$CURRENT_CAPTURE_PID" ]] && kill -0 "$CURRENT_CAPTURE_PID" 2>/dev/null; then if [[ -n "$CURRENT_CAPTURE_PID" ]] && kill -0 "$CURRENT_CAPTURE_PID" 2>/dev/null; then
log "Останавливаю текущий PID=$CURRENT_CAPTURE_PID при завершении скрипта" log "Останавливаю текущий PID=$CURRENT_CAPTURE_PID при завершении скрипта"
kill -TERM "$CURRENT_CAPTURE_PID" 2>/dev/null || true kill -TERM "$CURRENT_CAPTURE_PID" 2>/dev/null || true
@ -250,12 +146,10 @@ cleanup_capture() {
fi fi
CURRENT_CAPTURE_PID="" CURRENT_CAPTURE_PID=""
if [[ "${#CURRENT_SERVICE_UNITS[@]}" -gt 0 ]]; then if [[ -n "$CURRENT_SERVICE_UNIT" ]]; then
for unit in "${CURRENT_SERVICE_UNITS[@]}"; do log "Восстанавливаю service $CURRENT_SERVICE_UNIT при завершении скрипта"
log "Восстанавливаю service $unit при завершении скрипта" systemctl_run start "$CURRENT_SERVICE_UNIT" || true
systemctl_run start "$unit" || true CURRENT_SERVICE_UNIT=""
done
CURRENT_SERVICE_UNITS=()
fi fi
} }
@ -279,8 +173,6 @@ total_size_bytes() {
} }
ensure_requirements() { ensure_requirements() {
local capture_order band backend
if [[ ! -x "$PYTHON_BIN" ]]; then if [[ ! -x "$PYTHON_BIN" ]]; then
echo "Не найден python: $PYTHON_BIN" >&2 echo "Не найден python: $PYTHON_BIN" >&2
exit 1 exit 1
@ -308,10 +200,10 @@ ensure_requirements() {
mkdir -p "$BASE_DIR" mkdir -p "$BASE_DIR"
mkdir -p "$(dirname "$CAPTURE_LOG_FILE")" mkdir -p "$(dirname "$CAPTURE_LOG_FILE")"
capture_order="$(get_env_setting CAPTURE_ORDER)" if [[ -n "${CAPTURE_ORDER:-}" ]]; then
if [[ -n "$capture_order" ]]; then
ORDER=() ORDER=()
for band in ${capture_order//,/ }; do local band
for band in ${CAPTURE_ORDER//,/ }; do
ORDER+=("$band") ORDER+=("$band")
done done
fi fi
@ -321,132 +213,29 @@ ensure_requirements() {
exit 1 exit 1
fi fi
for band in "${ORDER[@]}"; do
backend="${BACKEND[$band]:-}"
if [[ -z "$backend" ]]; then
echo "Band $band не поддерживается capture_hourly." >&2
exit 1
fi
case "$backend" in
hackrf|iio)
;;
*)
echo "Неизвестный backend=$backend для band=$band" >&2
exit 1
;;
esac
if [[ "$backend" == "iio" ]]; then
if ! command -v iio_attr >/dev/null 2>&1; then
echo "Не найден iio_attr для band=$band" >&2
exit 1
fi
if ! command -v iio_readdev >/dev/null 2>&1; then
echo "Не найден iio_readdev для band=$band" >&2
exit 1
fi
fi
done
log "Рабочая директория: $SCRIPT_DIR" log "Рабочая директория: $SCRIPT_DIR"
log "BASE_DIR: $BASE_DIR" log "BASE_DIR: $BASE_DIR"
log "ORDER: ${ORDER[*]}" log "ORDER: ${ORDER[*]}"
log "RUN_ONCE: $RUN_ONCE" log "RUN_ONCE: $RUN_ONCE"
} }
run_one_freq() { run_one_freq() {
local band="$1" local band="$1"
local backend="${BACKEND[$band]}"
local serial="${SERIAL[$band]}" local serial="${SERIAL[$band]}"
local freq="${FREQ_HZ[$band]}" local freq="${FREQ_HZ[$band]}"
local ts out_dir log_file pid
local samp_rate split_size delay if [[ -z "$serial" ]]; then
local rf_gain if_gain bb_gain log "Для band=$band пустой serial, пропускаю"
local iio_uri iio_device iio_phy_device iio_i_channel iio_q_channel
local iio_lo_channel iio_port_select iio_gain_mode iio_hardwaregain
local iio_timeout_ms iio_settle iio_bandwidth iio_samples_per_read
local backend_desc
local -a cmd
if [[ "$backend" == "hackrf" && -z "$serial" ]]; then
log "Для band=$band backend=hackrf, но serial пустой, пропускаю"
return 0 return 0
fi fi
local ts out_dir log_file
ts="$(date '+%F_%H-%M-%S')" ts="$(date '+%F_%H-%M-%S')"
out_dir="$BASE_DIR/$band/$ts" out_dir="$BASE_DIR/$band/$ts"
log_file="$out_dir/run.log" log_file="$out_dir/run.log"
samp_rate="$(get_band_setting CAPTURE_SAMP_RATE "$band" "$DEFAULT_SAMP_RATE")" log "Старт band=$band serial=$serial freq=$freq dir=$out_dir"
split_size="$(get_band_setting CAPTURE_SPLIT_SIZE "$band" "$DEFAULT_SPLIT_SIZE")"
delay="$(get_band_setting CAPTURE_DELAY "$band" "$DEFAULT_DELAY")"
cmd=(
"$PYTHON_BIN"
"$SCRIPT_PATH"
"--backend" "$backend"
"--freq" "$freq"
"--save-dir" "$out_dir"
"--file-tag" "${band}_"
"--samp-rate" "$samp_rate"
"--split-size" "$split_size"
"--delay" "$delay"
)
if [[ "$backend" == "hackrf" ]]; then
rf_gain="$(get_band_setting CAPTURE_RF_GAIN "$band" "$DEFAULT_RF_GAIN")"
if_gain="$(get_band_setting CAPTURE_IF_GAIN "$band" "$DEFAULT_IF_GAIN")"
bb_gain="$(get_band_setting CAPTURE_BB_GAIN "$band" "$DEFAULT_BB_GAIN")"
cmd+=(
"--serial" "$serial"
"--rf-gain" "$rf_gain"
"--if-gain" "$if_gain"
"--bb-gain" "$bb_gain"
)
backend_desc="serial=$serial"
else
iio_uri="$(get_band_setting CAPTURE_IIO_URI "$band" "ip:192.168.2.1")"
iio_device="$(get_band_setting CAPTURE_IIO_DEVICE "$band" "cf-ad9361-lpc")"
iio_phy_device="$(get_band_setting CAPTURE_IIO_PHY_DEVICE "$band" "ad9361-phy")"
iio_i_channel="$(get_band_setting CAPTURE_IIO_I_CHANNEL "$band" "voltage0")"
iio_q_channel="$(get_band_setting CAPTURE_IIO_Q_CHANNEL "$band" "voltage1")"
iio_lo_channel="$(get_band_setting CAPTURE_IIO_LO_CHANNEL "$band" "altvoltage0")"
iio_port_select="$(get_band_setting CAPTURE_IIO_PORT_SELECT "$band" "A_BALANCED")"
iio_gain_mode="$(get_band_setting CAPTURE_IIO_GAIN_MODE "$band" "slow_attack")"
iio_hardwaregain="$(get_band_setting CAPTURE_IIO_HARDWAREGAIN "$band")"
iio_timeout_ms="$(get_band_setting CAPTURE_IIO_TIMEOUT_MS "$band" "4000")"
iio_settle="$(get_band_setting CAPTURE_SETTLE "$band" "0.12")"
iio_bandwidth="$(get_band_setting CAPTURE_IIO_BANDWIDTH "$band")"
iio_samples_per_read="$(get_band_setting CAPTURE_IIO_SAMPLES_PER_READ "$band")"
cmd+=(
"--iio-uri" "$iio_uri"
"--iio-device" "$iio_device"
"--iio-phy-device" "$iio_phy_device"
"--iio-i-channel" "$iio_i_channel"
"--iio-q-channel" "$iio_q_channel"
"--iio-lo-channel" "$iio_lo_channel"
"--iio-port-select" "$iio_port_select"
"--iio-gain-mode" "$iio_gain_mode"
"--timeout-ms" "$iio_timeout_ms"
"--settle" "$iio_settle"
)
if [[ -n "$iio_hardwaregain" ]]; then
cmd+=("--iio-hardwaregain" "$iio_hardwaregain")
fi
if [[ -n "$iio_bandwidth" ]]; then
cmd+=("--bandwidth" "$iio_bandwidth")
fi
if [[ -n "$iio_samples_per_read" ]]; then
cmd+=("--samples-per-read" "$iio_samples_per_read")
fi
backend_desc="uri=$iio_uri"
fi
log "Старт band=$band backend=$backend $backend_desc freq=$freq dir=$out_dir"
if ! mkdir -p "$out_dir"; then if ! mkdir -p "$out_dir"; then
log "Не удалось создать каталог $out_dir" log "Не удалось создать каталог $out_dir"
return 1 return 1
@ -454,8 +243,20 @@ run_one_freq() {
stop_band_service "$band" stop_band_service "$band"
"${cmd[@]}" >"$log_file" 2>&1 & "$PYTHON_BIN" "$SCRIPT_PATH" \
pid=$! --serial "$serial" \
--freq "$freq" \
--save-dir "$out_dir" \
--file-tag "${band}_" \
--samp-rate "$SAMP_RATE" \
--split-size "$SPLIT_SIZE" \
--delay "$DELAY" \
--rf-gain "$RF_GAIN" \
--if-gain "$IF_GAIN" \
--bb-gain "$BB_GAIN" \
>"$log_file" 2>&1 &
local pid=$!
CURRENT_CAPTURE_PID="$pid" CURRENT_CAPTURE_PID="$pid"
log "PID=$pid" log "PID=$pid"
@ -465,7 +266,7 @@ run_one_freq() {
cur_total_size="$(total_size_bytes)" cur_total_size="$(total_size_bytes)"
if (( cur_total_size >= TOTAL_LIMIT_BYTES )); then if (( cur_total_size >= TOTAL_LIMIT_BYTES )); then
log "Достигнут общий лимит ${lim_all} GiB. Останавливаю PID=$pid" log "Достигнут общий лимит $lim_all GiB. Останавливаю PID=$pid"
kill -TERM "$pid" 2>/dev/null || true kill -TERM "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true
CURRENT_CAPTURE_PID="" CURRENT_CAPTURE_PID=""
@ -474,7 +275,7 @@ run_one_freq() {
fi fi
if (( cur_dir_size >= PER_FREQ_LIMIT_BYTES )); then if (( cur_dir_size >= PER_FREQ_LIMIT_BYTES )); then
log "Для band=$band достигнут лимит 1 GiB. Останавливаю PID=$pid" log "Для band=$band достигнут лимит 3 GiB. Останавливаю PID=$pid"
kill -TERM "$pid" 2>/dev/null || true kill -TERM "$pid" 2>/dev/null || true
for _ in {1..10}; do for _ in {1..10}; do
@ -498,18 +299,17 @@ run_one_freq() {
CURRENT_CAPTURE_PID="" CURRENT_CAPTURE_PID=""
start_current_service start_current_service
log "Завершен band=$band backend=$backend, размер=$(du -sh "$out_dir" | awk '{print $1}')" log "Завершен band=$band, размер=$(du -sh "$out_dir" | awk '{print $1}')"
return 0 return 0
} }
main_loop() { main_loop() {
local total_before cycle_start elapsed sleep_left rc
while true; do while true; do
local total_before cycle_start elapsed sleep_left
total_before="$(total_size_bytes)" total_before="$(total_size_bytes)"
if (( total_before >= TOTAL_LIMIT_BYTES )); then if (( total_before >= TOTAL_LIMIT_BYTES )); then
log "Общий размер уже >= ${lim_all} GiB, выхожу" log "Общий размер уже >= GiB, выхожу"
break break
fi fi
@ -527,9 +327,10 @@ main_loop() {
if [[ $rc -eq 2 ]]; then if [[ $rc -eq 2 ]]; then
log "Остановка по общему лимиту" log "Остановка по общему лимиту"
return 0 return 0
else
log "Ошибка записи band=$band rc=$rc"
return "$rc"
fi fi
log "Ошибка записи band=$band rc=$rc"
return "$rc"
} }
done done
@ -542,7 +343,7 @@ main_loop() {
sleep_left=$(( CYCLE_SECONDS - elapsed )) sleep_left=$(( CYCLE_SECONDS - elapsed ))
if (( sleep_left > 0 )); then if (( sleep_left > 0 )); then
log "Цикл занял ${elapsed} сек, жду ${sleep_left} сек до следующего запуска" log "Цикл занял ${elapsed} сек, жду ${sleep_left} сек до следующего часа"
sleep "$sleep_left" sleep "$sleep_left"
else else
log "Цикл занял ${elapsed} сек, паузы нет" log "Цикл занял ${elapsed} сек, паузы нет"

@ -25,11 +25,6 @@ SDR_UNITS=(
) )
CONFIGURED_SDR_UNITS=() CONFIGURED_SDR_UNITS=()
IIO_TOOL_BINS=(
/usr/bin/iio_attr
/usr/bin/iio_readdev
/usr/bin/iio_info
)
log() { log() {
printf '[install_all] %s\n' "$*" printf '[install_all] %s\n' "$*"
@ -182,26 +177,10 @@ install_host_non_python_deps() {
libusb-1.0-0 \ libusb-1.0-0 \
libusb-1.0-0-dev \ libusb-1.0-0-dev \
hackrf \ hackrf \
libiio-utils \
gnuradio \ gnuradio \
gr-osmosdr gr-osmosdr
} }
link_host_sdr_tools_into_venv() {
local venv_path="$1"
local tool_path tool_name
for tool_path in "${IIO_TOOL_BINS[@]}"; do
if [[ ! -x "$tool_path" ]]; then
log "Skipping missing SDR host tool: ${tool_path}"
continue
fi
tool_name="$(basename "$tool_path")"
ln -sf "$tool_path" "$venv_path/bin/$tool_name"
done
}
setup_sdr_python_env() { setup_sdr_python_env() {
log "Setting up SDR python environment" log "Setting up SDR python environment"
local venv_path="${PROJECT_ROOT}/.venv-sdr" local venv_path="${PROJECT_ROOT}/.venv-sdr"
@ -212,7 +191,6 @@ setup_sdr_python_env() {
"$venv_path/bin/pip" install --upgrade pip "$venv_path/bin/pip" install --upgrade pip
"$venv_path/bin/pip" install -r "${PROJECT_ROOT}/deploy/requirements/sdr_host.txt" "$venv_path/bin/pip" install -r "${PROJECT_ROOT}/deploy/requirements/sdr_host.txt"
link_host_sdr_tools_into_venv "$venv_path"
chown -R "${RUN_USER}:${RUN_GROUP}" "$venv_path" chown -R "${RUN_USER}:${RUN_GROUP}" "$venv_path"
} }

@ -28,7 +28,7 @@ run_as_root() {
preflight() { preflight() {
[[ -f "${PROJECT_ROOT}/deploy/requirements/nn_gpu_pinned.txt" ]] || die "Missing deploy/requirements/nn_gpu_pinned.txt" [[ -f "${PROJECT_ROOT}/deploy/requirements/nn_gpu_pinned.txt" ]] || die "Missing deploy/requirements/nn_gpu_pinned.txt"
[[ -f "${PROJECT_ROOT}/train_scripts/requirements-train.txt" ]] || die "Missing train_scripts/requirements-train.txt" [[ -f "${PROJECT_ROOT}/requirements-train.txt" ]] || die "Missing train_scripts/requirements-train.txt"
[[ -f "${PROJECT_ROOT}/torchsig/pyproject.toml" ]] || die "Missing local torchsig package" [[ -f "${PROJECT_ROOT}/torchsig/pyproject.toml" ]] || die "Missing local torchsig package"
command -v "${PYTHON_BIN}" >/dev/null 2>&1 || die "${PYTHON_BIN} not found" command -v "${PYTHON_BIN}" >/dev/null 2>&1 || die "${PYTHON_BIN} not found"
} }

@ -9,7 +9,7 @@ import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple from typing import Dict, List, Optional, Tuple
try: try:
import numpy as np import numpy as np
@ -26,13 +26,12 @@ except Exception as exc:
sys.exit(1) sys.exit(1)
EPS = 1e-20 EPS = 1e-20
IIO_MIN_SAMPLE_RATE = 2083333
@dataclass @dataclass
class Target: class Target:
label: str label: str
device: str serial: str
freq_hz: float freq_hz: float
source: str source: str
@ -40,8 +39,8 @@ class Target:
@dataclass @dataclass
class Row: class Row:
label: str label: str
device: str serial: str
index: Optional[str] = None index: Optional[int] = None
freq_hz: float = 0.0 freq_hz: float = 0.0
status: str = "INIT" status: str = "INIT"
rms: Optional[float] = None rms: Optional[float] = None
@ -52,28 +51,13 @@ class Row:
error: str = "" error: str = ""
def label_sort_key(label: str) -> Tuple[int, float | str]: class ProbeTop(gr.top_block):
try: def __init__(self, index: int, freq_hz: float, sample_rate: float, vec_len: int,
return (0, float(label)) gain: float, if_gain: float, bb_gain: float):
except ValueError:
return (1, label)
class HackRfProbeTop(gr.top_block):
def __init__(
self,
serial: str,
freq_hz: float,
sample_rate: float,
vec_len: int,
gain: float,
if_gain: float,
bb_gain: float,
):
super().__init__("hackrf_energy_probe") super().__init__("hackrf_energy_probe")
self.probe = blocks.probe_signal_vc(vec_len) self.probe = blocks.probe_signal_vc(vec_len)
self.stream_to_vec = blocks.stream_to_vector(gr.sizeof_gr_complex * 1, vec_len) self.stream_to_vec = blocks.stream_to_vector(gr.sizeof_gr_complex * 1, vec_len)
self.src = osmosdr.source(args=f"numchan=1 hackrf={serial}") self.src = osmosdr.source(args=f"numchan=1 hackrf={index}")
self.src.set_time_unknown_pps(osmosdr.time_spec_t()) self.src.set_time_unknown_pps(osmosdr.time_spec_t())
self.src.set_sample_rate(sample_rate) self.src.set_sample_rate(sample_rate)
self.src.set_center_freq(freq_hz, 0) self.src.set_center_freq(freq_hz, 0)
@ -84,14 +68,14 @@ class HackRfProbeTop(gr.top_block):
for fn, val in (("set_gain", gain), ("set_if_gain", if_gain), ("set_bb_gain", bb_gain)): for fn, val in (("set_gain", gain), ("set_if_gain", if_gain), ("set_bb_gain", bb_gain)):
try: try:
getattr(self.src, fn)(val, 0) getattr(self.src, fn)(val, 0)
except Exception as exc: except Exception:
raise RuntimeError(f"failed to set {fn}={val}: {exc}") from exc raise Exception("не ставится усиление")
try: try:
self.src.set_bandwidth(0, 0) self.src.set_bandwidth(0, 0)
except Exception: except Exception:
pass pass
try: try:
self.src.set_antenna("", 0) self.src.set_antenna('', 0)
except Exception: except Exception:
pass pass
self.connect((self.src, 0), (self.stream_to_vec, 0)) self.connect((self.src, 0), (self.stream_to_vec, 0))
@ -101,22 +85,15 @@ class HackRfProbeTop(gr.top_block):
arr = np.asarray(self.probe.level(), dtype=np.complex64) arr = np.asarray(self.probe.level(), dtype=np.complex64)
if arr.size == 0: if arr.size == 0:
raise RuntimeError("no samples") raise RuntimeError("no samples")
power_lin = float(np.mean(arr.real * arr.real + arr.imag * arr.imag)) p = float(np.mean(arr.real * arr.real + arr.imag * arr.imag))
rms = math.sqrt(max(power_lin, 0.0)) rms = math.sqrt(max(p, 0.0))
dbfs = 10.0 * math.log10(max(power_lin, EPS)) dbfs = 10.0 * math.log10(max(p, EPS))
return rms, power_lin, dbfs, int(arr.size) return rms, p, dbfs, int(arr.size)
class HackRfWorker(threading.Thread): class Worker(threading.Thread):
def __init__( def __init__(self, target: Target, serial_to_index: Dict[str, int], rows: Dict[str, Row],
self, lock: threading.Lock, stop_event: threading.Event, args: argparse.Namespace):
target: Target,
serial_to_index: Dict[str, int],
rows: Dict[str, Row],
lock: threading.Lock,
stop_event: threading.Event,
args: argparse.Namespace,
):
super().__init__(daemon=True) super().__init__(daemon=True)
self.target = target self.target = target
self.serial_to_index = serial_to_index self.serial_to_index = serial_to_index
@ -124,52 +101,45 @@ class HackRfWorker(threading.Thread):
self.lock = lock self.lock = lock
self.stop_event = stop_event self.stop_event = stop_event
self.args = args self.args = args
self.tb: Optional[HackRfProbeTop] = None self.tb: Optional[ProbeTop] = None
def _set_row(self, **kwargs): def _set_row(self, **kwargs):
with self.lock: with self.lock:
row = self.rows[self.target.label] row = self.rows[self.target.label]
for key, value in kwargs.items(): for k, v in kwargs.items():
setattr(row, key, value) setattr(row, k, v)
row.updated_at = time.time() row.updated_at = time.time()
def _open(self) -> bool: def _open(self) -> bool:
if self.target.device not in self.serial_to_index: idx = self.serial_to_index.get(self.target.serial)
if idx is None:
self._set_row(status="NOT_FOUND", error="serial not in hackrf_info", index=None) self._set_row(status="NOT_FOUND", error="serial not in hackrf_info", index=None)
return False return False
self._set_row(index=idx, status="OPENING", error="", freq_hz=self.target.freq_hz)
idx = self.serial_to_index[self.target.device]
self._set_row(index=str(idx), status="OPENING", error="", freq_hz=self.target.freq_hz)
try: try:
self.tb = HackRfProbeTop( self.tb = ProbeTop(
self.target.device, idx, self.target.freq_hz, self.args.sample_rate, self.args.vec_len,
self.target.freq_hz, self.args.gain, self.args.if_gain, self.args.bb_gain
self.args.sample_rate,
self.args.vec_len,
self.args.gain,
self.args.if_gain,
self.args.bb_gain,
) )
self.tb.start() self.tb.start()
time.sleep(0.15) time.sleep(0.15)
self._set_row(status="OK", error="") self._set_row(status="OK", error="")
return True return True
except Exception as exc: except Exception as exc:
message = str(exc) msg = str(exc)
status = "BUSY" if ("Resource busy" in message or "-1000" in message) else "ERR" status = "BUSY" if ("Resource busy" in msg or "-1000" in msg) else "ERR"
self._set_row(status=status, error=message) self._set_row(status=status, error=msg)
self.tb = None self.tb = None
return False return False
def _close(self): def _close(self):
if self.tb is None: if self.tb is not None:
return try:
try: self.tb.stop()
self.tb.stop() self.tb.wait()
self.tb.wait() except Exception:
except Exception: pass
pass self.tb = None
self.tb = None
def run(self): def run(self):
time.sleep(self.args.stagger) time.sleep(self.args.stagger)
@ -179,15 +149,8 @@ class HackRfWorker(threading.Thread):
break break
continue continue
try: try:
rms, power_lin, dbfs, samples = self.tb.read_metrics() rms, p, dbfs, n = self.tb.read_metrics()
self._set_row( self._set_row(status="OK", rms=rms, power_lin=p, dbfs=dbfs, samples=n, error="")
status="OK",
rms=rms,
power_lin=power_lin,
dbfs=dbfs,
samples=samples,
error="",
)
except Exception as exc: except Exception as exc:
self._set_row(status="ERR", error=str(exc)) self._set_row(status="ERR", error=str(exc))
self._close() self._close()
@ -199,190 +162,6 @@ class HackRfWorker(threading.Thread):
self._close() self._close()
class IioProbe:
def __init__(self, args: argparse.Namespace):
self.args = args
self._static_signature: Optional[Tuple[float, float, str, Optional[float], str]] = None
self._last_freq_hz: Optional[int] = None
self._input_channels = [args.iio_i_channel]
if args.iio_q_channel not in self._input_channels:
self._input_channels.append(args.iio_q_channel)
def _run(self, cmd: Sequence[str], binary: bool = False) -> str | bytes:
proc = subprocess.run(
list(cmd),
capture_output=True,
text=not binary,
)
if proc.returncode != 0:
stderr = proc.stderr if binary else (proc.stderr or "")
stdout = "" if binary else (proc.stdout or "")
details = stderr.strip() or stdout.strip() or f"exit code {proc.returncode}"
raise RuntimeError(details)
return proc.stdout
def _set_input_attr(self, channel: str, attr: str, value: str):
self._run(
[
"iio_attr",
"-u",
self.args.uri,
"-q",
"-i",
"-c",
self.args.iio_phy_device,
channel,
attr,
value,
]
)
def _set_output_attr(self, channel: str, attr: str, value: str):
self._run(
[
"iio_attr",
"-u",
self.args.uri,
"-q",
"-o",
"-c",
self.args.iio_phy_device,
channel,
attr,
value,
]
)
def ensure_configured(self):
sample_rate = None
if self.args.sample_rate is not None:
sample_rate = int(round(max(float(self.args.sample_rate), IIO_MIN_SAMPLE_RATE)))
bandwidth = None
if self.args.bandwidth is not None:
bandwidth = int(round(max(float(self.args.bandwidth), 200000.0)))
signature = (
None if sample_rate is None else float(sample_rate),
None if bandwidth is None else float(bandwidth),
self.args.iio_gain_mode,
self.args.iio_hardwaregain,
self.args.iio_port_select,
)
if signature == self._static_signature:
return
for channel in self._input_channels:
if sample_rate is not None:
self._set_input_attr(channel, "sampling_frequency", str(sample_rate))
if bandwidth is not None:
self._set_input_attr(channel, "rf_bandwidth", str(bandwidth))
if self.args.iio_port_select:
self._set_input_attr(channel, "rf_port_select", self.args.iio_port_select)
if self.args.iio_gain_mode:
self._set_input_attr(channel, "gain_control_mode", self.args.iio_gain_mode)
if self.args.iio_gain_mode == "manual" and self.args.iio_hardwaregain is not None:
self._set_input_attr(channel, "hardwaregain", f"{self.args.iio_hardwaregain:.6f}")
self._static_signature = signature
def tune(self, freq_hz: float):
target = int(round(freq_hz))
if self._last_freq_hz == target:
return
self._set_output_attr(self.args.iio_lo_channel, "frequency", str(target))
self._last_freq_hz = target
if self.args.settle > 0:
time.sleep(self.args.settle)
def read_metrics(self) -> Tuple[float, float, float, int]:
cmd = [
"iio_readdev",
"-u",
self.args.uri,
"-T",
str(int(self.args.timeout_ms)),
"-b",
str(max(4, int(self.args.vec_len))),
"-s",
str(max(4, int(self.args.vec_len))),
self.args.iio_device,
self.args.iio_i_channel,
self.args.iio_q_channel,
]
raw = self._run(cmd, binary=True)
values = np.frombuffer(raw, dtype="<i2")
if values.size == 0:
raise RuntimeError("no samples")
if values.size % 2 != 0:
raise RuntimeError(f"unexpected IQ sample payload: {values.size} int16 values")
iq = values.reshape(-1, 2).astype(np.float32, copy=False)
i = iq[:, 0]
q = iq[:, 1]
power_lin = float(np.mean(i * i + q * q))
rms = math.sqrt(max(power_lin, 0.0))
dbfs = 10.0 * math.log10(max(power_lin, EPS))
return rms, power_lin, dbfs, int(iq.shape[0])
class IioWorker(threading.Thread):
def __init__(
self,
targets: List[Target],
rows: Dict[str, Row],
lock: threading.Lock,
stop_event: threading.Event,
args: argparse.Namespace,
):
super().__init__(daemon=True)
self.targets = targets
self.rows = rows
self.lock = lock
self.stop_event = stop_event
self.args = args
self.probe = IioProbe(args)
def _set_row(self, label: str, **kwargs):
with self.lock:
row = self.rows[label]
for key, value in kwargs.items():
setattr(row, key, value)
row.updated_at = time.time()
def run(self):
while not self.stop_event.is_set():
for target in self.targets:
if self.stop_event.is_set():
break
self._set_row(
target.label,
index="iio",
status="OPENING",
error="",
freq_hz=target.freq_hz,
)
try:
self.probe.ensure_configured()
self.probe.tune(target.freq_hz)
rms, power_lin, dbfs, samples = self.probe.read_metrics()
self._set_row(
target.label,
status="OK",
rms=rms,
power_lin=power_lin,
dbfs=dbfs,
samples=samples,
error="",
)
except Exception as exc:
self._set_row(target.label, status="ERR", error=str(exc))
if self.stop_event.wait(self.args.reopen_delay):
return
continue
if self.stop_event.wait(self.args.interval):
return
def parse_env(path: Path) -> Dict[str, str]: def parse_env(path: Path) -> Dict[str, str]:
out: Dict[str, str] = {} out: Dict[str, str] = {}
if not path.exists(): if not path.exists():
@ -391,198 +170,122 @@ def parse_env(path: Path) -> Dict[str, str]:
line = raw.strip() line = raw.strip()
if not line or line.startswith("#") or "=" not in line: if not line or line.startswith("#") or "=" not in line:
continue continue
key, value = line.split("=", 1) k, v = line.split("=", 1)
out[key.strip()] = value.strip().strip('"').strip("'") out[k.strip()] = v.strip().strip('"').strip("'")
return out return out
def collect_hackrf_targets( def collect_targets(env: Dict[str, str], only: Optional[set], override_freq_mhz: Optional[float]) -> List[Target]:
env: Dict[str, str],
only: Optional[set],
override_freq_mhz: Optional[float],
) -> List[Target]:
targets: List[Target] = [] targets: List[Target] = []
for key, value in env.items(): for k, v in env.items():
match = re.fullmatch(r"hack_(\d+)", key) m = re.fullmatch(r"hack_(\d+)", k)
if match: if m:
label = match.group(1) label = m.group(1)
else: else:
match = re.fullmatch(r"HACKID_(\d+)", key) m = re.fullmatch(r"HACKID_(\d+)", k)
if not match: if not m:
continue continue
label = match.group(1) label = m.group(1)
if only and label not in only: if only and label not in only:
continue continue
mhz = override_freq_mhz if override_freq_mhz is not None else float(label) mhz = override_freq_mhz if override_freq_mhz is not None else float(label)
targets.append(Target(label=label, device=value.lower(), freq_hz=mhz * 1e6, source=key)) targets.append(Target(label=label, serial=v.lower(), freq_hz=mhz * 1e6, source=k))
unique: Dict[str, Target] = {}
for target in sorted(targets, key=lambda item: (label_sort_key(item.label), 0 if item.source.startswith("hack_") else 1)):
unique.setdefault(target.label, target)
return [unique[key] for key in sorted(unique, key=label_sort_key)]
def collect_iio_targets(
env: Dict[str, str],
only: Optional[set],
override_freq_mhz: Optional[float],
uri: str,
) -> List[Target]:
if only:
labels = set(only)
else:
labels = set()
for key in env:
for pattern in (r"c_freq_(\d+)", r"hack_(\d+)", r"HACKID_(\d+)"):
match = re.fullmatch(pattern, key)
if match:
labels.add(match.group(1))
break
if not labels and override_freq_mhz is not None:
labels.add(str(int(override_freq_mhz) if float(override_freq_mhz).is_integer() else override_freq_mhz))
targets: List[Target] = [] uniq: Dict[str, Target] = {}
for label in sorted(labels, key=label_sort_key): for t in sorted(targets, key=lambda x: (int(x.label), 0 if x.source.startswith("hack_") else 1)):
raw_freq = env.get(f"c_freq_{label}", label) uniq.setdefault(t.label, t)
try: return [uniq[k] for k in sorted(uniq, key=lambda x: int(x))]
mhz = override_freq_mhz if override_freq_mhz is not None else float(raw_freq)
except ValueError as exc:
raise ValueError(f"cannot resolve frequency for target {label!r}") from exc
targets.append(Target(label=label, device=uri, freq_hz=mhz * 1e6, source="iio"))
return targets
def parse_hackrf_info() -> Dict[str, int]: def parse_hackrf_info() -> Dict[str, int]:
try: try:
proc = subprocess.run(["hackrf_info"], capture_output=True, text=True, timeout=15) proc = subprocess.run(["hackrf_info"], capture_output=True, text=True, timeout=15)
except FileNotFoundError as exc: except FileNotFoundError:
raise RuntimeError("hackrf_info not found") from exc raise RuntimeError("hackrf_info not found")
except subprocess.TimeoutExpired as exc: except subprocess.TimeoutExpired:
raise RuntimeError("hackrf_info timeout") from exc raise RuntimeError("hackrf_info timeout")
text = (proc.stdout or "") + "\n" + (proc.stderr or "") text = (proc.stdout or "") + "\n" + (proc.stderr or "")
out: Dict[str, int] = {} out: Dict[str, int] = {}
cur_idx: Optional[int] = None cur_idx: Optional[int] = None
for line in text.splitlines(): for line in text.splitlines():
match = re.search(r"^Index:\s*(\d+)", line) m = re.search(r"^Index:\s*(\d+)", line)
if match: if m:
cur_idx = int(match.group(1)) cur_idx = int(m.group(1))
continue continue
match = re.search(r"^Serial number:\s*([0-9a-fA-F]+)", line) m = re.search(r"^Serial number:\s*([0-9a-fA-F]+)", line)
if match and cur_idx is not None: if m and cur_idx is not None:
out[match.group(1).lower()] = cur_idx out[m.group(1).lower()] = cur_idx
if not out: if not out:
raise RuntimeError("no devices parsed from hackrf_info") raise RuntimeError("no devices parsed from hackrf_info")
return out return out
def fmt(value: Optional[float], spec: str) -> str: def fmt(v: Optional[float], spec: str) -> str:
return "-" if value is None else format(value, spec) return "-" if v is None else format(v, spec)
def render(rows: Dict[str, Row], started_at: float, env_path: Path, summary: str): def render(rows: Dict[str, Row], started_at: float, env_path: Path, serial_to_index: Dict[str, int]):
now = time.time() now = time.time()
print("\x1b[2J\x1b[H", end="") print("\x1b[2J\x1b[H", end="")
print("Read Energy Monitor (relative power: RMS / linear / dBFS, not calibrated dBm)") print("HackRF Energy Monitor (relative power: RMS / linear / dBFS, not calibrated dBm)")
print(f"env: {env_path} | {summary} | uptime: {int(now - started_at)}s | {time.strftime('%Y-%m-%d %H:%M:%S')}") print(f"env: {env_path} | discovered: {len(serial_to_index)} | uptime: {int(now-started_at)}s | {time.strftime('%Y-%m-%d %H:%M:%S')}")
print() print()
header = f"{'band':>5} {'idx':>4} {'freq':>8} {'status':>9} {'rms':>10} {'power':>12} {'dBFS':>9} {'N':>6} {'age':>5} {'device':>18} error" header = f"{'band':>5} {'idx':>3} {'freq':>7} {'status':>9} {'rms':>10} {'power':>12} {'dBFS':>9} {'N':>5} {'age':>5} {'serial':>12} error"
print(header) print(header)
print("-" * len(header)) print('-' * len(header))
for label in sorted(rows, key=label_sort_key): for label in sorted(rows, key=lambda x: int(x)):
row = rows[label] r = rows[label]
idx = "-" if row.index is None else str(row.index) idx = '-' if r.index is None else str(r.index)
age = "-" if row.updated_at <= 0 else f"{(now - row.updated_at):.1f}" age = '-' if r.updated_at <= 0 else f"{(now-r.updated_at):.1f}"
err = row.error or "" err = (r.error or "")
if len(err) > 64: if len(err) > 64:
err = err[:61] + "..." err = err[:61] + '...'
device = row.device[-18:]
print( print(
f"{row.label:>5} {idx:>4} {row.freq_hz / 1e6:>8.1f} {row.status:>9} " f"{r.label:>5} {idx:>3} {r.freq_hz/1e6:>7.1f} {r.status:>9} "
f"{fmt(row.rms, '.6f'):>10} {fmt(row.power_lin, '.8f'):>12} {fmt(row.dbfs, '.2f'):>9} " f"{fmt(r.rms, '.6f'):>10} {fmt(r.power_lin, '.8f'):>12} {fmt(r.dbfs, '.2f'):>9} "
f"{row.samples:>6} {age:>5} {device:>18} {err}" f"{r.samples:>5} {age:>5} {r.serial[-12:]:>12} {err}"
) )
print() print()
print("Ctrl+C to stop. Use --backend iio --uri ip:192.168.2.1 --only 2400 for Neptune.") print("Ctrl+C to stop. Use --only 2400,5200 or --freq-mhz 2450 to limit/override tuning.")
sys.stdout.flush() sys.stdout.flush()
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Realtime SDR relative energy monitor") p = argparse.ArgumentParser(description="Realtime HackRF relative energy monitor")
parser.add_argument("--backend", choices=("hackrf", "iio"), default="hackrf", help="SDR backend") p.add_argument("--env", default=".env", help="Path to .env (default: repo_root/.env)")
parser.add_argument("--env", default=".env", help="Path to .env (default: repo_root/.env)") p.add_argument("--only", default="", help="Comma-separated labels (e.g. 2400,5200)")
parser.add_argument("--only", default="", help="Comma-separated labels (e.g. 2400,5200)") p.add_argument("--freq-mhz", type=float, default=None, help="Override tuning frequency for all devices (MHz)")
parser.add_argument("--freq-mhz", type=float, default=None, help="Override tuning frequency for all targets (MHz)") p.add_argument("--sample-rate", type=float, default=2e6, help="Sample rate in Hz (HackRF min ~2e6)")
parser.add_argument( p.add_argument("--vec-len", type=int, default=4096, help="Probe vector length")
"--sample-rate", p.add_argument("--interval", type=float, default=0.5, help="Per-device read interval (s)")
type=float, p.add_argument("--refresh", type=float, default=0.5, help="Console refresh interval (s)")
default=None, p.add_argument("--reopen-delay", type=float, default=1.0, help="Retry delay after BUSY/ERR (s)")
help="Sample rate in Hz (HackRF default: 2e6, IIO default: keep current device setting)", p.add_argument("--gain", type=float, default=0.0, help="General gain")
) p.add_argument("--if-gain", type=float, default=16.0, help="IF gain")
parser.add_argument("--bandwidth", type=float, default=None, help="RF bandwidth in Hz (default: keep device setting)") p.add_argument("--bb-gain", type=float, default=16.0, help="BB gain")
parser.add_argument("--vec-len", type=int, default=4096, help="Probe vector length / capture size") return p
parser.add_argument("--interval", type=float, default=0.5, help="Per-target read interval (s)")
parser.add_argument("--refresh", type=float, default=0.5, help="Console refresh interval (s)")
parser.add_argument("--reopen-delay", type=float, default=1.0, help="Retry delay after ERR/BUSY (s)")
parser.add_argument("--gain", type=float, default=0.0, help="General gain for HackRF")
parser.add_argument("--if-gain", type=float, default=16.0, help="IF gain for HackRF")
parser.add_argument("--bb-gain", type=float, default=16.0, help="BB gain for HackRF")
parser.add_argument("--uri", default="ip:192.168.2.1", help="IIO URI, e.g. ip:192.168.2.1")
parser.add_argument("--iio-device", default="cf-ad9361-lpc", help="IIO RX buffer device")
parser.add_argument("--iio-phy-device", default="ad9361-phy", help="IIO PHY device")
parser.add_argument("--iio-i-channel", default="voltage0", help="IIO I channel")
parser.add_argument("--iio-q-channel", default="voltage1", help="IIO Q channel")
parser.add_argument("--iio-lo-channel", default="altvoltage0", help="IIO LO channel for RX frequency")
parser.add_argument("--iio-port-select", default="A_BALANCED", help="IIO rf_port_select value")
parser.add_argument(
"--iio-gain-mode",
choices=("manual", "fast_attack", "slow_attack", "hybrid"),
default="slow_attack",
help="IIO gain control mode",
)
parser.add_argument("--iio-hardwaregain", type=float, default=None, help="IIO hardware gain in dB for manual mode")
parser.add_argument("--timeout-ms", type=int, default=4000, help="IIO read timeout in milliseconds")
parser.add_argument("--settle", type=float, default=0.12, help="Wait after retune before reading in IIO mode (s)")
return parser
def main() -> int: def main() -> int:
args = build_parser().parse_args() args = build_parser().parse_args()
only = {item.strip() for item in args.only.split(",") if item.strip()} or None only = {x.strip() for x in args.only.split(',') if x.strip()} or None
env_path = Path(args.env) env_path = Path(args.env)
if not env_path.is_absolute(): if not env_path.is_absolute():
env_path = (Path(__file__).resolve().parent / env_path).resolve() env_path = (Path(__file__).resolve().parent / env_path).resolve()
env = parse_env(env_path) env = parse_env(env_path)
targets = collect_targets(env, only=only, override_freq_mhz=args.freq_mhz)
if not targets:
print(f"No hack_/HACKID_ entries found in {env_path}", file=sys.stderr)
return 2
if args.backend == "hackrf": try:
targets = collect_hackrf_targets(env, only=only, override_freq_mhz=args.freq_mhz) serial_to_index = parse_hackrf_info()
if not targets: except Exception as exc:
print(f"No hack_/HACKID_ entries found in {env_path}", file=sys.stderr) print(f"hackrf discovery failed: {exc}", file=sys.stderr)
return 2 return 3
try:
serial_to_index = parse_hackrf_info()
except Exception as exc:
print(f"hackrf discovery failed: {exc}", file=sys.stderr)
return 3
summary = f"backend=hackrf | discovered={len(serial_to_index)}"
if args.sample_rate is None:
args.sample_rate = 2e6
else:
try:
targets = collect_iio_targets(env, only=only, override_freq_mhz=args.freq_mhz, uri=args.uri)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 2
if not targets:
print(
f"No IIO targets resolved from {env_path}. Use c_freq_* in .env or pass --only / --freq-mhz.",
file=sys.stderr,
)
return 2
summary = f"backend=iio | uri={args.uri} | device={args.iio_device} | targets={len(targets)}"
rows = {target.label: Row(label=target.label, device=target.device, freq_hz=target.freq_hz) for target in targets} rows = {t.label: Row(label=t.label, serial=t.serial, freq_hz=t.freq_hz) for t in targets}
lock = threading.Lock() lock = threading.Lock()
stop_event = threading.Event() stop_event = threading.Event()
@ -592,32 +295,27 @@ def main() -> int:
signal.signal(signal.SIGINT, on_signal) signal.signal(signal.SIGINT, on_signal)
signal.signal(signal.SIGTERM, on_signal) signal.signal(signal.SIGTERM, on_signal)
workers: List[threading.Thread] = [] workers: List[Worker] = []
if args.backend == "hackrf": for i, t in enumerate(targets):
for idx, target in enumerate(targets): wa = argparse.Namespace(**vars(args))
worker_args = argparse.Namespace(**vars(args)) wa.stagger = i * 0.15
worker_args.stagger = idx * 0.15 w = Worker(t, serial_to_index, rows, lock, stop_event, wa)
worker = HackRfWorker(target, serial_to_index, rows, lock, stop_event, worker_args) workers.append(w)
workers.append(worker) w.start()
worker.start()
else:
worker = IioWorker(targets, rows, lock, stop_event, args)
workers.append(worker)
worker.start()
started = time.time() started = time.time()
try: try:
while not stop_event.is_set(): while not stop_event.is_set():
with lock: with lock:
snapshot = {key: Row(**vars(value)) for key, value in rows.items()} snap = {k: Row(**vars(v)) for k, v in rows.items()}
render(snapshot, started, env_path, summary) render(snap, started, env_path, serial_to_index)
stop_event.wait(args.refresh) stop_event.wait(args.refresh)
finally: finally:
stop_event.set() stop_event.set()
for worker in workers: for w in workers:
worker.join(timeout=2) w.join(timeout=2)
return 0 return 0
if __name__ == "__main__": if __name__ == '__main__':
raise SystemExit(main()) raise SystemExit(main())

@ -1,25 +1,13 @@
#!/usr/bin/env python3
"""
Examples:
HackRF:
./.venv-sdr/bin/python read_energy_wide.py \
--backend hackrf \
--serial 0000000000000000a18c63dc2a83b813 \
--sample-rate 20000000 \
--base 6000 \
--roof 5700 \
--step 20
Neptune / Pluto over IIO:
./.venv-sdr/bin/python read_energy_wide.py \
--backend iio \
--uri ip:192.168.2.1 \
--base 2402 \
--roof 2398 \
--step 1
""" """
./.venv-sdr/bin/python read_energy_wide.py \
--serial 0000000000000000a18c63dc2a83b813 \
--sample-rate 20000000 \
--base 6000 \
--roof 5700 \
--step 20
"""
#!/usr/bin/env python3
import argparse import argparse
import math import math
import re import re
@ -28,7 +16,7 @@ import subprocess
import sys import sys
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple from typing import Dict, List, Optional, Tuple
try: try:
import numpy as np import numpy as np
@ -45,7 +33,6 @@ except Exception as exc:
sys.exit(1) sys.exit(1)
EPS = 1e-20 EPS = 1e-20
IIO_MIN_SAMPLE_RATE = 2083333
@dataclass @dataclass
@ -66,10 +53,10 @@ class ScanWindow:
pass_no: int = 0 pass_no: int = 0
class HackRfWideProbe(gr.top_block): class WideProbeTop(gr.top_block):
def __init__( def __init__(
self, self,
serial: str, index: int,
center_freq_hz: float, center_freq_hz: float,
sample_rate: float, sample_rate: float,
vec_len: int, vec_len: int,
@ -80,7 +67,7 @@ class HackRfWideProbe(gr.top_block):
super().__init__("hackrf_energy_wide_probe") super().__init__("hackrf_energy_wide_probe")
self.probe = blocks.probe_signal_vc(vec_len) self.probe = blocks.probe_signal_vc(vec_len)
self.stream_to_vec = blocks.stream_to_vector(gr.sizeof_gr_complex * 1, vec_len) self.stream_to_vec = blocks.stream_to_vector(gr.sizeof_gr_complex * 1, vec_len)
self.src = osmosdr.source(args=f"numchan=1 hackrf={serial}") self.src = osmosdr.source(args=f"numchan=1 hackrf={index}")
self.src.set_time_unknown_pps(osmosdr.time_spec_t()) self.src.set_time_unknown_pps(osmosdr.time_spec_t())
self.src.set_sample_rate(sample_rate) self.src.set_sample_rate(sample_rate)
self.src.set_center_freq(center_freq_hz, 0) self.src.set_center_freq(center_freq_hz, 0)
@ -95,8 +82,8 @@ class HackRfWideProbe(gr.top_block):
for fn, val in (("set_gain", gain), ("set_if_gain", if_gain), ("set_bb_gain", bb_gain)): for fn, val in (("set_gain", gain), ("set_if_gain", if_gain), ("set_bb_gain", bb_gain)):
try: try:
getattr(self.src, fn)(val, 0) getattr(self.src, fn)(val, 0)
except Exception as exc: except Exception:
raise RuntimeError(f"failed to set {fn}={val}: {exc}") from exc pass
try: try:
self.src.set_bandwidth(0, 0) self.src.set_bandwidth(0, 0)
except Exception: except Exception:
@ -108,7 +95,7 @@ class HackRfWideProbe(gr.top_block):
self.connect((self.src, 0), (self.stream_to_vec, 0)) self.connect((self.src, 0), (self.stream_to_vec, 0))
self.connect((self.stream_to_vec, 0), (self.probe, 0)) self.connect((self.stream_to_vec, 0), (self.probe, 0))
def tune(self, freq_hz: float): def tune(self, freq_hz: float) -> None:
self.src.set_center_freq(freq_hz, 0) self.src.set_center_freq(freq_hz, 0)
def read_metrics(self) -> Tuple[float, float, float, int]: def read_metrics(self) -> Tuple[float, float, float, int]:
@ -152,154 +139,374 @@ class HackRfWideProbe(gr.top_block):
return rms, power_lin, dbfs, samples return rms, power_lin, dbfs, samples
class IioWideProbe: def parse_hackrf_info() -> Dict[str, int]:
def __init__(self, args: argparse.Namespace): try:
self.args = args proc = subprocess.run(["hackrf_info"], capture_output=True, text=True, timeout=15)
self._static_signature: Optional[Tuple[Optional[float], Optional[float], str, Optional[float], str]] = None except FileNotFoundError:
self._last_freq_hz: Optional[int] = None raise RuntimeError("hackrf_info not found")
self._input_channels = [args.iio_i_channel] except subprocess.TimeoutExpired:
if args.iio_q_channel not in self._input_channels: raise RuntimeError("hackrf_info timeout")
self._input_channels.append(args.iio_q_channel) text = (proc.stdout or "") + "\n" + (proc.stderr or "")
out: Dict[str, int] = {}
def _run(self, cmd: Sequence[str], binary: bool = False) -> str | bytes: cur_idx: Optional[int] = None
proc = subprocess.run(list(cmd), capture_output=True, text=not binary) for line in text.splitlines():
if proc.returncode != 0: m = re.search(r"^Index:\s*(\d+)", line)
stderr = proc.stderr if binary else (proc.stderr or "") if m:
stdout = "" if binary else (proc.stdout or "") cur_idx = int(m.group(1))
details = stderr.strip() or stdout.strip() or f"exit code {proc.returncode}" continue
raise RuntimeError(details) m = re.search(r"^Serial number:\s*([0-9a-fA-F]+)", line)
return proc.stdout if m and cur_idx is not None:
out[m.group(1).lower()] = cur_idx
def _run_binary(self, cmd: Sequence[str]) -> bytes: if not out:
proc = subprocess.run(list(cmd), capture_output=True) raise RuntimeError("no devices parsed from hackrf_info")
if proc.returncode != 0: return out
details = proc.stderr.decode("utf-8", errors="ignore").strip() or proc.stdout.decode("utf-8", errors="ignore").strip()
raise RuntimeError(details or f"exit code {proc.returncode}")
return proc.stdout def fmt(value: Optional[float], spec: str) -> str:
return "-" if value is None else format(value, spec)
def _read_input_attr(self, channel: str, attr: str) -> str:
out = self._run(
[ def build_windows(base_mhz: float, roof_mhz: float, step_mhz: float) -> List[ScanWindow]:
"iio_attr", if step_mhz <= 0:
"-u", raise ValueError("step must be > 0")
self.args.uri, if base_mhz == roof_mhz:
"-i", raise ValueError("base and roof must be different")
"-c",
self.args.iio_phy_device, direction = -1.0 if roof_mhz < base_mhz else 1.0
channel, edge = base_mhz
attr, seq = 1
] windows: List[ScanWindow] = []
while True:
next_edge = edge + direction * step_mhz
if direction < 0 and next_edge < roof_mhz:
next_edge = roof_mhz
if direction > 0 and next_edge > roof_mhz:
next_edge = roof_mhz
low_mhz = min(edge, next_edge)
high_mhz = max(edge, next_edge)
center_mhz = (low_mhz + high_mhz) / 2.0
windows.append(
ScanWindow(
seq=seq,
start_mhz=edge,
end_mhz=next_edge,
low_mhz=low_mhz,
high_mhz=high_mhz,
center_mhz=center_mhz,
)
) )
return str(out).strip()
if next_edge == roof_mhz:
def _set_input_attr(self, channel: str, attr: str, value: str): break
self._run( edge = next_edge
[ seq += 1
"iio_attr",
"-u", return windows
self.args.uri,
"-q",
"-i", def render(
"-c", windows: List[ScanWindow],
self.args.iio_phy_device, serial: str,
channel, index: int,
attr, sample_rate: float,
value, base_mhz: float,
] roof_mhz: float,
step_mhz: float,
started_at: float,
pass_no: int,
current_seq: int,
) -> None:
now = time.time()
capture_bw_mhz = sample_rate / 1e6
current_row = next((row for row in windows if row.seq == current_seq), None)
best_row = max(
(row for row in windows if row.status == "OK" and row.dbfs is not None),
key=lambda row: row.dbfs if row.dbfs is not None else float("-inf"),
default=None,
)
print("\x1b[2J\x1b[H", end="")
print("HackRF Wide Energy Monitor (relative power: RMS / linear / dBFS)")
print(
f"serial: {serial} | idx: {index} | sample-rate: {capture_bw_mhz:.3f} MHz | "
f"scan: {base_mhz:.3f}->{roof_mhz:.3f} MHz step {step_mhz:.3f} MHz | "
f"pass: {pass_no} | uptime: {int(now-started_at)}s | {time.strftime('%Y-%m-%d %H:%M:%S')}"
)
print()
header = (
f"{'cur':>3} {'seq':>3} {'window MHz':>23} {'center':>9} {'status':>8} "
f"{'dBFS':>9} {'rms':>10} {'power':>12} {'N':>5} {'age':>5} error"
)
print(header)
print("-" * len(header))
for row in windows:
age = "-" if row.updated_at <= 0 else f"{(now-row.updated_at):.1f}"
err = row.error
if len(err) > 50:
err = err[:47] + "..."
marker = ">>>" if row.seq == current_seq else ""
print(
f"{marker:>3} {row.seq:>3} "
f"{f'{row.high_mhz:.3f}-{row.low_mhz:.3f}':>23} {row.center_mhz:>9.3f} {row.status:>8} "
f"{fmt(row.dbfs, '.2f'):>9} {fmt(row.rms, '.6f'):>10} {fmt(row.power_lin, '.8f'):>12} "
f"{row.samples:>5} {age:>5} {err}"
) )
print()
if best_row is not None:
best_age = "-" if best_row.updated_at <= 0 else f"{(now-best_row.updated_at):.1f}"
print(
f"{'':>3} {'MAX':>3} "
f"{f'{best_row.high_mhz:.3f}-{best_row.low_mhz:.3f}':>23} {best_row.center_mhz:>9.3f} {best_row.status:>8} "
f"{fmt(best_row.dbfs, '.2f'):>9} {fmt(best_row.rms, '.6f'):>10} {fmt(best_row.power_lin, '.8f'):>12} "
f"{best_row.samples:>5} {best_age:>5} pass={best_row.pass_no}"
)
elif current_row is not None:
current_age = "-" if current_row.updated_at <= 0 else f"{(now-current_row.updated_at):.1f}"
print(
f"{'':>3} {'MAX':>3} "
f"{f'{current_row.high_mhz:.3f}-{current_row.low_mhz:.3f}':>23} {current_row.center_mhz:>9.3f} {'INIT':>8} "
f"{fmt(None, '.2f'):>9} {fmt(None, '.6f'):>10} {fmt(None, '.8f'):>12} "
f"{0:>5} {current_age:>5} no successful windows yet"
)
print("Ctrl+C to stop. Window width equals step; sample-rate must be >= step to cover each window.")
sys.stdout.flush()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Retune one HackRF across a wide frequency range and measure energy")
parser.add_argument("--serial", required=True, help="HackRF serial number from hackrf_info")
parser.add_argument("--sample-rate", type=float, required=True, help="Sample rate in Hz")
parser.add_argument("--base", type=float, required=True, help="Scan start edge in MHz")
parser.add_argument("--roof", type=float, required=True, help="Scan end edge in MHz")
parser.add_argument("--step", type=float, required=True, help="Window width / retune step in MHz")
parser.add_argument("--vec-len", type=int, default=4096, help="Probe vector length")
parser.add_argument("--settle", type=float, default=0.12, help="Wait time after retune before reading (s)")
parser.add_argument("--avg-reads", type=int, default=3, help="How many probe reads to average per window")
parser.add_argument("--pause-between-reads", type=float, default=0.02, help="Pause between averaged reads (s)")
parser.add_argument("--passes", type=int, default=0, help="Number of sweep passes, 0 means infinite")
parser.add_argument("--gain", type=float, default=16.0, help="General gain")
parser.add_argument("--if-gain", type=float, default=16.0, help="IF gain")
parser.add_argument("--bb-gain", type=float, default=16.0, help="BB gain")
return parser
def _set_output_attr(self, channel: str, attr: str, value: str): def main() -> int:
self._run( args = build_parser().parse_args()
[ serial = args.serial.lower()
"iio_attr",
"-u", try:
self.args.uri, windows = build_windows(args.base, args.roof, args.step)
"-q", except ValueError as exc:
"-o", print(f"invalid scan range: {exc}", file=sys.stderr)
"-c", return 2
self.args.iio_phy_device,
channel, step_hz = args.step * 1e6
attr, if args.sample_rate < step_hz:
value, print(
] f"sample-rate {args.sample_rate:.0f} Hz is smaller than step window {step_hz:.0f} Hz; "
"this would leave gaps in the scan",
file=sys.stderr,
) )
return 2
try:
serial_to_index = parse_hackrf_info()
except Exception as exc:
print(f"hackrf discovery failed: {exc}", file=sys.stderr)
return 3
index = serial_to_index.get(serial)
if index is None:
print(f"serial {serial} not found in hackrf_info", file=sys.stderr)
print("available serials:", file=sys.stderr)
for item_serial, item_index in sorted(serial_to_index.items(), key=lambda item: item[1]):
print(f" idx={item_index} serial={item_serial}", file=sys.stderr)
return 4
stop_requested = False
def on_signal(signum, frame):
nonlocal stop_requested
stop_requested = True
signal.signal(signal.SIGINT, on_signal)
signal.signal(signal.SIGTERM, on_signal)
probe: Optional[WideProbeTop] = None
started_at = time.time()
pass_no = 0
current_seq = windows[0].seq
def get_effective_sample_rate(self) -> float: try:
if self.args.sample_rate is not None: probe = WideProbeTop(
return float(self.args.sample_rate) index=index,
raw = self._read_input_attr(self.args.iio_i_channel, "sampling_frequency") center_freq_hz=windows[0].center_mhz * 1e6,
return float(raw) sample_rate=args.sample_rate,
vec_len=args.vec_len,
def ensure_configured(self): gain=args.gain,
sample_rate = None if_gain=args.if_gain,
if self.args.sample_rate is not None: bb_gain=args.bb_gain,
sample_rate = int(round(max(float(self.args.sample_rate), IIO_MIN_SAMPLE_RATE)))
bandwidth = None
if self.args.bandwidth is not None:
bandwidth = int(round(max(float(self.args.bandwidth), 200000.0)))
signature = (
None if sample_rate is None else float(sample_rate),
None if bandwidth is None else float(bandwidth),
self.args.iio_gain_mode,
self.args.iio_hardwaregain,
self.args.iio_port_select,
) )
if signature == self._static_signature: probe.start()
return time.sleep(max(args.settle, 0.12))
for channel in self._input_channels: while not stop_requested:
if sample_rate is not None: pass_no += 1
self._set_input_attr(channel, "sampling_frequency", str(sample_rate)) for row in windows:
if bandwidth is not None: if stop_requested:
self._set_input_attr(channel, "rf_bandwidth", str(bandwidth)) break
if self.args.iio_port_select: current_seq = row.seq
self._set_input_attr(channel, "rf_port_select", self.args.iio_port_select) try:
if self.args.iio_gain_mode: probe.tune(row.center_mhz * 1e6)
self._set_input_attr(channel, "gain_control_mode", self.args.iio_gain_mode) rms, power_lin, dbfs, samples = probe.read_window(
if self.args.iio_gain_mode == "manual" and self.args.iio_hardwaregain is not None: settle=args.settle,
self._set_input_attr(channel, "hardwaregain", f"{self.args.iio_hardwaregain:.6f}") avg_reads=args.avg_reads,
pause_between_reads=args.pause_between_reads,
self._static_signature = signature )
row.status = "OK"
def tune(self, freq_hz: float): row.rms = rms
target = int(round(freq_hz)) row.power_lin = power_lin
if self._last_freq_hz == target: row.dbfs = dbfs
return row.samples = samples
self._set_output_attr(self.args.iio_lo_channel, "frequency", str(target)) row.error = ""
self._last_freq_hz = target row.updated_at = time.time()
row.pass_no = pass_no
def read_metrics(self, samples_per_read: int) -> Tuple[float, float, float, int]: except Exception as exc:
cmd = [ row.status = "ERR"
"iio_readdev", row.error = str(exc)
"-u", row.updated_at = time.time()
self.args.uri, render(
"-T", windows=windows,
str(int(self.args.timeout_ms)), serial=serial,
"-b", index=index,
str(max(4, int(samples_per_read))), sample_rate=args.sample_rate,
"-s", base_mhz=args.base,
str(max(4, int(samples_per_read))), roof_mhz=args.roof,
self.args.iio_device, step_mhz=args.step,
self.args.iio_i_channel, started_at=started_at,
self.args.iio_q_channel, pass_no=pass_no,
] current_seq=current_seq,
raw = self._run_binary(cmd) )
values = np.frombuffer(raw, dtype="<i2") if args.passes > 0 and pass_no >= args.passes:
if values.size == 0: break
except Exception as exc:
print(f"scanner failed: {exc}", file=sys.stderr)
return 5
finally:
if probe is not None:
try:
probe.stop()
probe.wait()
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
import argparse
import math
import re
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
try:
import numpy as np
except Exception as exc:
print(f"numpy import failed: {exc}", file=sys.stderr)
sys.exit(1)
try:
from gnuradio import blocks, gr
import osmosdr
except Exception as exc:
print(f"gnuradio/osmosdr import failed: {exc}", file=sys.stderr)
print("Run with the SDR venv, e.g. .venv-sdr/bin/python read_energy_wide.py", file=sys.stderr)
sys.exit(1)
EPS = 1e-20
@dataclass
class ScanWindow:
seq: int
start_mhz: float
end_mhz: float
low_mhz: float
high_mhz: float
center_mhz: float
status: str = "INIT"
rms: Optional[float] = None
power_lin: Optional[float] = None
dbfs: Optional[float] = None
samples: int = 0
updated_at: float = 0.0
error: str = ""
pass_no: int = 0
class WideProbeTop(gr.top_block):
def __init__(
self,
index: int,
center_freq_hz: float,
sample_rate: float,
vec_len: int,
gain: float,
if_gain: float,
bb_gain: float,
):
super().__init__("hackrf_energy_wide_probe")
self.probe = blocks.probe_signal_vc(vec_len)
self.stream_to_vec = blocks.stream_to_vector(gr.sizeof_gr_complex * 1, vec_len)
self.src = osmosdr.source(args=f"numchan=1 hackrf={index}")
self.src.set_time_unknown_pps(osmosdr.time_spec_t())
self.src.set_sample_rate(sample_rate)
self.src.set_center_freq(center_freq_hz, 0)
try:
self.src.set_freq_corr(0, 0)
except Exception:
pass
try:
self.src.set_gain_mode(False, 0)
except Exception:
pass
for fn, val in (("set_gain", gain), ("set_if_gain", if_gain), ("set_bb_gain", bb_gain)):
try:
getattr(self.src, fn)(val, 0)
except Exception:
pass
try:
self.src.set_bandwidth(0, 0)
except Exception:
pass
try:
self.src.set_antenna("", 0)
except Exception:
pass
self.connect((self.src, 0), (self.stream_to_vec, 0))
self.connect((self.stream_to_vec, 0), (self.probe, 0))
def tune(self, freq_hz: float) -> None:
self.src.set_center_freq(freq_hz, 0)
def read_metrics(self) -> Tuple[float, float, float, int]:
arr = np.asarray(self.probe.level(), dtype=np.complex64)
if arr.size == 0:
raise RuntimeError("no samples") raise RuntimeError("no samples")
if values.size % 2 != 0: power_lin = float(np.mean(arr.real * arr.real + arr.imag * arr.imag))
raise RuntimeError(f"unexpected IQ sample payload: {values.size} int16 values")
iq = values.reshape(-1, 2).astype(np.float32, copy=False)
i = iq[:, 0]
q = iq[:, 1]
power_lin = float(np.mean(i * i + q * q))
rms = math.sqrt(max(power_lin, 0.0)) rms = math.sqrt(max(power_lin, 0.0))
dbfs = 10.0 * math.log10(max(power_lin, EPS)) dbfs = 10.0 * math.log10(max(power_lin, EPS))
return rms, power_lin, dbfs, int(iq.shape[0]) return rms, power_lin, dbfs, int(arr.size)
def read_window(self, settle: float, avg_reads: int, pause_between_reads: float, samples_per_read: int) -> Tuple[float, float, float, int]: def read_window(self, settle: float, avg_reads: int, pause_between_reads: float) -> Tuple[float, float, float, int]:
if settle > 0: if settle > 0:
time.sleep(settle) time.sleep(settle)
@ -309,10 +516,10 @@ class IioWideProbe:
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
for idx in range(read_count): for idx in range(read_count):
deadline = time.time() + max(1.0, self.args.timeout_ms / 1000.0) deadline = time.time() + 1.0
while True: while True:
try: try:
_, power_lin, _, samples = self.read_metrics(samples_per_read) _, power_lin, _, samples = self.read_metrics()
powers.append(power_lin) powers.append(power_lin)
sample_sizes.append(samples) sample_sizes.append(samples)
break break
@ -334,21 +541,21 @@ class IioWideProbe:
def parse_hackrf_info() -> Dict[str, int]: def parse_hackrf_info() -> Dict[str, int]:
try: try:
proc = subprocess.run(["hackrf_info"], capture_output=True, text=True, timeout=15) proc = subprocess.run(["hackrf_info"], capture_output=True, text=True, timeout=15)
except FileNotFoundError as exc: except FileNotFoundError:
raise RuntimeError("hackrf_info not found") from exc raise RuntimeError("hackrf_info not found")
except subprocess.TimeoutExpired as exc: except subprocess.TimeoutExpired:
raise RuntimeError("hackrf_info timeout") from exc raise RuntimeError("hackrf_info timeout")
text = (proc.stdout or "") + "\n" + (proc.stderr or "") text = (proc.stdout or "") + "\n" + (proc.stderr or "")
out: Dict[str, int] = {} out: Dict[str, int] = {}
cur_idx: Optional[int] = None cur_idx: Optional[int] = None
for line in text.splitlines(): for line in text.splitlines():
match = re.search(r"^Index:\s*(\d+)", line) m = re.search(r"^Index:\s*(\d+)", line)
if match: if m:
cur_idx = int(match.group(1)) cur_idx = int(m.group(1))
continue continue
match = re.search(r"^Serial number:\s*([0-9a-fA-F]+)", line) m = re.search(r"^Serial number:\s*([0-9a-fA-F]+)", line)
if match and cur_idx is not None: if m and cur_idx is not None:
out[match.group(1).lower()] = cur_idx out[m.group(1).lower()] = cur_idx
if not out: if not out:
raise RuntimeError("no devices parsed from hackrf_info") raise RuntimeError("no devices parsed from hackrf_info")
return out return out
@ -400,9 +607,8 @@ def build_windows(base_mhz: float, roof_mhz: float, step_mhz: float) -> List[Sca
def render( def render(
windows: List[ScanWindow], windows: List[ScanWindow],
backend: str, serial: str,
device_ref: str, index: int,
index_ref: str,
sample_rate: float, sample_rate: float,
base_mhz: float, base_mhz: float,
roof_mhz: float, roof_mhz: float,
@ -420,93 +626,73 @@ def render(
default=None, default=None,
) )
print("\x1b[2J\x1b[H", end="") print("\x1b[2J\x1b[H", end="")
print("Wide Energy Monitor (relative power: RMS / linear / dBFS)") print("HackRF Wide Energy Monitor (relative power: RMS / linear / dBFS)")
print( print(
f"backend: {backend} | device: {device_ref} | idx: {index_ref} | sample-rate: {capture_bw_mhz:.3f} MHz | " f"serial: {serial} | idx: {index} | sample-rate: {capture_bw_mhz:.3f} MHz | "
f"scan: {base_mhz:.3f}->{roof_mhz:.3f} MHz step {step_mhz:.3f} MHz | " f"scan: {base_mhz:.3f}->{roof_mhz:.3f} MHz step {step_mhz:.3f} MHz | "
f"pass: {pass_no} | uptime: {int(now-started_at)}s | {time.strftime('%Y-%m-%d %H:%M:%S')}" f"pass: {pass_no} | uptime: {int(now-started_at)}s | {time.strftime('%Y-%m-%d %H:%M:%S')}"
) )
print() print()
header = ( header = (
f"{'cur':>3} {'seq':>3} {'window MHz':>23} {'center':>9} {'status':>8} " f"{'cur':>3} {'seq':>3} {'window MHz':>23} {'center':>9} {'status':>8} "
f"{'dBFS':>9} {'rms':>10} {'power':>12} {'N':>6} {'age':>5} error" f"{'dBFS':>9} {'rms':>10} {'power':>12} {'N':>5} {'age':>5} error"
) )
print(header) print(header)
print("-" * len(header)) print("-" * len(header))
for row in windows: for row in windows:
age = "-" if row.updated_at <= 0 else f"{(now - row.updated_at):.1f}" age = "-" if row.updated_at <= 0 else f"{(now-row.updated_at):.1f}"
err = row.error err = row.error
if len(err) > 56: if len(err) > 50:
err = err[:53] + "..." err = err[:47] + "..."
marker = ">>>" if row.seq == current_seq else "" marker = ">>>" if row.seq == current_seq else ""
print( print(
f"{marker:>3} {row.seq:>3} " f"{marker:>3} {row.seq:>3} "
f"{f'{row.high_mhz:.3f}-{row.low_mhz:.3f}':>23} {row.center_mhz:>9.3f} {row.status:>8} " f"{f'{row.high_mhz:.3f}-{row.low_mhz:.3f}':>23} {row.center_mhz:>9.3f} {row.status:>8} "
f"{fmt(row.dbfs, '.2f'):>9} {fmt(row.rms, '.6f'):>10} {fmt(row.power_lin, '.8f'):>12} " f"{fmt(row.dbfs, '.2f'):>9} {fmt(row.rms, '.6f'):>10} {fmt(row.power_lin, '.8f'):>12} "
f"{row.samples:>6} {age:>5} {err}" f"{row.samples:>5} {age:>5} {err}"
) )
print() print()
if best_row is not None: if best_row is not None:
best_age = "-" if best_row.updated_at <= 0 else f"{(now - best_row.updated_at):.1f}" best_age = "-" if best_row.updated_at <= 0 else f"{(now-best_row.updated_at):.1f}"
print( print(
f"{'':>3} {'MAX':>3} " f"{'':>3} {'MAX':>3} "
f"{f'{best_row.high_mhz:.3f}-{best_row.low_mhz:.3f}':>23} {best_row.center_mhz:>9.3f} {best_row.status:>8} " f"{f'{best_row.high_mhz:.3f}-{best_row.low_mhz:.3f}':>23} {best_row.center_mhz:>9.3f} {best_row.status:>8} "
f"{fmt(best_row.dbfs, '.2f'):>9} {fmt(best_row.rms, '.6f'):>10} {fmt(best_row.power_lin, '.8f'):>12} " f"{fmt(best_row.dbfs, '.2f'):>9} {fmt(best_row.rms, '.6f'):>10} {fmt(best_row.power_lin, '.8f'):>12} "
f"{best_row.samples:>6} {best_age:>5} pass={best_row.pass_no}" f"{best_row.samples:>5} {best_age:>5} pass={best_row.pass_no}"
) )
elif current_row is not None: elif current_row is not None:
current_age = "-" if current_row.updated_at <= 0 else f"{(now - current_row.updated_at):.1f}" current_age = "-" if current_row.updated_at <= 0 else f"{(now-current_row.updated_at):.1f}"
print( print(
f"{'':>3} {'MAX':>3} " f"{'':>3} {'MAX':>3} "
f"{f'{current_row.high_mhz:.3f}-{current_row.low_mhz:.3f}':>23} {current_row.center_mhz:>9.3f} {'INIT':>8} " f"{f'{current_row.high_mhz:.3f}-{current_row.low_mhz:.3f}':>23} {current_row.center_mhz:>9.3f} {'INIT':>8} "
f"{fmt(None, '.2f'):>9} {fmt(None, '.6f'):>10} {fmt(None, '.8f'):>12} " f"{fmt(None, '.2f'):>9} {fmt(None, '.6f'):>10} {fmt(None, '.8f'):>12} "
f"{0:>6} {current_age:>5} no successful windows yet" f"{0:>5} {current_age:>5} no successful windows yet"
) )
print("Ctrl+C to stop. Window width equals step; sample-rate must be >= step to cover each window.") print("Ctrl+C to stop. Window width equals step; sample-rate must be >= step to cover each window.")
sys.stdout.flush() sys.stdout.flush()
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Retune one SDR across a wide frequency range and measure energy") parser = argparse.ArgumentParser(description="Retune one HackRF across a wide frequency range and measure energy")
parser.add_argument("--backend", choices=("hackrf", "iio"), default="hackrf", help="SDR backend") parser.add_argument("--serial", required=True, help="HackRF serial number from hackrf_info")
parser.add_argument("--serial", help="HackRF serial number from hackrf_info") parser.add_argument("--sample-rate", type=float, required=True, help="Sample rate in Hz")
parser.add_argument(
"--sample-rate",
type=float,
default=None,
help="Sample rate in Hz (required for hackrf, optional for iio to keep device setting)",
)
parser.add_argument("--bandwidth", type=float, default=None, help="RF bandwidth in Hz (iio only, optional)")
parser.add_argument("--base", type=float, required=True, help="Scan start edge in MHz") parser.add_argument("--base", type=float, required=True, help="Scan start edge in MHz")
parser.add_argument("--roof", type=float, required=True, help="Scan end edge in MHz") parser.add_argument("--roof", type=float, required=True, help="Scan end edge in MHz")
parser.add_argument("--step", type=float, required=True, help="Window width / retune step in MHz") parser.add_argument("--step", type=float, required=True, help="Window width / retune step in MHz")
parser.add_argument("--vec-len", type=int, default=4096, help="Probe vector length / samples per read") parser.add_argument("--vec-len", type=int, default=4096, help="Probe vector length")
parser.add_argument("--settle", type=float, default=0.12, help="Wait time after retune before reading (s)") parser.add_argument("--settle", type=float, default=0.12, help="Wait time after retune before reading (s)")
parser.add_argument("--avg-reads", type=int, default=3, help="How many reads to average per window") parser.add_argument("--avg-reads", type=int, default=3, help="How many probe reads to average per window")
parser.add_argument("--pause-between-reads", type=float, default=0.02, help="Pause between averaged reads (s)") parser.add_argument("--pause-between-reads", type=float, default=0.02, help="Pause between averaged reads (s)")
parser.add_argument("--gain", type=float, default=16.0, help="General gain for HackRF") parser.add_argument("--passes", type=int, default=0, help="Number of sweep passes, 0 means infinite")
parser.add_argument("--if-gain", type=float, default=16.0, help="IF gain for HackRF") parser.add_argument("--gain", type=float, default=16.0, help="General gain")
parser.add_argument("--bb-gain", type=float, default=16.0, help="BB gain for HackRF") parser.add_argument("--if-gain", type=float, default=16.0, help="IF gain")
parser.add_argument("--uri", default="ip:192.168.2.1", help="IIO URI, e.g. ip:192.168.2.1") parser.add_argument("--bb-gain", type=float, default=16.0, help="BB gain")
parser.add_argument("--iio-device", default="cf-ad9361-lpc", help="IIO RX buffer device")
parser.add_argument("--iio-phy-device", default="ad9361-phy", help="IIO PHY device")
parser.add_argument("--iio-i-channel", default="voltage0", help="IIO I channel")
parser.add_argument("--iio-q-channel", default="voltage1", help="IIO Q channel")
parser.add_argument("--iio-lo-channel", default="altvoltage0", help="IIO LO channel for RX frequency")
parser.add_argument("--iio-port-select", default="A_BALANCED", help="IIO rf_port_select value")
parser.add_argument(
"--iio-gain-mode",
choices=("manual", "fast_attack", "slow_attack", "hybrid"),
default="slow_attack",
help="IIO gain control mode",
)
parser.add_argument("--iio-hardwaregain", type=float, default=None, help="IIO hardware gain in dB for manual mode")
parser.add_argument("--timeout-ms", type=int, default=4000, help="IIO read timeout in milliseconds")
return parser return parser
def main() -> int: def main() -> int:
args = build_parser().parse_args() args = build_parser().parse_args()
serial = args.serial.lower()
try: try:
windows = build_windows(args.base, args.roof, args.step) windows = build_windows(args.base, args.roof, args.step)
@ -514,72 +700,28 @@ def main() -> int:
print(f"invalid scan range: {exc}", file=sys.stderr) print(f"invalid scan range: {exc}", file=sys.stderr)
return 2 return 2
probe: Optional[HackRfWideProbe | IioWideProbe] = None step_hz = args.step * 1e6
device_ref = "" if args.sample_rate < step_hz:
index_ref = "-" print(
f"sample-rate {args.sample_rate:.0f} Hz is smaller than step window {step_hz:.0f} Hz; "
if args.backend == "hackrf": "this would leave gaps in the scan",
if not args.serial: file=sys.stderr,
print("--serial is required for --backend hackrf", file=sys.stderr)
return 2
if args.sample_rate is None:
print("--sample-rate is required for --backend hackrf", file=sys.stderr)
return 2
serial = args.serial.lower()
step_hz = args.step * 1e6
if args.sample_rate < step_hz:
print(
f"sample-rate {args.sample_rate:.0f} Hz is smaller than step window {step_hz:.0f} Hz; this would leave gaps in the scan",
file=sys.stderr,
)
return 2
try:
serial_to_index = parse_hackrf_info()
except Exception as exc:
print(f"hackrf discovery failed: {exc}", file=sys.stderr)
return 3
index = serial_to_index.get(serial)
if index is None:
print(f"serial {serial} not found in hackrf_info", file=sys.stderr)
print("available serials:", file=sys.stderr)
for item_serial, item_index in sorted(serial_to_index.items(), key=lambda item: item[1]):
print(f" idx={item_index} serial={item_serial}", file=sys.stderr)
return 4
probe = HackRfWideProbe(
serial=serial,
center_freq_hz=windows[0].center_mhz * 1e6,
sample_rate=args.sample_rate,
vec_len=args.vec_len,
gain=args.gain,
if_gain=args.if_gain,
bb_gain=args.bb_gain,
) )
effective_sample_rate = float(args.sample_rate) return 2
device_ref = serial
index_ref = str(index) try:
else: serial_to_index = parse_hackrf_info()
probe = IioWideProbe(args) except Exception as exc:
try: print(f"hackrf discovery failed: {exc}", file=sys.stderr)
probe.ensure_configured() return 3
effective_sample_rate = probe.get_effective_sample_rate()
except Exception as exc:
print(f"iio setup failed: {exc}", file=sys.stderr)
return 3
step_hz = args.step * 1e6
if effective_sample_rate < step_hz:
print(
f"effective sample-rate {effective_sample_rate:.0f} Hz is smaller than step window {step_hz:.0f} Hz; this would leave gaps in the scan",
file=sys.stderr,
)
return 2
device_ref = args.uri index = serial_to_index.get(serial)
index_ref = "iio" if index is None:
print(f"serial {serial} not found in hackrf_info", file=sys.stderr)
print("available serials:", file=sys.stderr)
for item_serial, item_index in sorted(serial_to_index.items(), key=lambda item: item[1]):
print(f" idx={item_index} serial={item_serial}", file=sys.stderr)
return 4
stop_requested = False stop_requested = False
@ -590,14 +732,23 @@ def main() -> int:
signal.signal(signal.SIGINT, on_signal) signal.signal(signal.SIGINT, on_signal)
signal.signal(signal.SIGTERM, on_signal) signal.signal(signal.SIGTERM, on_signal)
probe: Optional[WideProbeTop] = None
started_at = time.time() started_at = time.time()
pass_no = 0 pass_no = 0
current_seq = windows[0].seq current_seq = windows[0].seq
try: try:
if isinstance(probe, HackRfWideProbe): probe = WideProbeTop(
probe.start() index=index,
time.sleep(max(args.settle, 0.12)) center_freq_hz=windows[0].center_mhz * 1e6,
sample_rate=args.sample_rate,
vec_len=args.vec_len,
gain=args.gain,
if_gain=args.if_gain,
bb_gain=args.bb_gain,
)
probe.start()
time.sleep(max(args.settle, 0.12))
while not stop_requested: while not stop_requested:
pass_no += 1 pass_no += 1
@ -607,19 +758,11 @@ def main() -> int:
current_seq = row.seq current_seq = row.seq
try: try:
probe.tune(row.center_mhz * 1e6) probe.tune(row.center_mhz * 1e6)
if isinstance(probe, HackRfWideProbe): rms, power_lin, dbfs, samples = probe.read_window(
rms, power_lin, dbfs, samples = probe.read_window( settle=args.settle,
settle=args.settle, avg_reads=args.avg_reads,
avg_reads=args.avg_reads, pause_between_reads=args.pause_between_reads,
pause_between_reads=args.pause_between_reads, )
)
else:
rms, power_lin, dbfs, samples = probe.read_window(
settle=args.settle,
avg_reads=args.avg_reads,
pause_between_reads=args.pause_between_reads,
samples_per_read=args.vec_len,
)
row.status = "OK" row.status = "OK"
row.rms = rms row.rms = rms
row.power_lin = power_lin row.power_lin = power_lin
@ -632,13 +775,11 @@ def main() -> int:
row.status = "ERR" row.status = "ERR"
row.error = str(exc) row.error = str(exc)
row.updated_at = time.time() row.updated_at = time.time()
render( render(
windows=windows, windows=windows,
backend=args.backend, serial=serial,
device_ref=device_ref, index=index,
index_ref=index_ref, sample_rate=args.sample_rate,
sample_rate=effective_sample_rate,
base_mhz=args.base, base_mhz=args.base,
roof_mhz=args.roof, roof_mhz=args.roof,
step_mhz=args.step, step_mhz=args.step,
@ -646,12 +787,13 @@ def main() -> int:
pass_no=pass_no, pass_no=pass_no,
current_seq=current_seq, current_seq=current_seq,
) )
if args.passes > 0 and pass_no >= args.passes:
break
except Exception as exc: except Exception as exc:
print(f"scanner failed: {exc}", file=sys.stderr) print(f"scanner failed: {exc}", file=sys.stderr)
return 5 return 5
finally: finally:
if isinstance(probe, HackRfWideProbe): if probe is not None:
try: try:
probe.stop() probe.stop()
probe.wait() probe.wait()

@ -1,23 +1,27 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import argparse
import os import os
import signal
import subprocess
import sys import sys
import threading
import time import time
import signal
import argparse
import threading
import numpy as np import numpy as np
from gnuradio import gr from gnuradio import gr
import osmosdr import osmosdr
IIO_MIN_SAMPLE_RATE = 2_083_333
class SimsiSink(gr.sync_block):
def __init__(self, save_dir="./signal", file_tag="fragment_", split_size=1000000, delay=0.0):
gr.sync_block.__init__(
self,
name="Simsi_Sink",
in_sig=[np.complex64],
out_sig=None,
)
class RotatingIqWriter:
def __init__(self, save_dir="./signal", file_tag="fragment_", split_size=1_000_000, delay=0.0):
self.save_dir = str(save_dir) self.save_dir = str(save_dir)
self.file_tag = str(file_tag) self.file_tag = str(file_tag)
self.split_size = int(split_size) self.split_size = int(split_size)
@ -82,56 +86,35 @@ class RotatingIqWriter:
self.file_index += 1 self.file_index += 1
self._open_next_file() self._open_next_file()
def write_samples(self, samples): def work(self, input_items, output_items):
data = np.asarray(samples, dtype=np.complex64).reshape(-1) data = input_items[0]
offset = 0 offset = 0
total = int(data.size) total = len(data)
while offset < total: while offset < total:
remaining = self.split_size - self.current_len remaining = self.split_size - self.current_len
chunk = min(remaining, total - offset) chunk = min(remaining, total - offset)
block = np.ascontiguousarray(data[offset:offset + chunk], dtype=np.complex64)
self.current_fd.write(block.tobytes()) self.current_fd.write(data[offset:offset + chunk].copy())
self.current_len += chunk self.current_len += chunk
offset += chunk offset += chunk
if self.current_len >= self.split_size: if self.current_len >= self.split_size:
self._rotate_file() self._rotate_file()
def close(self): return len(data)
def stop(self):
try: try:
if self.current_fd is not None: if self.current_fd is not None:
self.current_fd.close() self.current_fd.close()
self.current_fd = None self.current_fd = None
finally: finally:
self._remove_in_progress() self._remove_in_progress()
class SimsiSink(gr.sync_block):
def __init__(self, save_dir="./signal", file_tag="fragment_", split_size=1_000_000, delay=0.0):
gr.sync_block.__init__(
self,
name="Simsi_Sink",
in_sig=[np.complex64],
out_sig=None,
)
self.writer = RotatingIqWriter(
save_dir=save_dir,
file_tag=file_tag,
split_size=split_size,
delay=delay,
)
def work(self, input_items, output_items):
self.writer.write_samples(input_items[0])
return len(input_items[0])
def stop(self):
self.writer.close()
return True return True
class HackRfDataSaver(gr.top_block): class DataSaver(gr.top_block):
def __init__( def __init__(
self, self,
serial: str, serial: str,
@ -147,204 +130,47 @@ class HackRfDataSaver(gr.top_block):
): ):
super().__init__("data_saver_headless", catch_exceptions=True) super().__init__("data_saver_headless", catch_exceptions=True)
dev_args = f"numchan=1 hackrf={serial}" self.serial = serial
self.freq = float(freq)
self.save_dir = save_dir
self.file_tag = file_tag
self.samp_rate = float(samp_rate)
self.split_size = int(split_size)
self.delay = float(delay)
self.rf_gain = float(rf_gain)
self.if_gain = float(if_gain)
self.bb_gain = float(bb_gain)
dev_args = f"numchan=1 hackrf={self.serial}"
self.source = osmosdr.source(args=dev_args) self.source = osmosdr.source(args=dev_args)
self.source.set_time_unknown_pps(osmosdr.time_spec_t()) self.source.set_time_unknown_pps(osmosdr.time_spec_t())
self.source.set_sample_rate(float(samp_rate)) self.source.set_sample_rate(self.samp_rate)
self.source.set_center_freq(float(freq), 0) self.source.set_center_freq(self.freq, 0)
self.source.set_freq_corr(0, 0) self.source.set_freq_corr(0, 0)
self.source.set_dc_offset_mode(0, 0) self.source.set_dc_offset_mode(0, 0)
self.source.set_iq_balance_mode(0, 0) self.source.set_iq_balance_mode(0, 0)
self.source.set_gain_mode(False, 0) self.source.set_gain_mode(False, 0)
self.source.set_gain(float(rf_gain), 0) self.source.set_gain(self.rf_gain, 0)
self.source.set_if_gain(float(if_gain), 0) self.source.set_if_gain(self.if_gain, 0)
self.source.set_bb_gain(float(bb_gain), 0) self.source.set_bb_gain(self.bb_gain, 0)
self.source.set_antenna("", 0) self.source.set_antenna("", 0)
self.source.set_bandwidth(0, 0) self.source.set_bandwidth(0, 0)
self.sink = SimsiSink( self.sink = SimsiSink(
save_dir=save_dir, save_dir=self.save_dir,
file_tag=file_tag, file_tag=self.file_tag,
split_size=split_size, split_size=self.split_size,
delay=delay, delay=self.delay,
) )
self.connect((self.source, 0), (self.sink, 0)) self.connect((self.source, 0), (self.sink, 0))
class IioCapture:
def __init__(self, args: argparse.Namespace):
self.args = args
self.writer = RotatingIqWriter(
save_dir=args.save_dir,
file_tag=args.file_tag,
split_size=args.split_size,
delay=args.delay,
)
self._static_signature = None
self._last_freq_hz = None
self._input_channels = [args.iio_i_channel]
if args.iio_q_channel not in self._input_channels:
self._input_channels.append(args.iio_q_channel)
def _run(self, cmd):
proc = subprocess.run(list(cmd), capture_output=True, text=True)
if proc.returncode != 0:
details = (proc.stderr or "").strip() or (proc.stdout or "").strip() or f"exit code {proc.returncode}"
raise RuntimeError(details)
return proc.stdout
def _run_binary(self, cmd, stop_event: threading.Event):
proc = subprocess.Popen(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
while True:
try:
stdout, stderr = proc.communicate(timeout=0.2)
break
except subprocess.TimeoutExpired:
if stop_event.is_set():
proc.terminate()
try:
proc.communicate(timeout=1.0)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
raise InterruptedError("stop requested")
if proc.returncode != 0:
details = stderr.decode("utf-8", errors="ignore").strip() or stdout.decode("utf-8", errors="ignore").strip()
raise RuntimeError(details or f"exit code {proc.returncode}")
return stdout
finally:
if proc.poll() is None:
proc.kill()
proc.communicate()
def _set_input_attr(self, channel: str, attr: str, value: str):
self._run(
[
"iio_attr",
"-u",
self.args.iio_uri,
"-q",
"-i",
"-c",
self.args.iio_phy_device,
channel,
attr,
value,
]
)
def _set_output_attr(self, channel: str, attr: str, value: str):
self._run(
[
"iio_attr",
"-u",
self.args.iio_uri,
"-q",
"-o",
"-c",
self.args.iio_phy_device,
channel,
attr,
value,
]
)
def ensure_configured(self):
sample_rate = int(round(max(float(self.args.samp_rate), IIO_MIN_SAMPLE_RATE)))
bandwidth = None
if self.args.bandwidth is not None:
bandwidth = int(round(max(float(self.args.bandwidth), 200_000.0)))
signature = (
float(sample_rate),
None if bandwidth is None else float(bandwidth),
self.args.iio_gain_mode,
self.args.iio_hardwaregain,
self.args.iio_port_select,
)
if signature == self._static_signature:
return
for channel in self._input_channels:
self._set_input_attr(channel, "sampling_frequency", str(sample_rate))
if bandwidth is not None:
self._set_input_attr(channel, "rf_bandwidth", str(bandwidth))
if self.args.iio_port_select:
self._set_input_attr(channel, "rf_port_select", self.args.iio_port_select)
if self.args.iio_gain_mode:
self._set_input_attr(channel, "gain_control_mode", self.args.iio_gain_mode)
if self.args.iio_gain_mode == "manual" and self.args.iio_hardwaregain is not None:
self._set_input_attr(channel, "hardwaregain", f"{self.args.iio_hardwaregain:.6f}")
self._static_signature = signature
def tune(self):
target = int(round(float(self.args.freq)))
if self._last_freq_hz == target:
return
self._set_output_attr(self.args.iio_lo_channel, "frequency", str(target))
self._last_freq_hz = target
def read_chunk(self, stop_event: threading.Event):
samples_per_read = self.args.samples_per_read
if samples_per_read is None:
samples_per_read = max(4096, min(int(self.args.split_size), 262_144))
raw = self._run_binary(
[
"iio_readdev",
"-u",
self.args.iio_uri,
"-T",
str(int(self.args.timeout_ms)),
"-b",
str(int(samples_per_read)),
"-s",
str(int(samples_per_read)),
self.args.iio_device,
self.args.iio_i_channel,
self.args.iio_q_channel,
],
stop_event,
)
values = np.frombuffer(raw, dtype="<i2")
if values.size == 0:
raise RuntimeError("no samples")
if values.size % 2 != 0:
raise RuntimeError(f"unexpected IQ sample payload: {values.size} int16 values")
iq = values.reshape(-1, 2).astype(np.float32, copy=False)
complex_samples = (iq[:, 0] + 1j * iq[:, 1]).astype(np.complex64, copy=False)
complex_samples /= 32768.0
return complex_samples
def run(self, stop_event: threading.Event):
self.ensure_configured()
self.tune()
if self.args.settle > 0:
time.sleep(self.args.settle)
while not stop_event.is_set():
try:
samples = self.read_chunk(stop_event)
except InterruptedError:
break
self.writer.write_samples(samples)
def close(self):
self.writer.close()
def parse_args(): def parse_args():
parser = argparse.ArgumentParser(description="Headless IQ saver for HackRF or IIO SDR backends") parser = argparse.ArgumentParser(description="Headless GNU Radio IQ saver for HackRF")
parser.add_argument("--backend", choices=("hackrf", "iio"), default="hackrf", help="SDR backend") parser.add_argument("--serial", required=True, help="HackRF serial number")
parser.add_argument("--serial", help="HackRF serial number")
parser.add_argument("--freq", type=float, required=True, help="Center frequency in Hz") parser.add_argument("--freq", type=float, required=True, help="Center frequency in Hz")
parser.add_argument("--save-dir", required=True, help="Directory for output IQ files") parser.add_argument("--save-dir", required=True, help="Directory for output IQ files")
parser.add_argument("--file-tag", default="fragment_", help="Prefix for output files") parser.add_argument("--file-tag", default="fragment_", help="Prefix for output files")
@ -357,91 +183,55 @@ def parse_args():
parser.add_argument("--if-gain", type=float, default=30, help="HackRF IF gain") parser.add_argument("--if-gain", type=float, default=30, help="HackRF IF gain")
parser.add_argument("--bb-gain", type=float, default=36, help="HackRF BB gain") parser.add_argument("--bb-gain", type=float, default=36, help="HackRF BB gain")
parser.add_argument("--bandwidth", type=float, default=None, help="Optional IIO RF bandwidth in Hz")
parser.add_argument("--iio-uri", default="ip:192.168.2.1", help="IIO URI")
parser.add_argument("--iio-device", default="cf-ad9361-lpc", help="IIO RX buffer device")
parser.add_argument("--iio-phy-device", default="ad9361-phy", help="IIO PHY device")
parser.add_argument("--iio-i-channel", default="voltage0", help="IIO I channel")
parser.add_argument("--iio-q-channel", default="voltage1", help="IIO Q channel")
parser.add_argument("--iio-lo-channel", default="altvoltage0", help="IIO LO channel for RX frequency")
parser.add_argument("--iio-port-select", default="A_BALANCED", help="IIO rf_port_select value")
parser.add_argument("--iio-gain-mode", default="slow_attack", help="IIO gain_control_mode value")
parser.add_argument("--iio-hardwaregain", type=float, default=None, help="IIO hardware gain in dB for manual mode")
parser.add_argument("--timeout-ms", type=int, default=4000, help="IIO read timeout in milliseconds")
parser.add_argument("--settle", type=float, default=0.12, help="Delay after tuning for IIO backend")
parser.add_argument("--samples-per-read", type=int, default=None, help="IIO complex samples per read call")
return parser.parse_args() return parser.parse_args()
def main(): def main():
args = parse_args() args = parse_args()
stop_event = threading.Event()
if args.backend == "hackrf" and not args.serial: tb = DataSaver(
print("--serial is required for --backend hackrf", file=sys.stderr) serial=args.serial,
sys.exit(2) freq=args.freq,
save_dir=args.save_dir,
file_tag=args.file_tag,
samp_rate=args.samp_rate,
split_size=args.split_size,
delay=args.delay,
rf_gain=args.rf_gain,
if_gain=args.if_gain,
bb_gain=args.bb_gain,
)
capture = None stop_event = threading.Event()
tb = None
def handle_signal(sig, frame): def handle_signal(sig, frame):
print(f"Received signal {sig}, stopping...", flush=True) print(f"Received signal {sig}, stopping...", flush=True)
stop_event.set() stop_event.set()
if tb is not None: tb.stop()
tb.stop() tb.wait()
tb.wait()
signal.signal(signal.SIGINT, handle_signal) signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal) signal.signal(signal.SIGTERM, handle_signal)
print("Starting capture...", flush=True) print("Starting flowgraph...", flush=True)
print(f" backend: {args.backend}", flush=True) print(f" serial: {args.serial}", flush=True)
print(f" freq: {args.freq}", flush=True) print(f" freq: {args.freq}", flush=True)
print(f" save_dir: {args.save_dir}", flush=True) print(f" save_dir: {args.save_dir}", flush=True)
print(f" file_tag: {args.file_tag}", flush=True) print(f" file_tag: {args.file_tag}", flush=True)
print(f" samp_rate: {args.samp_rate}", flush=True) print(f" samp_rate: {args.samp_rate}", flush=True)
print(f" split_size: {args.split_size}", flush=True) print(f" split_size: {args.split_size}", flush=True)
print(f" gains: rf={args.rf_gain} if={args.if_gain} bb={args.bb_gain}", flush=True)
tb.start()
try: try:
if args.backend == "hackrf": while not stop_event.is_set():
print(f" serial: {args.serial}", flush=True) time.sleep(0.5)
print(f" gains: rf={args.rf_gain} if={args.if_gain} bb={args.bb_gain}", flush=True)
tb = HackRfDataSaver(
serial=args.serial,
freq=args.freq,
save_dir=args.save_dir,
file_tag=args.file_tag,
samp_rate=args.samp_rate,
split_size=args.split_size,
delay=args.delay,
rf_gain=args.rf_gain,
if_gain=args.if_gain,
bb_gain=args.bb_gain,
)
tb.start()
while not stop_event.is_set():
time.sleep(0.5)
else:
print(f" uri: {args.iio_uri}", flush=True)
print(
f" iio: device={args.iio_device} phy={args.iio_phy_device} "
f"port={args.iio_port_select} gain_mode={args.iio_gain_mode}",
flush=True,
)
capture = IioCapture(args)
capture.run(stop_event)
except KeyboardInterrupt: except KeyboardInterrupt:
handle_signal(signal.SIGINT, None) handle_signal(signal.SIGINT, None)
finally:
if capture is not None:
capture.close()
if tb is not None and not stop_event.is_set():
tb.stop()
tb.wait()
print("Stopped.", flush=True) print("Stopped.", flush=True)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
Loading…
Cancel
Save