diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e311c63 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Line-ending policy: LF everywhere, on every platform. +# Identical working trees on Windows and Linux mean one patch/diff flavor +# and byte-identical checkouts; modern Windows tooling is LF-clean. +* text=auto eol=lf + +# Binary payloads: never normalize. +*.iq binary +*.wav binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.bin binary + +# Frozen verbatim fixtures are byte-exact by definition: real tool output +# captured for parser tests. Normalizing their line endings would silently +# alter the very bytes they exist to preserve. +hackrfpy/tests/fixtures/** -text diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 77e94f1..f16ffd1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,6 +26,13 @@ jobs: matrix: # windows-latest is first because it is the platform this library # targets; the cross-platform lifecycle stubs must pass there. + # ubuntu re-entered the matrix 2026-09-19: Linux was verified + # against a real board (Debian 12, full suite incl. hardware tests, + # 227/227) and is held to the same standards as Windows. This also + # puts the Linux pdeathsig dead-man path back under regression + # testing. The verification itself surfaced and fixed two lifecycle + # bugs (frozen-writer USB-claim leak; open-retry race) -- see the + # CHANGELOG. os: [windows-latest, ubuntu-latest, macos-latest] python-version: ["3.11", "3.12", "3.13"] @@ -42,12 +49,12 @@ jobs: run: uv sync - name: Run test suite (hardware tests deselected) - run: uv run pytest -m "not hardware" --cov=hackrfpy --cov-report=xml --cov-report=term-missing + run: uv run pytest -m "not hardware" --cov=hackrfpy --cov-report=xml --cov-report=term-missing --cov-fail-under=85 - name: Upload coverage to Codecov # Optional: only runs once linked at https://codecov.io. Tokenless for # public repos. Safe to leave in before linking - it just no-ops/soft-fails. - if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + if: matrix.os == 'windows-latest' && matrix.python-version == '3.12' uses: codecov/codecov-action@v5 with: files: hackrfpy/coverage.xml diff --git a/.gitignore b/.gitignore index 83972fa..0ee0839 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,4 @@ __marimo__/ # Streamlit .streamlit/secrets.toml +hackrfpy/tests/fm_testdata/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 450a6e6..ab9ed41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ Work on the current development branch. Entries move to a versioned section on release. ### Added +- Linux promoted from experimental to hardware-verified. Verification run + 2026-09-19 on Debian 12 (bookworm, kernel 6.12.95, Dell Latitude 7420) + with `hackrf` 2022.09.1 and firmware 2024.02.1: full test suite including + all hardware tests, 227/227, cross-checked the same day on Windows with + hardware at 227/227 on identical bytes. Per the platform policy, Linux + re-enters the CI matrix under the same standards as Windows (85% coverage + gate, hardware evidence for core changes), which also returns the Linux + `pdeathsig` dead-man path to regression testing. The verification itself + surfaced the two process-lifecycle bugs fixed below (frozen-writer USB + claim leak; open-retry race) -- the strongest possible argument for + requiring it. Debian-family setup notes and the verification sequence are + merged into the main README (Linux Setup section); platform language + updated in both READMEs and CONTRIBUTING; macOS remains experimental + pending its own board run. +- Housekeeping: `hackrfpy/tests/fm_testdata/` outputs are regenerable and + were never meant to be committed; the `.gitignore` entry pointed at the + wrong path (`tests/fm_testdata/`), so three generated files were tracked. + Path fixed, files untracked. - Type annotations across the entire shipped package, and `mypy` promoted to a blocking CI gate (`disallow_untyped_defs`). The package has always shipped a `py.typed` marker, which tells downstream type-checkers the inline annotations @@ -28,8 +46,168 @@ release. (`cli.py` from 12% to ~98%). - Community health files: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, issue templates, and a pull request template. +- `examples/channel_monitor.py`: live multi-channel power meter driven by + `monitor_frequencies` (one continuous sweep, ASCII bar output, no plotting + extra) -- the library's sweep-backed monitoring style previously had no + example. +- `examples/tx_test_tone.py`: the first and only transmitting example. A + constant-wave test tone behind the deliberate RX->TX mode switch, with a + required and capped duration (10 s), a deliberately low default TX gain, the + RF amp never enabled, and `--print-cmd` dry-run support. +- `tests/collect_real_FM_data.py`: records one named FM broadcast station as a + reference dataset for comparison against a hardware receiver implementation. + Writes the IQ + SigMF sidecar plus a machine-readable `.report.json` pinning + down exact settings, firmware/tools versions, raw-IQ health (power, clipping, + DC), and an FM-discriminator check for the 19 kHz stereo pilot with its SNR + -- the definitive "this really is the station" test. Deliberately split from + `tests/collect_real_data.py`, which freezes tiny verbatim parser fixtures. +- `tests/collect_fm_testdata.py`: repeatable FM test-data generator with a + five-stage hardware pipeline -- discover (top-N sweep candidates), calibrate + (walks LNA/VGA until peak amplitude lands in [0.25, 0.70]), verify (short + probe per candidate; the 19 kHz pilot must show at the tuning offset before + any full recording, making the script robust to time-of-day and weather + propagation changes), record (station parked at +300 kHz, clear of the DC + spike), validate. Falls back (`--fallback auto|always|never`) to a + deterministic synthetic FM recording (fixed-seed; byte-identical every run) + in the identical int8 + SigMF format, so downstream pipelines are never + blocked by RF conditions or a missing board. Reports log every candidate + tried with its pilot SNR, keeping runs at different times comparable. +- Per-capture validation in `examples/collect_sample_data.py`: short reads, + dead/quiet front end, gain-induced clipping, stuck DC, and ADC utilization + (peak amplitude < 0.1, i.e. fewer than ~13 of 127 int8 codes) are flagged. + Validation is band-aware: bands are tagged `continuous` (fm), `bursty` + (airband, ism433, ism915), or `scheduled` (noaa), and low utilization is a + hard SUSPECT only for continuous bands -- on bursty bands a quiet window is + correct data and is annotated as a noise-floor reference instead. A + peak-to-median burst-ratio metric reports detected activity. Suspect + captures are kept on disk but flagged on stderr, marked in the generated + sample-data README, and the script exits 2 so a bad collection run cannot + silently ship. (Motivated by a real 2026-09-17 run whose captures spanned + only +/-4 of 127 int8 codes -- effectively 3-bit recordings -- while passing + every earlier check.) +- `--hunt` / `--hunt-secs` in `examples/collect_sample_data.py`: for bursty + bands, probe in 100 ms slices until a transmission appears (burst ratio + >= 10 dB), then take the real capture -- so an ISM sample can be made to + actually contain a burst (e.g. by pressing a key fob during the hunt window). +- The generated sample-data README now annotates each IQ file as a + *(noise-floor reference)* or *(burst captured)*. +- `.gitignore`: `tests/fm_testdata/` (deterministic, regenerable output). +- `tests/test_interrupt_clean.py`: the clean-interrupt contract as a tested + guarantee on BOTH platforms, replacing the "untested on Windows" caveat in + `core.py`. The stub child now records WHICH signal it caught, so the tests + distinguish the clean interrupt (SIGINT on POSIX; SIGBREAK from + CTRL_BREAK_EVENT on Windows, traversing the `.bat` launcher layer like the + real tools) from the `terminate()` escalation -- previously a broken + CTRL_BREAK path could hide behind a working escalation. Also asserted: + output written by the child's interrupt handler is drained into `stop()`'s + result (the no-truncated-capture contract), and a child that ignores the + interrupt is still reaped and reported unclean. The Windows CI leg proves + the Windows path on the next push. +- OS dead-man for handle-mode children (closes the long-standing + `TODO(os-deadman)` in `core.py`): the atexit backstop never runs on + SIGKILL / TerminateProcess, so a hard-killed parent could leave a + transmitter on the air. Now the OS itself ends the child when the parent + dies: Linux children set `PR_SET_PDEATHSIG` to SIGINT in preexec (the + CLEAN interrupt -- the dead-man is the flush path, with a `getppid()` + check closing the fork-window race), and Windows children are assigned to + a Job Object with `KILL_ON_JOB_CLOSE` (terminating the whole + `.bat` -> python tree; the job handle lives on the `_Process`). macOS has + no equivalent primitive; the atexit backstop remains the net there, + documented. Gated exactly like the atexit registry: TX always, RX unless + `backstop_rx=False`. `tests/test_deadman.py` proves it with a real + SIGKILL of an intermediate parent on POSIX -- the child dies AND its + interrupt handler ran -- plus the opt-out gate; the Windows Job Object + leg proves on the next CI push. +- No-board detection reported nothing useful on Linux: `hackrf_info` there + prints its version lines and "No HackRF boards found." to STDOUT and + exits 1 with an empty stderr, so `detect()` raised on the exit code and + discarded everything -- `problem` came back blank and `tools_version` + `None`. `detect()` now parses whatever the tool printed regardless of + exit code and reports the tool's own words, and tool errors in general + fall back to the last stdout line when stderr is empty. Found and + verified against the real 2023.01.1 Linux binaries (no board attached); + regression tests pin both behaviors. +- Documentation refresh across both READMEs and CONTRIBUTING: the root + README's claim that an OS-level dead-man was "not yet implemented" (it now + is, and is tested), four dangling links to a `project_summary.md` that does + not exist (scope statements are now inline), the CLI reference extended + with `monitor`, `scan`, `sweep -o/-B/-I`, and `tx --cw`, the + `monitor_frequencies` notes now state the covering-bin semantics, the + package README's features/platform/transmit sections updated for the + lifecycle-safety work, CONTRIBUTING's "mypy (advisory for now)" corrected + to blocking, and the PR checklist extended (changelog entry, docstring + gate, coverage gate). +- SigMF spec compliance is now tested, not trusted: the official + `sigmf` package joined the dev dependency group, and the sidecar writer's + output is validated with it -- including that the `hackrf` extension is + DECLARED, not just used (a strict-validator rejection that regressed once + pre-1.0). GNU Radio / IQEngine interop is a tested property. +- Docstrings across the entire public API: every public method on + `HackRF` and `PersistentReceiver`, both classes, and the module-level + functions -- 60 docstrings where `help()` previously returned nothing. + The documentation used to live only in `#` comments, invisible to + `help()`, IDE tooltips, and doc generators; the comments (which carry the + rationale) remain, and the docstrings carry the contract. Includes the + print-cmd caveat on `transmit`/`transmit_cw`: duration bounds are + enforced parent-side, so a copied `--print-cmd` argv carries NO time + bound. `tests/test_docstrings.py` gates the whole surface so it cannot + drift back to undocumented. +- CLI caught up with the library, each command with tests: + `hrf tx --cw` (a bounded CW test tone; requires `-d/--duration` because an + unbounded carrier is exactly the orphan-transmitter risk the library + exists to prevent; `--cw-amplitude` defaults below full scale), + `hrf monitor` (sweep-backed multi-frequency power to stdout), + `hrf scan` (per-frequency capture power), and `hrf sweep -o FILE` with + `-B` / `-I` binary passthrough (which refuse the CSV stdout path, since + that output is unparsed). +- Thread-safety documented (rescued by the roadmap-comment cleanup; it was + recorded nowhere else): a `HackRF` instance is not safe to share across + threads -- per-instance mutable state (`last_params`, persisted mode, + logging wiring) and per-child drain threads. One instance per thread; + instances are cheap. In the README and on the class. +- Coverage policy recorded in CONTRIBUTING: CI gates at + `--cov-fail-under=82`, deliberately under the measured 85% because that + figure counts Windows-only and hardware-only code as missed on Linux legs; + to be revisited upward after the docstring pass. +- `hackrfpy.__version__`, resolved from installed package metadata + (`importlib.metadata`), with a `0.0.0+unknown` fallback for uninstalled + checkouts. +- `examples/fm_demod_to_wav.py`: the missing last mile -- demodulate a + captured broadcast-FM IQ file to an audible mono 16-bit WAV using only + numpy and the stdlib. Channelize (staged windowed-sinc decimation to + 200 kHz), FM-discriminate, 75 us de-emphasis (`--deemph 50` for regions + using 50 us), 15 kHz audio lowpass to a 50 kHz WAV. Handles off-center + stations via `--offset` (e.g. 300e3 for `collect_fm_testdata.py` output) + and prints a multiplex readout proving the 19 kHz pilot, 38 kHz stereo + subcarrier, and 57 kHz RDS are present in the discriminator output. + Developed and verified against the 2026-09-17 98.1 MHz reference capture: + the output audio shows music-shaped spectra, syllabic-band envelope + modulation, and spectral flatness 0.09 (structured content, not noise). ### Changed +- CI platform policy: the test matrix now contains hardware-verified + platforms only -- currently Windows (primary) and macOS -- and Linux was + removed until it is verified against a real board, at which point it + returns under the same standards. The coverage gate rose from 82% to an + 85% minimum on every remaining leg, and the codecov upload moved from the + removed Linux leg to the Windows 3.12 leg. Known consequence, recorded in + the workflow comment: the Linux `pdeathsig` dead-man path only executes on + a Linux runner, so it is regression-untested until Linux re-enters the + matrix. +- CONTRIBUTING policy rewrite to match: 85% minimum coverage, the + hardware-verified-platforms rule, and a new requirement that any change + under `src/hackrfpy/` include evidence of a full-suite run (hardware tests + passing) on Windows with a real board -- CI cannot attach hardware, so + that gate is enforced by review. The `needs_tools` test category (real + binaries on PATH, no board needed) is now documented, which is why + passed/skipped counts differ between machines. +- Second documentation pass: the root README's local-install line pinned a + two-versions-stale wheel name (now a wildcard), its testing notes still + described the Windows CTRL_BREAK path as untested (it now points at the + tests that prove it), and its repo tree was missing three examples; the + package README's platform section now records the real-binaries Linux + verification and the Windows-EXE-on-Linux non-goal, and gained a CLI + quick-start. - Library diagnostics now go through the standard `logging` module instead of `print()`. Records are emitted on the `hackrfpy` logger: warnings at `WARNING`, verbose progress messages at `INFO`. A consumer can now route, @@ -49,8 +227,79 @@ release. `5 - Production/Stable` to match the 1.0.0 release. - Ruff configuration added (`line-length = 100`, `select = ["E", "F", "W"]`), and the codebase made lint-clean so the CI lint job is meaningful. +- `examples/persistent_capture.py` rewritten to match its name and the README's + description: a gapless segment collector draining ONE long-lived + `open_receiver()` stream into back-to-back `seg_NNN.iq` files with SigMF + sidecars (contrast with `capture(segment_secs=...)`, whose per-file process + re-open leaves a short gap between files). The file had been a near-duplicate + of `waterfall_persistent.py` whose own header pointed at the wrong filename. +- `examples/waterfall_persistent.py` absorbed the improvements stranded in that + duplicate: frame-averaged spectra, DC/LO-leakage spike suppression, and + large-block reads so the pipe drains fast enough to keep `hackrf_transfer` + streaming at 10 Msps. +- `hackrfpy/README.md` examples list corrected (`persistent_capture.py` entry + now matches the code) and extended with the new examples, including a new + Transmit section. +- `examples/collect_sample_data.py` defaults: collects three bands (fm, + ism433, ism915) instead of one, and exposes `--lna` / `--vga` with defaults + raised to 32 / 28 (the old library defaults of 16 / 20 produced the 3-bit + captures described above). +- `tests/collect_real_FM_data.py` defaults: gains raised to LNA 32 / VGA 28, + and default sample rate raised from 2 Msps to 8 Msps -- the HackRF's + baseband filter bottoms out at 1.75 MHz, so rates under 8 Msps admit + aliases; a reference recording deserves the alias-free version at the price + of larger files. +- `tests/collect_fm_testdata.py` captures at an integer multiple of the + requested output rate that clears 8 Msps and decimates in software + (windowed-sinc FIR) back down, so output files are unchanged (2 Msps int8 + + SigMF by default) while discovery and demodulation run alias-free. Offline + verification: an interferer 1.7 MHz off-channel, which a direct 2 Msps + capture folds onto the demod region, is suppressed ~45 dB while the pilot + survives at ~72 dB SNR. ### Fixed +- `monitor_frequencies()` emitted partial updates several times per sweep + pass on real hardware: the pass boundary was a timestamp change, but real + `hackrf_sweep` timestamps each row individually (the test stubs shared one + timestamp per pass, which hid it), so over a wide span most watched + frequencies read `None` in every update -- seen live as a wall of `--`. + The boundary is now the sweep wrap (a segment arriving a second time), + which is timestamp-independent; every update covers every watched + frequency the span covers, pinned by a regression test with per-row + timestamps. The `channel_monitor.py` example also now redraws its meter + in place (ANSI, Windows VT enabled, scrolling fallback when piped) + instead of scroll-printing each frame. +- Abandoning a live stream could leak a child that held the USB claim for + the rest of the process: with the consumer gone, the 64 KB stdout pipe + fills in milliseconds at capture rates and the child blocks inside + write(); hackrf tools' SIGINT/SIGTERM handlers only set an exit flag that + a blocked write never returns to check, and the breakout teardown ended + at an unreaped terminate() -- so hackrf_transfer stayed frozen in + write(), and every later open in the same process failed + `hackrf_open() failed: Resource busy (-1000)`. Found on the Linux + hardware verification run: a wall of 7 failures starting immediately + after the two stream-breakout tests, unmoved by open-retries (the claim + was held, not slow to release). Teardown now gives the clean interrupt a + short window with the pipe open (well-behaved children still flush and + exit cleanly -- tested), then closes the read end so a frozen write + becomes EPIPE and the child can die (the kernel releases the USB claim + on any death), then completes the terminate -> kill ladder. Three + regression tests reproduce the frozen-writer state with a deaf, flooding + stub and pin bounded teardown, immediate reopen, and the preserved + clean-exit window. +- Rapid back-to-back device operations could fail with + `hackrf_open() failed: Resource busy (-1000)`: after a `hackrf_*` child + exits, the kernel takes a moment to release its USB claim, and the next + open can lose that race -- found on the first real-hardware Linux run, + where 7 hardware tests failed with exactly this error (Windows' USB stack + never exposed it). The library now absorbs it with bounded, backed-off + retries in every acquisition mode: blocking/timed runs re-invoke, and + streaming paths (sweep, monitor, the persistent receiver) respawn only if + the child died busy BEFORE yielding anything -- once data has flowed, a + busy error cannot be a stale-claim race and is raised as-is. Tunable via + `busy_retries` (default 3) and `busy_backoff` (0.25 s, doubling); + `busy_retries=0` restores fail-fast. The test stub can now simulate the + race (`busy_fails=N`), and five tests pin the behavior across modes. - The `atexit` backstop never reaped an orphaned `PersistentReceiver`. `PersistentReceiver` registers itself in the live-handle registry, whose shutdown hook calls `if h.is_alive(): h.stop()` -- but `is_alive()` did not @@ -79,16 +328,46 @@ release. directory on every run. - `CITATION.cff` version and release date corrected to `1.0.0` / `2026-06-16`, aligning the citation metadata with `pyproject.toml` and the tagged release. - - +- Removed a stray `hackrfpy/capture.sigmf-meta` at the package root, left over + from a July test run at 433.92 MHz. +- `examples/fm_demod_to_wav.py` de-emphasis was accidentally quadratic: each + 64 K block was convolved against a full-block-length geometric kernel + (~5e10 multiply-adds for a 2 s / 8 Msps capture), stalling the script after + the multiplex readout. The kernel is now truncated where its weight falls + below 1e-9 (a few hundred taps at 200 kHz); a 2 s reference file demodulates + in ~3 s end to end, with output identical to within 1 LSB. Stage timings are + now printed so a future stall is self-locating. +- `sweep()` / `sweep_to_file()` silently truncated the TOP of the requested + band: both MHz edges were floored, so `sweep(433.9e6, 434.1e6)` ran + `433:434` and never covered 434.0-434.1 MHz even while warning about the + snap. Edges now snap OUTWARD (floor the low edge, ceil the high edge) so + the swept range always contains the requested band; the warning text says + so. +- `monitor_frequencies()` reported the MEAN dB of the whole covering sweep + segment as the power "at" a frequency, diluting a narrowband carrier + toward the noise floor (a -20 dB carrier in one bin of a 10-bin segment + read as -74). It now reads the bin covering the frequency (max of that + bin +/-1 for tuning slop). Behavior change for `examples/channel_monitor.py` + and any monitor consumer: readings of narrowband signals rise to their + true level. +- Handle-mode output was not visible to the parent until process exit unless + the child flooded: the drain thread read pipes with `BufferedReader.read(N)`, + which blocks until N bytes (64 KB) accumulate, so small periodic writes -- + e.g. hackrf_transfer's ~60-byte-per-second stats lines -- sat invisible for + what would be ~18 minutes of real capture. Now `read1()`: bytes appear in + the drain as soon as the child writes them. Found by the new clean-interrupt + tests; also cut the test suite's wall time roughly in half by removing the + same latency from every stubbed lifecycle test. (Found by the new + clean-interrupt tests.) +- `_Process.stop()`'s escalation fired `terminate()` and returned without + reaping: a child that ignored the interrupt could outlive `stop()`, and + `result()` reported `returncode None`. The escalation is now a bounded, + reaped ladder (interrupt -> terminate -> kill), so `stop()` always returns + with the child dead and a real exit status. (Found by the new + clean-interrupt tests.) +- `from_device()` guarded its parsed-info invariant with a bare `assert`, + which `python -O` strips, letting raw text flow onward; it now raises + `HackRFDeviceError`. ## [1.0.0] - 2026-06-16 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a655384..a40a879 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,10 @@ path. or code that reproduces it. - **Request a feature** — open an issue with the feature template. Note that signal processing (demod, FFT, waterfalls) is intentionally **out of scope** - for this repo; see `project_summary.md`. + for the library API — this project is transport + control. The `examples/` + directory may demonstrate downstream processing (`fm_demod_to_wav.py`) to + show where the boundary sits, but the API stays at `complex64` in, files + out. - **Improve docs** — README fixes, clearer examples, and beginner notes are all welcome and don't require hardware. - **Submit code** — see the workflow below. @@ -44,12 +47,25 @@ Run these from the `hackrfpy/` directory: uv run pytest -m "not hardware" # full hardware-free suite must pass uv run ruff check . # lint uv run ruff format . # apply formatting -uv run mypy src/hackrfpy # type check (advisory for now) +uv run mypy src/hackrfpy # type check (BLOCKING in CI) ``` -CI runs the same suite across Windows, Linux, and macOS on Python 3.11–3.13. -Windows is the primary target platform, so process-lifecycle changes must pass -there specifically. +Also expected with a code PR: + +- **A CHANGELOG entry** under `[Unreleased]` — this project's changelog is + detailed and rationale-bearing; say what changed and why. +- **Docstrings on new public API** — `tests/test_docstrings.py` gates the + whole public surface and will fail your PR without them. +- **Coverage** — CI gates at 85% (see the policy at the bottom of this file); + new code arrives with its tests. +- **Hardware evidence for core changes** — any change under `src/hackrfpy/` + requires a full-suite run on Windows with a real HackRF attached (hardware + tests passing), reported in the PR. See the policy below. + +CI runs the suite on Windows and macOS across Python 3.11–3.13. Windows is +the primary — and only hardware-verified — platform, so process-lifecycle +changes must pass there specifically. Linux is not in the CI matrix: see the +platform standard in the coverage policy below. ## Adding a command @@ -75,20 +91,49 @@ tests import. Don't hard-code envelope numbers in a method. ## Tests that touch hardware Tests that need a real board are marked `@pytest.mark.hardware` and self-skip -when no device is detected. Parser fixtures live in `tests/fixtures/` and are -frozen from real hardware output via `tests/collect_real_data.py`. If you add a -parser, add a real-output fixture rather than a hand-written one where possible. +when no device is detected. A second, lighter category is marked with a +`needs_tools` skipif: those need the real `hackrf-tools` binaries on `PATH` +but **no board**, so they run on any machine with the tools installed — which +is why passed/skipped counts differ between machines. Parser fixtures live in +`tests/fixtures/` and are frozen from real hardware output via +`tests/collect_real_data.py`. If you add a parser, add a real-output fixture +rather than a hand-written one where possible. ## Commit and PR conventions - Keep PRs focused; one logical change per PR is easiest to review. - Reference the issue the PR closes (`Closes #123`). -- Describe what you tested, and whether it was tested against real hardware or - stubs only. -- New public methods need a README entry in the Method Reference and, ideally, a - runnable example under `examples/`. +- Describe what you tested. For changes under `src/hackrfpy/` (the core + library), a full-suite run on Windows with a real HackRF attached — hardware + tests passing — is **required**, and the PR should say so (paste the pytest + tail). Docs, examples, and test-only changes are exempt. CI cannot attach a + board, so this is the human half of the quality gate; maintainers may + re-verify on their own hardware before merge. +- New public methods need a README entry in the Method Reference in the main repo README + and, ideally, a runnable example under `examples/`. ## License By contributing, you agree that your contributions are licensed under the project's **GPL-2.0-or-later** license. + +## Test coverage and platform policy + +CI gates coverage at **85% minimum** (`--cov-fail-under=85`, applied on every +leg of the CI matrix). Coverage below the gate fails the build; new code +arrives with the tests that keep it above. + +**The CI matrix contains hardware-verified platforms** — currently Windows +(primary) and Linux (verified 2026-09-19), plus macOS pending verification. +A platform is included when it is verified against a real HackRF +board, and is then held to the same standards (the 85% gate and the +hardware-evidence requirement below). + +**Core library changes require a passing hardware test.** CI has no board +attached, so the stub suite is necessary but not sufficient: any PR that +changes code under `src/hackrfpy/` must include evidence of a full-suite +run — hardware tests included and passing — on Windows with a real HackRF +attached. Paste the pytest tail in the PR description. Changes limited to +docs, examples, or tests are exempt. The gates that CI *can* enforce +(coverage, lint, types, docstrings) stay automated; this one is enforced by +review. \ No newline at end of file diff --git a/README.md b/README.md index 5873450..bb97d96 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ## An UNOFFICIAL Python CLI + scripting wrapper for the HackRF One -A non-GUI Python wrapper and command-line tool for the [HackRF One](https://greatscottgadgets.com/hackrf/one/) software-defined radio. +A non-GUI Python wrapper and command-line tool for the [HackRF One](https://greatscottgadgets.com/hackrf/one/) software-defined radio. Designed to work on Windows, also works on other OS. This repository uses official resources and documentation but is **NOT** endorsed by Great Scott Gadgets or the HackRF project. See the [references](#references) section for further reading. See the [official HackRF documentation](https://hackrf.readthedocs.io/) and the [GitHub project](https://github.com/greatscottgadgets/hackrf) for official documentation of device behavior. @@ -29,6 +29,7 @@ The primary GitHub: [https://github.com/LC-Linkous/hackRF_python](https://github * [Library Usage](#library-usage) * [Local Install Using UV](#local-install-using-uv) * [Installing hackrf-tools](#installing-hackrf-tools) + * [Linux Setup (Debian)](#linux-setup-debian) * [Requirements](#requirements) * [Structure](#structure) * [The Operating Envelope](#the-operating-envelope) @@ -71,7 +72,7 @@ The primary GitHub: [https://github.com/LC-Linkous/hackRF_python](https://github ## The HackRF One Device -The [HackRF One](https://greatscottgadgets.com/hackrf/one/) is a wide-band, half-duplex software-defined radio from Great Scott Gadgets. It tunes from 1 MHz to 6 GHz and samples at up to 20 Msps (2 Msps floor). It is **half-duplex** (it receives or transmits, but never both at once) and **8-bit**: samples are quantized to signed 8-bit I and Q, interleaved. That interleaved int8 I/Q is the native format this library reads and writes, on both the receive and transmit paths. +The [HackRF One](https://greatscottgadgets.com/hackrf/one/) is a wide-band, half-duplex software-defined radio from Great Scott Gadgets. It tunes from 1 MHz to 6 GHz and samples at up to 20 Msps (2 Msps floor). It is **half-duplex** (it receives or transmits, but never both at once) and **8-bit**: samples are quantized to signed 8-bit I and Q, interleaved. That interleaved int8 I/Q is the native format this library reads and writes, on both the receive and transmit paths. Because there is one radio and it can't do both directions at once, the library carries an explicit operating mode (RX or TX), with a deliberate gate before transmit — see [Operating Modes and the TX Gate](#operating-modes-and-the-tx-gate). @@ -87,7 +88,7 @@ Wrapping the binaries instead of binding the C library shapes everything downstr * **No compiled dependencies.** This is the largest change from previous versions of this tool and other Python libraries. There is no `libhackrf` to link, no build step, and no wheel that has to match your platform's C toolchain. The tradeoff is that the binaries for the SDR are a *system* dependency you install separately, but it is what lets this library run on Windows without managing multiple installs. * **The library manages the hackrf-tools child processes.** It handles draining stdout/stderr without deadlocking, stopping hackrf_transfer cleanly so recordings aren't truncated mid-sample-pair, reaping children when a consumer exits early, and shutting down the transmitter if the script dies. Tests cover these paths. * **Everything funnels through one method.** Every binary invocation goes through `_run(argv, mode=...)`. The `mode` selects the lifecycle: `blocking` (run to completion), `timed` (run N seconds then stop), `handle` (return a controllable process), or `stream` (yield output as it arrives). Centralizing this is what makes the lifecycle testable. -* **The library yields `complex64`; it does not interpret samples.** Decoding interleaved int8 to normalized `complex64` is the boundary. Filtering, demodulation, FFTs, waterfalls — deliberately *not* here. See [project_summary.md](project_summary.md) for the split between this project (transport + control) and the planned signal-processing project. +* **The library yields `complex64`; it does not interpret samples.** Decoding interleaved int8 to normalized `complex64` is the boundary. Filtering, demodulation, FFTs, waterfalls — deliberately *not* in the library API; this project is transport + control, and signal processing belongs downstream. The `examples/` directory may *demonstrate* downstream processing (`fm_demod_to_wav.py` turns a capture into audible audio) precisely to show where the boundary sits. ## Library Usage @@ -121,7 +122,7 @@ To build a distributable wheel: ```bash # produces dist/ in the package directory uv build -pip install dist/hackrfpy-0.1.0-py3-none-any.whl +pip install dist/hackrfpy-*-py3-none-any.whl # match the version uv build produced ``` You can use another package manager if you prefer; the package metadata is all in `pyproject.toml`. Manual install equivalents: @@ -134,11 +135,11 @@ pip install -e . # editable, library only ### Installing hackrf-tools -This library requires the `hackrf-tools` binaries on the host. They are **not** a pip dependency; they are installed at the OS level. This package cannot install them. +This library requires the `hackrf-tools` binaries on the host. They are **not** a pip dependency; they are installed at the OS level. This package cannot install them. Only the command line tools are required. (`hackrf_info`, `hackrf_transfer`, `hackrf_sweep`, and the device-management utilities). -* **Linux:** `sudo apt install hackrf` (Debian/Ubuntu) or your distribution's equivalent. This installs the tools and the udev rules; you may need to be in the `plugdev` group for non-root USB access. +* **Linux:** `sudo apt install hackrf` (Debian/Ubuntu) or your distribution's equivalent — full setup notes in [Linux Setup (Debian)](#linux-setup-debian) below. * **macOS:** `brew install hackrf`. * **Windows:** Great Scott Gadgets publishes the tools as CI build artifacts for some workflow runs. You do **not** need GNU Radio, SoapySDR, or a full SDR distribution. If you have radioconda installed, then you have the binaries, and GNU Radio and a full conda environment you don't need for this library. Download the prebuilt `hackrf-tools` from the [Great Scott Gadgets github](https://github.com/greatscottgadgets/hackrf) (or via a package manager that provides them). @@ -155,6 +156,40 @@ hackrf_info If that prints a board (or at least runs), the binaries are reachable. If it is not on your `PATH`, use `tools_dir` (Python) or `[tools].dir` in config. +### Linux Setup (Debian) + +**Linux is verified with real hardware** (2026-09-19: Debian 12 bookworm, kernel 6.12.95, `hackrf` 2022.09.1, firmware 2024.02.1 — full test suite including all hardware tests, 227/227) and sits in the CI matrix under the same standards as Windows (see the platform policy in `CONTRIBUTING.md`). Mechanics were additionally verified on Ubuntu 24.04 with `hackrf` 2023.01.1, so both tools versions are known-good. macOS remains experimental pending its own board run. + +Unlike Windows, no artifact download is needed: `sudo apt install hackrf` installs all the `hackrf_*` binaries onto `PATH` (no compiling, no `tools_dir` configuration) **and** the udev rules for USB access. `hackrf_info` then has two healthy outcomes — with no board: version lines, then `No HackRF boards found.`, exit code 1 (the library reports this verbatim from `detect()`); with a board: `Found HackRF` with serial, board ID, and firmware. + +**USB permissions — no sudo, no exceptions.** The package ships `/lib/udev/rules.d/60-libhackrf0.rules`, which grants the HackRF One (USB `1d50:6089`) to the **`plugdev`** group. Never run the tools or the library with `sudo`; join the group instead: + +```bash +sudo usermod -aG plugdev "$USER" +# log out and back in (or `newgrp plugdev`), then REPLUG the board +hackrf_info # must work without sudo before going further +``` + +The Python environment is identical to Windows (`uv sync`, then run everything through `uv run`). Without a board, the hardware-marked tests self-skip while the `needs_tools` tests run, because the binaries are on `PATH`; with a board attached, everything runs. The verification sequence — the same run that verified Linux on 2026-09-19 — is: + +```bash +uv run pytest -q # full suite, hardware included +uv run hrf detect # library sees the board +uv run hrf monitor 98.1M -d 10 # live sanity: FM power moves +uv run python examples/collect_sample_data.py # validated captures +``` + +If you reproduce a fully green suite on another distribution or tools version, please report it (distribution, kernel, `hackrf` package version, firmware version) — each new combination widens the known-good record, and the original verification run surfaced two real process-lifecycle bugs, so running this sequence is not a formality. + +| Symptom | Cause / fix | +|---|---| +| `hackrf_info: command not found` | `sudo apt install hackrf`; new shell | +| `No HackRF boards found.` | Board unplugged, bad cable/port, or DFU mode | +| Permission denied without sudo | `plugdev` group + replug (above) | +| Works as root only | Same — fix the group, don't keep sudo | +| Old tools version in apt | Fine for this library (verified with 2022.09.1 and 2023.01.1); build from source only if you need newer device features | +| Pointing `tools_dir` at the Windows `.EXE` bundle | Never works on Linux — each platform uses its own native tools | + ## Requirements @@ -180,7 +215,6 @@ The public API is `from hackrfpy import HackRF`. Per-command methods live in mix ``` hackRF_python/ repo root (this README, CITATION, etc.) ├── README.md -├── project_summary.md this-project vs next-project split ├── hackrfpy/ the installable package + its dev tree │ ├── pyproject.toml │ ├── README.md package README (dev/install quickref) @@ -212,7 +246,10 @@ hackRF_python/ repo root (this README, CITATION, etc.) │ │ ├── persistent_capture.py multi-segment capture, one process │ │ ├── benchmark.py measure decode/throughput/latency │ │ ├── calibrate.py calibration workflow (Levels 2-3) +│ │ ├── channel_monitor.py multi-frequency power meter (one sweep) +│ │ ├── tx_test_tone.py the one transmitting example (gated, bounded) │ │ ├── collect_sample_data.py real sample-data collector (read-only) +│ │ ├── fm_demod_to_wav.py capture -> audible WAV (downstream demo) │ │ └── sample_data/ committed real recordings + SigMF + README │ └── tests/ │ ├── conftest.py cross-platform stub-binary factory @@ -269,11 +306,11 @@ uv run pytest -m hardware uv run pytest --cov=hackrfpy --cov-report=term-missing ``` -> **Note:** this is a configured uv project, so `uv run pytest` uses the synced venv and editable install. **Run the scripts the same way** (such as `uv run python tests/collect_real_data.py ...`), not wih the bare `python ...`. The bare `python` call might work with some setups, but it is an easy source of error if uv creates a second virtual environment on a lower level. +> **Note:** this is a configured uv project, so `uv run pytest` uses the synced venv and editable install. **Run the scripts the same way** (such as `uv run python tests/collect_real_data.py ...`), not with the bare `python ...`. The bare `python` call might work with some setups, but it is an easy source of error if uv creates a second virtual environment on a lower level. The suite is split into hardware-free tests and tests marked `@pytest.mark.hardware`, which auto-skip when no board is detected. Hardware detection is intentionally **not cached**, so you can plug/unplug between runs. -**A note on cross-platform coverage:** the process-lifecycle tests (the riskiest code) use **cross-platform stub binaries**, not bash scripts, so they run on Windows, which is the platform this library targets. Stubs are generated by a factory in `conftest.py` that writes a small Python program plus a launcher (`.bat` on Windows, a shebang'd file on POSIX). This matters because the Windows interrupt path (`CTRL_BREAK_EVENT`, used to stop a running `hackrf_transfer`) is otherwise untested on the exact platform where it must work. Run `pytest tests/test_lifecycle_xplat.py` on Windows to prove the reap/stop machinery before attaching hardware. +**A note on cross-platform coverage:** the process-lifecycle tests (the riskiest code) use **cross-platform stub binaries**, not bash scripts, so they run on Windows, which is the platform this library targets. Stubs are generated by a factory in `conftest.py` that writes a small Python program plus a launcher (`.bat` on Windows, a shebang'd file on POSIX). The Windows interrupt path (`CTRL_BREAK_EVENT`, used to stop a running `hackrf_transfer`) is tested explicitly: `tests/test_interrupt_clean.py` asserts the interrupt signal itself arrives (not the terminate escalation) and that the child's final flush survives into the result, and `tests/test_deadman.py` proves a hard-killed parent cannot orphan a child. Run `pytest tests/test_lifecycle_xplat.py tests/test_interrupt_clean.py tests/test_deadman.py` on Windows to prove the reap/stop machinery before attaching hardware. **Hardware-validated parsing.** The parsers (`parse_info`, `parse_sweep_line`, IQ decode) are tested against included sample data in `tests/fixtures/*_real.*`. Data can be collected from real hardware with `tests/test_real_output.py`. @@ -295,7 +332,7 @@ Two feedback toggles control how chatty the library is: * `set_verbose(True/False)` (or `HackRF(verbose=True)`) — prints status/diagnostic messages (which mode it's in, capture size estimates, what a method did). * `allow_out_of_spec` (or `--force` on the CLI) — downgrades a reject-by-default range error to a stderr warning instead of an exception. Use with care; it exists for the rare legitimate out-of-spec case, not as a way to silence validation. -Safety and correctness warnings (gain snapping, sub-recommended sample rate, forced out-of-spec, MHz edge truncation on sweep) print to **stderr unconditionally**. This is not blocked by the `verbose` toggle, because these are places where the library is still not fully stable. +Safety and correctness warnings (gain snapping, sub-recommended sample rate, forced out-of-spec, outward MHz edge snapping on sweep) print to **stderr unconditionally**. This is not blocked by the `verbose` toggle, because these are places where the library is still not fully stable. Validation is not exhaustive, and the binaries and device do their own checks, so always consult the official documentation for valid ranges. @@ -468,7 +505,7 @@ h.transmit(433.92e6, 8e6, "signal.iq", txvga=20) h.transmit(433.92e6, 8e6, "beacon.iq", repeat=True, max_duration=30.0) ``` -`max_duration` is best-effort: it stops the child on schedule from within the process. It does not survive a hard kill of the parent (`kill -9` / power loss); that needs an OS-level dead-man not yet implemented (see [project_summary.md](project_summary.md)). +`max_duration` stops the child on schedule from within the process. Beneath it sit two more safety tiers: an `atexit` backstop that reaps live handles on normal interpreter exit, and an **OS dead-man** for the deaths `atexit` cannot see — on Linux the child sets `PR_SET_PDEATHSIG` to SIGINT (the clean flush path) so the kernel ends it the moment the parent dies, and on Windows the child runs inside a Job Object with `KILL_ON_JOB_CLOSE`, so even a `kill -9` / TerminateProcess of the parent cannot leave a transmitter on the air (power loss takes the device down with the host anyway). Both paths are covered by `tests/test_deadman.py` with a real parent hard-kill. ### Reading Recordings Back @@ -489,7 +526,7 @@ fc = meta["captures"][0]["core:frequency"] ### Sample Data (no board required) Real recordings have been included under `examples/sample_data/` so the library basics can be tested without hardware. Each -`.iq` is interleaved int8 I/Q with a `.sigmf-meta` sidecar, plus sweep CSVs. The README in `examples/sample_data/`notes the firmware/tools that produced the data. +`.iq` is interleaved int8 I/Q with a `.sigmf-meta` sidecar, plus sweep CSVs. The README in `examples/sample_data/` notes the firmware/tools that produced the data. ```python from hackrfpy import load_iq, read_sigmf_meta @@ -518,10 +555,13 @@ The `examples/` directory has end-to-end scripts you can run against a board (al | `sweep_collect.py` | One sweep across a band saved to CSV. | | `waterfall_realtime.py` | Live spectrum waterfall (needs the `[plotting]` extra). | | `waterfall_persistent.py` | Single-frequency FFT waterfall over time, driven by a persistent receiver — the complement to the sweep waterfall (one channel evolving vs. a wide band). Needs the `[plotting]` extra. | -| `persistent_capture.py` | Collect many segments at one frequency from a single long-lived process (amortizes startup). | +| `persistent_capture.py` | Gapless back-to-back segments at one frequency from one long-lived receive process — contrast with `capture(segment_secs=...)`, whose files have a short re-open gap. | +| `channel_monitor.py` | Live power meter on several frequencies at once via `monitor_frequencies` — one continuous sweep, no plotting extra needed. | | `benchmark.py` | Measure decode throughput, sustained-rate drop behavior, and callback latency on your hardware. | | `calibrate.py` | Calibration workflow (Levels 2–3): derive an absolute-ish `offset_db` from a known reference and/or a frequency-response curve, saved to `calibration.json`. | -| `collect_sample_data.py` | Collect real sample datasets into `examples/sample_data/`. | +| `collect_sample_data.py` | Collect real sample datasets into `examples/sample_data/`, with per-capture validation. | +| `fm_demod_to_wav.py` | Demodulate a captured FM broadcast IQ file to an audible mono WAV — the downstream-processing boundary demo (numpy + stdlib only; never touches the device). | +| `tx_test_tone.py` | The one transmitting example: a bounded CW test tone behind the TX-mode gate, with `--print-cmd` dry-run. | Run any of them through uv so the project environment is used, e.g. `uv run python examples/device_explorer.py`. @@ -591,7 +631,7 @@ with h.open_receiver(100e6, 8e6) as rx: #### `monitor_frequencies` * **Signature:** `monitor_frequencies(freqs_hz, *, span_hz=2_000_000, duration=None, on_update=None, lna=16, vga=20, amp=False)` * **Returns:** a list of `{freq_hz: power_db}` dicts (one per sweep pass), or `None` if `on_update` is given. -* **Notes:** watch **power over time** at several frequencies, backed by `hackrf_sweep`'s fast internal retuning. Deliberately separate from `scan_frequencies`: that one returns **IQ samples** (per-frequency captures); this returns **power** (spectrum bins) and never yields IQ. Use it for "is there activity on these channels?" monitoring. `on_update(update)` returning `False` stops it. +* **Notes:** watch **power over time** at several frequencies, backed by `hackrf_sweep`'s fast internal retuning. Deliberately separate from `scan_frequencies`: that one returns **IQ samples** (per-frequency captures); this returns **power** (spectrum bins) and never yields IQ. The reported value is the sweep **bin covering the frequency** (max of that bin ±1 for tuning slop), not a segment average — a narrowband carrier reads at its true level. Use it for "is there activity on these channels?" monitoring. `on_update(update)` returning `False` stops it. #### `sweep_stream` * **Signature:** `sweep_stream(f_min_hz, f_max_hz, **kwargs)` @@ -811,12 +851,19 @@ hrf presets # list band presets hrf rx -f 433.92M -s 8M -n 2000000 -o capture.iq hrf rx --preset ads-b -n 4000000 # a preset can supply the frequency -# sweep (CSV to stdout) +# sweep (CSV to stdout, or to a file; -B / -I binary passthrough need -o) hrf sweep --f-min 88M --f-max 108M +hrf sweep --f-min 88M --f-max 108M -o fm.csv +hrf sweep --f-min 88M --f-max 108M -o fm.bin -B + +# power monitoring and multi-frequency scanning +hrf monitor 98.1M 103.7M -d 10 # power over time via one sweep +hrf scan 98.1M 433.92M -n 262144 # per-frequency capture power (dBFS) # transmit (TX mode required) hrf mode tx hrf tx signal.iq -f 433.92M -s 8M -x 20 +hrf tx --cw -f 433.92M -s 2M -d 2 # bounded CW test tone; -d is mandatory ``` Most commands accept `--print-cmd` to print the underlying `hackrf_*` command without running it, `--force` to downgrade range rejects to warnings, `--serial` to select a board, and `-v/--verbose`. Frequencies accept unit suffixes (`433.92M`, `1.09G`). @@ -833,7 +880,7 @@ uv run pytest # hardware tests self-skip without a device Per-command methods live in mixin modules under `src/hackrfpy/_commands/` and are composed onto the `HackRF` class in `core.py`. Adding a command usually means: add a method to the appropriate mixin, then add a command-construction test (assert the exact `hackrf_*` argv on the happy path via `print_cmd`, and assert nothing runs on the validation-error path). Process-lifecycle changes should be exercised with the cross-platform stub factory in `tests/conftest.py` so they are covered on Windows as well as POSIX. -See [project_summary.md](project_summary.md) for the boundary between this project (transport + control) and the planned signal-processing/visualization project. +The boundary of this project is transport + control: it hands you normalized `complex64` and takes back files to transmit. Signal processing and visualization belong to downstream projects; the examples show where that line sits. ## Notes for Beginners @@ -877,7 +924,7 @@ Portability, especially on Windows. No C extension to compile means no toolchain ### Will there be signal processing (demod, FFTs, waterfalls)? -Not in this library. That is the planned second project. This one stops at delivering `complex64`. +Not in this library — it stops at delivering `complex64`, and signal processing belongs downstream. The examples show where that boundary sits: `fm_demod_to_wav.py` demodulates a capture to audible audio using only numpy and the stdlib, as a demonstration rather than an API. ### How often is this updated? @@ -902,4 +949,4 @@ This project is licensed under the GNU General Public License v2.0. See the [LIC The **code in this repository** is released under GPL-2.0-or-later. This licensing does NOT take priority over the official HackRF releases or the decisions of Great Scott Gadgets, and does NOT apply to their products or firmware. -This software is released **AS-IS** — there may be bugs, especially under active development. It is **UNOFFICIAL**: Great Scott Gadgets does not support, maintain, or bear responsibility for it. You are responsible for operating your hardware safely and legally, particularly when transmitting. +This software is released **AS-IS** — there may be bugs, especially under active development. It is **UNOFFICIAL**: Great Scott Gadgets does not support, maintain, or bear responsibility for it. You are responsible for operating your hardware safely and legally, particularly when transmitting. \ No newline at end of file diff --git a/hackrfpy/README.md b/hackrfpy/README.md index 06b903d..7e684d9 100644 --- a/hackrfpy/README.md +++ b/hackrfpy/README.md @@ -19,20 +19,26 @@ This repository uses official resources and documentation but is **NOT** endorse - **Device Discovery** — detect and identify connected HackRF boards, report firmware and identity - **IQ Capture** — bounded, timed, streaming, or callback-style receive; decoded to normalized `complex64` -- **Spectrum Sweep** — collect or stream `hackrf_sweep` output across a frequency range +- **Spectrum Sweep** — collect or stream `hackrf_sweep` output across a frequency range, plus multi-frequency power monitoring over time - **Transmit** — file playback and constant-wave test mode, behind a deliberate TX-mode gate - **Operating Envelope** — per-parameter range checks and gain snapping against the device's real steps - **SigMF Recordings** — self-describing `.iq` captures with metadata sidecars - **Error Handling** — a typed exception hierarchy and verbose output options +- **Lifecycle Safety** — clean interrupts on both platforms, an `atexit` backstop, and an OS dead-man (pdeathsig / Job Object) so even a hard-killed script cannot orphan a transmitter - **CLI** — the `hrf` command-line shell over the full API ## Platform support -hackrfpy is developed and tested on **Windows**. Running the `hackrf-tools` binaries as subprocesses — rather than binding to `libhackrf` through a C extension — is a deliberate choice so that no compiler or build step is required, which is the main friction point for using a HackRF on Windows. +hackrfpy is developed and tested on **Windows**, and has also been verified on Debian Linux with real hardware. Running the `hackrf-tools` binaries as subprocesses — rather than binding to `libhackrf` through a C extension — is what makes that portability cheap: there is no compiler or build step on any platform, and each OS runs its own native tools. Process control is handled per-platform (`SIGINT` on POSIX, `CTRL_BREAK` on Windows), with the interrupt, flush, and dead-man paths covered by tests on every OS, no skips. -The library is *written* to be cross-platform: binary discovery goes through `shutil.which`, and process control uses `SIGINT` on POSIX and `CTRL_BREAK` on Windows. The `hackrf-tools` binaries are themselves native to Linux and macOS, and the wrapper's non-hardware mechanics (binary resolution, process lifecycle, sweep streaming, IQ decode) pass in CI on Linux. So the library is **expected** to work on Linux and macOS. +A platform is called **tested** here only after the full suite (hardware tests included) passes against a real board: -However, it has **not yet been verified against a real HackRF board** on Linux or macOS. Treat those platforms as **experimental** for now. If you try it there, please [open an issue](https://github.com/LC-Linkous/hackRF_python/issues) to report success or trouble — confirmation from real hardware is exactly what's needed to promote them to supported. +- **Windows** — the primary development platform, verified continuously against real hardware. +- **Linux** — verified 2026-09-19 on Debian 12 (`hackrf` 2022.09.1, firmware 2024.02.1): full suite, 227/227. Mechanics additionally exercised against tools 2023.01.1 on Ubuntu, so both packaged tools versions are known-good. Setup notes for Debian-family systems are in the [main repository README](https://github.com/LC-Linkous/hackRF_python#linux-setup-debian-family). + +**macOS remains experimental**: the mechanics are CI-tested there, but board-attached operation is unverified. If you run it with a board, please [open an issue](https://github.com/LC-Linkous/hackRF_python/issues) — a passing hardware suite is what promotes a platform, and the bar is not a formality: the Linux verification run surfaced and fixed two real process-lifecycle bugs. + +One caveat that applies everywhere: `tools_dir` must point at binaries built for the OS you are on — the Windows `.EXE` bundle will never run on Linux, and vice versa. ## Installation @@ -51,7 +57,7 @@ Python 3.11+ is required. **You also need the `hackrf-tools` binaries**, which are *not* a pip dependency — they are installed separately at the OS level. hackrfpy locates them on your `PATH` (or via a configured `tools_dir`). - **Windows** — *tested.* The tools are published as CI build artifacts under the [Actions tab](https://github.com/greatscottgadgets/hackrf/actions) of the HackRF repo; see the main repository README for the step-by-step. -- **Linux** — *experimental, see [Platform support](#platform-support).* `sudo apt install hackrf` (or your distribution's equivalent). +- **Linux** — *tested.* `sudo apt install hackrf` (or your distribution's equivalent). - **macOS** — *experimental, see [Platform support](#platform-support).* `brew install hackrf`. Verify the tools are installed with `hackrf_info`. @@ -88,9 +94,20 @@ for r in rows: print(r["hz_low"], r["hz_high"], min(r["db"]), max(r["db"])) ``` +The same operations are available from the shell via the `hrf` entry point: + +```bash +hrf detect # is a board attached and ready? +hrf rx -f 433.92M -s 8M -n 2000000 -o capture.iq +hrf sweep --f-min 88M --f-max 108M +hrf monitor 98.1M 103.7M -d 10 # power over time on several frequencies +``` + +Frequencies accept unit suffixes (`433.92M`, `1.09G`), and most commands take `--print-cmd` to show the underlying `hackrf_*` invocation without running it. The full CLI reference is in the repository README. + ## Transmitting -Transmit is gated behind an explicit mode switch, because an accidental transmit is the one operation that can damage equipment or break the law: +Transmit is gated behind an explicit mode switch, because an accidental transmit is the one operation that can damage equipment (or break the law) ```python from hackrfpy import HackRF @@ -100,8 +117,18 @@ h.set_mode("tx") # prints the TX-mode safety banner h.transmit(433.92e6, 8e6, "signal.iq", txvga=20) ``` +A bounded constant-wave test tone is available without a source file: `h.transmit_cw(433.92e6, 2e6, duration=2)`, or `hrf tx --cw -f 433.92M -s 2M -d 2` from the CLI (the duration is mandatory there — an unbounded carrier is exactly the risk the gates exist to prevent). Behind the mode gate sit an `atexit` backstop and an OS dead-man, so even a hard-killed script cannot leave a transmitter on the air. + **Transmitting is regulated.** You are responsible for operating within the law and within your equipment's limits. +## Thread safety + +A `HackRF` instance is **not** safe to share across threads: methods mutate +per-instance state (`last_params`, the persisted operating mode, logging +wiring) without locks. Instances are cheap -- the constructor touches no +hardware -- so create one per thread, or confine all hackrfpy calls to a +single worker thread. + ## Examples The [main GitHub repository](https://github.com/LC-Linkous/hackRF_python) provides runnable examples, grouped by what they demonstrate. @@ -113,9 +140,10 @@ The [main GitHub repository](https://github.com/LC-Linkous/hackRF_python) provid **Acquisition** -- `persistent_capture.py` — collect many segments at one frequency from a single long-lived process +- `persistent_capture.py` — gapless back-to-back segments at one frequency from a single long-lived receive process (contrast with `capture(segment_secs=...)`, whose files have a short re-open gap between them) - `power_meter.py` — live dBFS power meter at one frequency via the callback API - `scan_then_capture.py` — sweep a band, find the strongest bin, then capture there +- `channel_monitor.py` — live power meter on several frequencies at once via `monitor_frequencies` (one continuous sweep, no plotting extra needed) **Sweep and plotting** @@ -130,14 +158,19 @@ The [main GitHub repository](https://github.com/LC-Linkous/hackRF_python) provid **Sample data** -- `collect_sample_data.py` — collect real IQ + sweep datasets (read-only; never transmits) +- `collect_sample_data.py` — collect real IQ + sweep datasets with per-capture validation (read-only; never transmits) +- `fm_demod_to_wav.py` — demodulate a captured FM broadcast IQ file to an audible mono WAV (numpy + stdlib only; file processing, never touches the device) + +**Transmit** + +- `tx_test_tone.py` — the one transmitting example: a bounded CW test tone behind the TX-mode gate, with `--print-cmd` dry-run > Most plotting examples require the optional plotting dependencies: > `pip install "hackrfpy[plotting]"` ## Documentation -For comprehensive documentation, the full method reference, the CLI reference, and the operating envelope: +Every public method carries a docstring: `help(hackrfpy.HackRF)` or `python -m pydoc hackrfpy` is the offline method reference, and a test gates the whole surface so it cannot drift. For the narrative documentation, the CLI reference, and the operating envelope: - **Library GitHub repository**: [https://github.com/LC-Linkous/hackRF_python/](https://github.com/LC-Linkous/hackRF_python/) - **Official HackRF documentation**: [https://hackrf.readthedocs.io/](https://hackrf.readthedocs.io/) (not associated with this library) diff --git a/hackrfpy/examples/channel_monitor.py b/hackrfpy/examples/channel_monitor.py new file mode 100644 index 0000000..6a22cf9 --- /dev/null +++ b/hackrfpy/examples/channel_monitor.py @@ -0,0 +1,99 @@ +#! /usr/bin/python3 +##--------------------------------------------------------------------\ +# hackrfpy 'examples/channel_monitor.py' +# Watch POWER over time on several frequencies at once, backed by one +# continuous hackrf_sweep (monitor_frequencies). Demonstrates the +# library's third acquisition style, distinct from the other two: +# - capture()/open_receiver() -> IQ samples at ONE frequency +# - scan_frequencies() -> IQ samples at several (re-open gaps) +# - monitor_frequencies() this -> POWER ONLY at several, gap-free via +# the sweep's fast hardware retuning +# +# Prints a live per-channel bar meter; no plotting extra needed. +# Read-only / receive-only. +# +# Usage: +# uv run python examples/channel_monitor.py +# uv run python examples/channel_monitor.py --freq 433.92M --freq 915M \ +# --duration 30 +# +# +# Author(s): Lauren Linkous +##--------------------------------------------------------------------\ +import argparse +import os +import sys + +from hackrfpy import HackRF, parse_freq + +BAR_FLOOR, BAR_CEIL, BAR_WIDTH = -80.0, -20.0, 40 + + +def bar(db): + if db is None: + return "?" * 3 + frac = (min(max(db, BAR_FLOOR), BAR_CEIL) - BAR_FLOOR) / (BAR_CEIL - BAR_FLOOR) + n = int(frac * BAR_WIDTH) + return "#" * n + "." * (BAR_WIDTH - n) + + +def main(): + p = argparse.ArgumentParser( + description="Multi-channel power monitor via one sweep (read-only).") + p.add_argument("--freq", action="append", + help="frequency to watch (repeatable; parse_freq notation " + "like 433.92M). Default: 98M, 433.92M, 915M") + p.add_argument("--span", default="2M", + help="sweep margin around min/max watched freq (default 2M)") + p.add_argument("--duration", type=float, default=None, + help="seconds to run (default: until Ctrl-C)") + p.add_argument("--lna", type=int, default=16) + p.add_argument("--vga", type=int, default=20) + p.add_argument("--tools-dir", default=None) + args = p.parse_args() + freqs = [parse_freq(f) for f in (args.freq or ["98M", "433.92M", "915M"])] + + h = HackRF(tools_dir=args.tools_dir) + labels = {f: f"{f/1e6:9.3f} MHz" for f in freqs} + print("[*] monitoring " + + ", ".join(l.strip() for l in labels.values()) + + " (Ctrl-C to stop)") + + # In-place redraw: this is a live METER, so each update overwrites the + # previous frame instead of scrolling a log. os.system("") on Windows + # switches the classic console into ANSI/VT mode (a documented quirk: + # spawning any shell command enables VT processing); it is a no-op + # elsewhere. Piped/redirected output falls back to scrolling so logs + # stay readable. + if os.name == "nt": + os.system("") + redraw_in_place = sys.stdout.isatty() + frame_state = {"drawn": False} + + def on_update(update): + lines = [f" {labels[f]} {bar(db)} " + f"{'--' if db is None else f'{db:6.1f} dB'}" + for f, db in update.items()] + if redraw_in_place: + if frame_state["drawn"]: + # move the cursor up over the previous frame + sys.stdout.write(f"\x1b[{len(lines)}A") + # \x1b[2K clears each line so shorter bars leave no residue + sys.stdout.write("\n".join("\x1b[2K" + ln for ln in lines) + "\n") + sys.stdout.flush() + frame_state["drawn"] = True + else: + print("\n".join(lines) + "\n") + + try: + h.monitor_frequencies(freqs, span_hz=parse_freq(args.span), + duration=args.duration, on_update=on_update, + lna=args.lna, vga=args.vga) + except KeyboardInterrupt: + pass + print("[*] stopped") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hackrfpy/examples/collect_sample_data.py b/hackrfpy/examples/collect_sample_data.py index 4024efd..e8661f8 100644 --- a/hackrfpy/examples/collect_sample_data.py +++ b/hackrfpy/examples/collect_sample_data.py @@ -63,22 +63,77 @@ BANDS = { "fm": { "center": 98_000_000, "sweep": (88_000_000, 108_000_000), - "desc": "FM broadcast band"}, + "desc": "FM broadcast band", "signal": "continuous"}, "airband": { "center": 124_000_000, "sweep": (118_000_000, 137_000_000), - "desc": "VHF airband (AM voice)"}, + "desc": "VHF airband (AM voice)", "signal": "bursty"}, "ism433": { "center": 433_920_000, "sweep": (433_000_000, 435_000_000), - "desc": "433 MHz ISM"}, + "desc": "433 MHz ISM", "signal": "bursty"}, "ism915": { "center": 915_000_000, "sweep": (902_000_000, 928_000_000), - "desc": "915 MHz ISM (US)"}, + "desc": "915 MHz ISM (US)", "signal": "bursty"}, "noaa": { "center": 137_500_000, "sweep": (137_000_000, 138_000_000), - "desc": "NOAA weather satellite downlink"}, + "desc": "NOAA weather satellite downlink", "signal": "scheduled"}, } +def _burst_ratio_db(iq): + # Peak-to-median magnitude: >~10 dB means something transmitted during + # the window; near 0 dB means the window is pure noise floor. + if not len(iq): + return 0.0 + mag = np.abs(iq) + med = float(np.median(mag)) + 1e-9 + return 10 * np.log10((float(mag.max()) / med) ** 2) + + +def _validate_capture(iq, expected_n, signal="continuous"): + # Catch the failure modes of a bad collection run BEFORE the data goes + # into the sample library: short reads, a dead/disconnected front end + # (pure noise-floor silence), a stuck DC rail, and gain-induced clipping. + # Band-aware: a CONTINUOUS band (FM broadcast) with a tiny peak is a bad + # capture, but a BURSTY band (ISM) with a tiny peak usually means nothing + # transmitted during the window -- correct data, noted, not SUSPECT. + # Returns (ok, [reasons], [notes]). + reasons, notes = [], [] + if len(iq) < 0.9 * expected_n: + reasons.append(f"short read ({len(iq)}/{expected_n} samples)") + if len(iq): + mag = np.abs(iq) + power_db = 10 * np.log10(float(np.mean(mag ** 2)) + 1e-20) + if power_db < -70: + reasons.append(f"suspiciously quiet ({power_db:.1f} dBFS -- " + "dead antenna / wrong gain?)") + peak = float(mag.max()) + if peak < 0.1: + msg = (f"low ADC utilization (peak {peak:.3f} < 0.1, " + f"~{int(peak*128)} of 127 int8 codes)") + if signal == "continuous": + reasons.append(msg + " -- raise LNA/VGA; see " + "examples/calibrate.py") + else: + notes.append(msg + f" -- normal for a {signal} band with no " + "transmission in the window; data is a " + "noise-floor reference (try --hunt to catch a " + "burst)") + if signal != "continuous": + br = _burst_ratio_db(iq) + if br >= 10.0: + notes.append(f"activity detected (burst ratio {br:.0f} dB)") + clip = float(np.mean(mag > 0.99)) + if clip > 0.01: + reasons.append(f"clipping ({clip*100:.1f}% of samples -- " + "reduce gain)") + dc = float(abs(np.mean(iq))) + if dc > 0.1: + reasons.append(f"large DC offset ({dc:.3f})") + else: + reasons.append("empty capture") + return (not reasons), reasons, notes + + def _signal_summary(iq): # A quick, honest description of what was captured: mean power and whether # there's evident signal vs noise floor. Not DSP -- just a sanity readout. @@ -90,7 +145,7 @@ def _signal_summary(iq): return f"mean {db:.1f} dBFS, peak |amp| {peak:.3f}" -def collect_band(h, name, args): +def collect_band(h, name, args, suspects, file_notes): band = BANDS[name] os.makedirs(OUT_DIR, exist_ok=True) n = int(args.sample_rate * args.seconds) @@ -101,14 +156,47 @@ def collect_band(h, name, args): print(f"\n== {name}: IQ capture @ {band['center']/1e6:g} MHz " f"({args.seconds}s, {args.sample_rate/1e6:g} Msps) ==") try: + if args.hunt and band.get("signal") in ("bursty", "scheduled"): + # Probe in short slices until something transmits, THEN take the + # real capture -- so a bursty band's sample actually contains a + # burst. Press a key fob / doorbell during the hunt to help. + print(f" hunting for a burst (up to {args.hunt_secs:g}s; " + "trigger: peak > 3x median)...") + import time as _time + t0 = _time.time() + while _time.time() - t0 < args.hunt_secs: + probe = h.capture_array(band["center"], args.sample_rate, + int(args.sample_rate * 0.1), + lna=args.lna, vga=args.vga) + if len(probe) and _burst_ratio_db(probe) >= 10.0: + print(f" burst seen after {_time.time()-t0:.1f}s -- " + "capturing now") + break + else: + print(" no burst within the hunt window; capturing " + "noise floor anyway") h.capture(band["center"], args.sample_rate, num_samples=n, - out=iq_path, sigmf=True) + out=iq_path, sigmf=True, lna=args.lna, vga=args.vga) iq = load_iq(iq_path) size_mb = os.path.getsize(iq_path) / 1e6 print(f" wrote {os.path.basename(iq_path)} " f"({size_mb:.1f} MB, {len(iq)} samples) -- {_signal_summary(iq)}") print(f" + {os.path.basename(iq_path).rsplit('.',1)[0]}.sigmf-meta") + ok, reasons, notes = _validate_capture( + iq, n, band.get("signal", "continuous")) + for note in notes: + print(f" [i] {note}") + if notes: + file_notes[iq_path] = ("burst captured" if any( + "activity" in x for x in notes) else "noise-floor reference") + if not ok: + print(" [!] SUSPECT capture -- " + "; ".join(reasons), + file=sys.stderr) + print(" [!] kept on disk, but re-run this band before shipping " + "it in the sample library", file=sys.stderr) results.append(iq_path) + if not ok: + suspects.append(iq_path) except HackRFError as e: print(f" IQ capture failed: {e}", file=sys.stderr) @@ -135,7 +223,8 @@ def collect_band(h, name, args): return results -def write_readme(collected, args, det): +def write_readme(collected, args, det, suspects=(), file_notes=None): + file_notes = file_notes or {} # A README for the sample library so the data is self-documenting. path = os.path.join(OUT_DIR, "README.md") with open(path, "w", newline="\n") as f: @@ -156,7 +245,10 @@ def write_readme(collected, args, det): "meta = read_sigmf_meta('fm_2Msps.iq')\n```\n\n") f.write("## Files\n\n") for p in collected: - f.write(f"- `{os.path.basename(p)}`\n") + flag = " **(SUSPECT -- failed validation; re-collect)**" \ + if p in suspects else "" + note = f" *({file_notes[p]})*" if p in file_notes else "" + f.write(f"- `{os.path.basename(p)}`{flag}{note}\n") print(f"\n wrote {os.path.relpath(path, _HERE)}") @@ -165,17 +257,29 @@ def main(): description="Collect real sample datasets from a HackRF (READ-ONLY).") p.add_argument("--tools-dir", default=None) p.add_argument("--band", action="append", choices=list(BANDS), - help="band(s) to collect; repeatable. Default: fm") + help="band(s) to collect; repeatable. " + "Default: fm, ism433, ism915") p.add_argument("--seconds", type=float, default=0.5, help="capture duration per band (default 0.5s)") p.add_argument("--sample-rate", type=float, default=2e6, help="sample rate in sps (default 2e6, small + USB-friendly)") p.add_argument("--sweep-count", type=int, default=1, help="sweeps per band dataset (default 1)") + p.add_argument("--lna", type=int, default=32, + help="LNA gain dB (default 32; the old library default of " + "16 produced ~3-bit captures on 2026-09-17)") + p.add_argument("--vga", type=int, default=28, + help="VGA gain dB (default 28)") + p.add_argument("--hunt", action="store_true", + help="for bursty bands (ISM, airband): probe until a " + "transmission appears before capturing, so the " + "sample contains an actual burst") + p.add_argument("--hunt-secs", type=float, default=30.0, + help="max seconds to hunt per band (default 30)") p.add_argument("--no-sweep", action="store_true", help="IQ captures only, skip sweep datasets") args = p.parse_args() - bands = args.band or ["fm"] + bands = args.band or ["fm", "ism433", "ism915"] h = HackRF(tools_dir=args.tools_dir, verbose=False) print("== confirming a real board before collecting ==") @@ -188,17 +292,23 @@ def main(): print(f" ! {w}") collected = [] + suspects = [] + file_notes = {} try: for name in bands: - collected += collect_band(h, name, args) + collected += collect_band(h, name, args, suspects, file_notes) except KeyboardInterrupt: print("\ninterrupted", file=sys.stderr) if collected: - write_readme(collected, args, det) + write_readme(collected, args, det, suspects, file_notes) total = sum(os.path.getsize(p) for p in collected if p.endswith(".iq")) / 1e6 print(f"\n== done: {len(collected)} files, ~{total:.1f} MB of IQ " f"in {os.path.relpath(OUT_DIR, _HERE)} ==") + if suspects: + print(f"== {len(suspects)} capture(s) FAILED validation -- see " + "warnings above; re-run those bands ==", file=sys.stderr) + return 2 return 0 diff --git a/hackrfpy/examples/fm_demod_to_wav.py b/hackrfpy/examples/fm_demod_to_wav.py new file mode 100644 index 0000000..9d6b09a --- /dev/null +++ b/hackrfpy/examples/fm_demod_to_wav.py @@ -0,0 +1,177 @@ +#! /usr/bin/python3 +##--------------------------------------------------------------------\ +# hackrfpy 'examples/fm_demod_to_wav.py' +# The missing last mile: turn a captured IQ file into AUDIBLE audio. +# Broadcast-FM demodulation -- channelize, discriminate, de-emphasize, +# write a mono 16-bit WAV -- using only numpy and the stdlib. +# +# Chain (rates for the default 8 Msps reference capture): +# 1. load IQ + SigMF sidecar (rate and center come from the sidecar) +# 2. mix the station to DC (--offset, for captures where the station +# is parked off-center, e.g. +300 kHz from collect_fm_testdata.py) +# 3. FIR lowpass + decimate to a ~200 kHz channel (windowed sinc, +# staged x8 then x5) +# 4. FM discriminator: phase difference of successive samples +# 5. 75 us de-emphasis (single-pole IIR; use --deemph 50 outside +# the Americas/South Korea) +# 6. 15 kHz audio lowpass, decimate to 50 kHz, normalize, WAV +# +# This intentionally decodes MONO (the L+R sum). The 19 kHz pilot, +# 38 kHz stereo subcarrier, and 57 kHz RDS are all present in the +# discriminator output this script produces -- extracting them is the +# natural next exercise, and the printed multiplex power readout shows +# they are there. +# +# Works on: tests/fm_reference/ captures (station at center), +# tests/fm_testdata/ captures (--offset 300e3), or any FM capture with +# a sidecar. SAFETY: file processing only; never touches the device. +# +# Usage: +# uv run python examples/fm_demod_to_wav.py tests/fm_reference/fm_98.1MHz_8Msps.iq +# uv run python examples/fm_demod_to_wav.py tests/fm_testdata/fm_hw_103.7MHz_2Msps.iq --offset 300e3 +# +# +# Author(s): Lauren Linkous +##--------------------------------------------------------------------\ +import argparse +import os +import sys +import time +import wave + +import numpy as np + +from hackrfpy import load_iq, parse_freq, read_sigmf_meta + +AUDIO_FS_TARGET = 50_000.0 # output WAV rate (channel_fs / 4) +CHANNEL_FS_TARGET = 200_000.0 # wide enough for +/-75 kHz deviation + + +def lpf_decimate(x, factor, cutoff_frac=0.45): + # Windowed-sinc FIR lowpass then downsample. cutoff_frac is relative to + # the OUTPUT Nyquist; 0.45 leaves transition headroom. + if factor == 1: + return x + ntaps = 16 * factor + 1 + m = np.arange(ntaps) - (ntaps - 1) / 2 + taps = np.sinc(2 * (cutoff_frac / factor) * m) * np.hanning(ntaps) + taps /= taps.sum() + return np.convolve(x, taps, mode="same")[::factor] + + +def staged_factors(total): + # split a big decimation into stages (cheaper: taps scale per stage) + stages = [] + for f in (8, 5, 4, 3, 2): + while total % f == 0 and total > 1: + stages.append(f) + total //= f + if total > 1: + stages.append(total) + return stages + + +def main(): + p = argparse.ArgumentParser( + description="Demodulate a broadcast-FM IQ capture to a mono WAV.") + p.add_argument("iq_file", help="int8 interleaved IQ file with sidecar") + p.add_argument("--offset", type=parse_freq, default=0.0, + help="station offset from capture center in Hz " + "(default 0; collect_fm_testdata.py files use 300e3)") + p.add_argument("--out", default=None, + help="output WAV path (default: alongside the IQ file)") + p.add_argument("--deemph", type=float, default=75.0, + help="de-emphasis time constant in us (75 Americas/KR, " + "50 most elsewhere; default 75)") + args = p.parse_args() + + meta = read_sigmf_meta(args.iq_file) + fs = float(meta["global"]["core:sample_rate"]) + center = float(meta["captures"][0]["core:frequency"]) + station = center + args.offset + print(f"[*] {os.path.basename(args.iq_file)}: {fs/1e6:g} Msps, " + f"center {center/1e6:g} MHz, station {station/1e6:g} MHz") + + t0 = time.perf_counter() + iq = load_iq(args.iq_file) + + # 2. mix the station to DC + if args.offset: + t = np.arange(len(iq)) / fs + iq = iq * np.exp(-2j * np.pi * args.offset * t) + + # 3. channelize to ~200 kHz + chan_factor = max(1, int(round(fs / CHANNEL_FS_TARGET))) + x = iq + for f in staged_factors(chan_factor): + x = lpf_decimate(x, f) + chan_fs = fs / chan_factor + print(f"[*] channelized: x{chan_factor} -> {chan_fs/1e3:g} kHz " + f"({time.perf_counter()-t0:.1f}s)") + + # 4. FM discriminator, scaled to Hz of instantaneous deviation + demod = np.angle(x[1:] * np.conj(x[:-1])) * chan_fs / (2 * np.pi) + + # multiplex readout: prove the pilot/stereo/RDS are in there + spec = np.abs(np.fft.rfft(demod * np.hanning(len(demod)))) ** 2 + fr = np.fft.rfftfreq(len(demod), 1.0 / chan_fs) + + def band_db(lo, hi): + m = (fr >= lo) & (fr < hi) + return 10 * np.log10(float(spec[m].max()) + 1e-20) if m.any() else -200 + + ref = band_db(300, 15_000) + print(f"[*] multiplex (dB rel. audio peak): " + f"pilot 19k {band_db(18_700, 19_300)-ref:+.1f}, " + f"stereo 38k {band_db(37_000, 39_000)-ref:+.1f}, " + f"RDS 57k {band_db(56_500, 57_500)-ref:+.1f}") + + # 5. de-emphasis: single-pole IIR y[n] = a*x[n] + b*y[n-1], with + # a = 1 - exp(-1/(fs*tau)), b = 1 - a. A pure-python sample loop is too + # slow, so evaluate the same recurrence blockwise: within each block, + # y[n] = a * sum_{k<=n} b^(n-k) x[k] + y_prev * b^(n+1) + # via one convolution against the geometric kernel, carrying y_prev + # across block boundaries. Numerically safe because b < 1 and blocks + # are short enough that b^n underflows to 0 harmlessly. + tau = args.deemph * 1e-6 + a = 1.0 - np.exp(-1.0 / (chan_fs * tau)) + b = 1.0 - a + # The geometric kernel decays fast (b < 1), so truncate it where its + # weight drops below 1e-9 -- a few hundred taps at 200 kHz -- instead + # of convolving against a full-block kernel, which is quadratic. + klen = int(np.ceil(np.log(1e-9) / np.log(b))) + 1 + kernel = a * b ** np.arange(klen) + audio = np.empty_like(demod) + y_prev = 0.0 + block = 262144 + for start in range(0, len(demod), block): + seg = demod[start:start + block] + n = len(seg) + y = np.convolve(seg, kernel)[:n] + decay = b ** np.arange(1, min(n, klen) + 1) + y[:len(decay)] += y_prev * decay + audio[start:start + n] = y + y_prev = y[-1] + + # 6. audio lowpass + decimate to WAV rate + audio_factor = max(1, int(round(chan_fs / AUDIO_FS_TARGET))) + for f in staged_factors(audio_factor): + audio = lpf_decimate(audio, f, cutoff_frac=0.30) # 15 kHz at x4 from 200k + audio_fs = chan_fs / audio_factor + audio -= np.mean(audio) # remove residual tuning DC + peak = float(np.max(np.abs(audio))) + 1e-12 + pcm = np.clip(audio / peak * 0.9 * 32767, -32768, 32767).astype(" int8 on write (exact for +# samples produced by the library's own decode). # -# It uses open_receiver() so the hackrf_transfer process spins up once and -# streams continuously -- the natural fit for a live single-frequency view. -# Read-only / receive-only; the receiver is reaped on exit. +# Use this when downstream processing cares about continuity across +# file boundaries (long observations, TDOA-ish work, demod that spans +# segments). Each file gets a SigMF sidecar. +# +# SAFETY: read-only / receive-only. # # Usage: -# uv run python examples/waterfall_persistent.py --freq 100e6 --rate 8e6 -# uv run python examples/waterfall_persistent.py --freq 2437e6 --rate 10e6 +# uv run python examples/persistent_capture.py --freq 100e6 --rate 2e6 +# uv run python examples/persistent_capture.py --freq 433.92e6 \ +# --rate 2e6 --segment-secs 1.0 --count 10 --out-dir segments # # # Author(s): Lauren Linkous -# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import argparse +import os import sys + import numpy as np -import matplotlib.pyplot as plt -from matplotlib import colors -from hackrfpy import HackRF -# ---- aesthetic: same signals-monitor identity as the sweep waterfall ------ -BG = "#0a0e14" -PANEL = "#0d1320" -GRID = "#1c2738" -ACCENT = "#36e0c8" -TEXT = "#9fb3c8" -TEXTDIM = "#52617a" -CMAP = "turbo" +from hackrfpy import HackRF, write_sigmf_meta + -plt.rcParams.update({ - "figure.facecolor": BG, "axes.facecolor": PANEL, "savefig.facecolor": BG, - "font.family": "monospace", "text.color": TEXT, "axes.edgecolor": GRID, - "axes.labelcolor": TEXT, "xtick.color": TEXTDIM, "ytick.color": TEXTDIM, - "axes.linewidth": 0.8, -}) +def encode_iq(iq: np.ndarray) -> bytes: + # complex64 in ~[-1, 1) -> interleaved int8 (HackRF native). Exact + # round-trip for data that came out of decode_iq (values are k/128). + out = np.empty(len(iq) * 2, dtype=np.int8) + out[0::2] = np.clip(np.round(iq.real * 128.0), -128, 127) + out[1::2] = np.clip(np.round(iq.imag * 128.0), -128, 127) + return out.tobytes() def main(): - p = argparse.ArgumentParser(description="Single-freq waterfall (persistent RX).") + p = argparse.ArgumentParser( + description="Gapless segmented capture from one persistent receiver.") p.add_argument("--freq", default="100e6", help="center frequency Hz") - p.add_argument("--rate", default="8e6", help="sample rate sps") - p.add_argument("--fft", type=int, default=1024, help="FFT size (bins)") - p.add_argument("--rows", type=int, default=400, help="time history depth") + p.add_argument("--rate", default="2e6", help="sample rate sps") + p.add_argument("--segment-secs", type=float, default=1.0, + help="seconds of samples per file (default 1.0)") + p.add_argument("--count", type=int, default=5, + help="number of segments to collect (default 5)") + p.add_argument("--out-dir", default="segments", + help="output directory (default ./segments)") + p.add_argument("--lna", type=int, default=16) + p.add_argument("--vga", type=int, default=20) p.add_argument("--tools-dir", default=None) args = p.parse_args() - freq, rate, nfft, rows = float(args.freq), float(args.rate), args.fft, args.rows + freq, rate = float(args.freq), float(args.rate) + n_per = int(rate * args.segment_secs) h = HackRF(tools_dir=args.tools_dir) - det = h.detect() - if not det["ready"]: - print(f"no usable HackRF: {det['problem']}", file=sys.stderr) - return 1 - - # color scale in dBFS-ish FFT magnitude; fixed so colors are stable - DB_FLOOR, DB_CEIL = -90, -20 - win = np.hanning(nfft).astype(np.float32) - - def spectrum(iq): - # Average several FFT frames across the big block for a smoother line, - # then suppress the center DC / LO-leakage spike that every direct- - # conversion SDR shows at 0 Hz (the bright line dead-center). We - # replace the few central bins with their neighbors so a real signal - # isn't hidden under the artifact. - nframes = max(1, len(iq) // nfft) - acc = np.zeros(nfft, dtype=np.float64) - for k in range(nframes): - seg = iq[k * nfft:(k + 1) * nfft] - if len(seg) < nfft: - break - sp = np.fft.fftshift(np.fft.fft(seg * win)) - acc += (np.abs(sp) / nfft) ** 2 - acc /= nframes - db = 10 * np.log10(acc + 1e-12) - # flatten the DC spike: overwrite the center +/-2 bins with a neighbor - c = nfft // 2 - db[c - 2:c + 3] = db[c + 3] - return db - - fig, ax = plt.subplots(figsize=(11, 6)) - try: - fig.canvas.manager.set_window_title( - "hackrfpy :: persistent-RX waterfall") - except Exception: - pass - - history = [] - img = None - cbar = None - # frequency axis: center +/- rate/2, in MHz - f_lo = (freq - rate / 2) / 1e6 - f_hi = (freq + rate / 2) / 1e6 - - print(f"[*] persistent waterfall @ {freq/1e6:g} MHz, {rate/1e6:g} Msps, " - f"{nfft}-pt FFT (close window or Ctrl-C to stop)") - - try: - with h.open_receiver(freq, rate) as rx: - # Read a LARGE block per iteration so the pipe drains fast enough - # to keep hackrf_transfer streaming (reading only nfft=1024 samples - # per loop, with a redraw each time, stalls the device at 10 Msps). - # Each big block becomes one averaged waterfall row. - block_samples = 262144 - while True: - iq = rx.read(block_samples) - if len(iq) < nfft: - break - history.append(spectrum(iq)) - history = history[-rows:] - arr = np.array(history)[::-1] # newest on top + os.makedirs(args.out_dir, exist_ok=True) - if img is None: - img = ax.imshow( - arr, aspect="auto", cmap=CMAP, - norm=colors.Normalize(vmin=DB_FLOOR, vmax=DB_CEIL), - interpolation="nearest", origin="upper", - extent=[f_lo, f_hi, 0, len(arr)]) - cbar = fig.colorbar(img, ax=ax, pad=0.01, fraction=0.046) - cbar.set_label("magnitude (dB)", color=TEXT, fontsize=9) - cbar.outline.set_edgecolor(GRID) - plt.setp(plt.getp(cbar.ax, "yticklabels"), color=TEXTDIM) - ax.set_title( - f"SINGLE-FREQ WATERFALL \u2014 {freq/1e6:g} MHz " - f"\u00b1 {rate/2e6:g} MHz", - color=ACCENT, fontsize=13, fontweight="bold", - loc="left", pad=12, family="monospace") - ax.set_xlabel("frequency (MHz)", fontsize=9) - ax.set_ylabel("time (newest at top)", fontsize=9) - ax.set_yticks([]) - for s in ax.spines.values(): - s.set_color(GRID) - fig.tight_layout() - else: - img.set_data(arr) - img.set_extent([f_lo, f_hi, 0, len(arr)]) - plt.pause(0.001) - if not plt.fignum_exists(fig.number): + print(f"[*] {args.count} x {args.segment_secs}s gapless segments " + f"@ {freq/1e6:g} MHz, {rate/1e6:g} Msps -> {args.out_dir}/") + written = [] + with h.open_receiver(freq, rate, lna=args.lna, vga=args.vga) as rx: + for i in range(args.count): + iq = rx.read(n_per) + if len(iq) < n_per: + print(f"[!] stream ended early on segment {i}", file=sys.stderr) + if not len(iq): break - except KeyboardInterrupt: - pass - print("[*] stopped") + path = os.path.join(args.out_dir, f"seg_{i:03d}.iq") + with open(path, "wb") as f: + f.write(encode_iq(iq)) + write_sigmf_meta(path, freq, rate, lna=rx.lna, vga=rx.vga, + amp=rx.amp, datatype="ci8") + db = h.power_dbfs(iq) + print(f" seg_{i:03d}.iq {len(iq)} samples {db:6.1f} dBFS") + written.append(path) + print(f"[*] done: {len(written)} files, " + f"{rx.total_samples} contiguous samples total") return 0 diff --git a/hackrfpy/examples/sample_data/README.md b/hackrfpy/examples/sample_data/README.md deleted file mode 100644 index d36c0e4..0000000 --- a/hackrfpy/examples/sample_data/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# hackrfpy sample data - -Real recordings from a HackRF One, for trying the library and downstream processing without owning a board. - -- collected: 2026-06-15T13:48:13 -- device firmware: 2024.02.1 (API:1.08) -- tools: git-b1dbb47 -- sample rate: 2 Msps, 0.5s per IQ capture - -Each `.iq` is interleaved int8 I/Q (HackRF native) with a `.sigmf-meta` sidecar describing frequency, rate, and gains. Load with: - -```python -from hackrfpy import load_iq, read_sigmf_meta -iq = load_iq('fm_2Msps.iq') -meta = read_sigmf_meta('fm_2Msps.iq') -``` - -## Files - -- `fm_2Msps.iq` -- `fm_sweep.csv` diff --git a/hackrfpy/examples/sample_data/fm_2Msps.iq b/hackrfpy/examples/sample_data/fm_2Msps.iq deleted file mode 100644 index 417c694..0000000 Binary files a/hackrfpy/examples/sample_data/fm_2Msps.iq and /dev/null differ diff --git a/hackrfpy/examples/sample_data/fm_2Msps.sigmf-meta b/hackrfpy/examples/sample_data/fm_2Msps.sigmf-meta deleted file mode 100644 index 6bcffdc..0000000 --- a/hackrfpy/examples/sample_data/fm_2Msps.sigmf-meta +++ /dev/null @@ -1,20 +0,0 @@ -{ - "global": { - "core:datatype": "ci8", - "core:sample_rate": 2000000.0, - "core:hw": "HackRF One", - "core:version": "1.0.0", - "core:recorder": "hackrfpy", - "hackrf:lna_gain_db": 16, - "hackrf:vga_gain_db": 20, - "hackrf:amp_enabled": false - }, - "captures": [ - { - "core:sample_start": 0, - "core:frequency": 98000000.0, - "core:datetime": "2026-06-15T17:48:13.436516+00:00" - } - ], - "annotations": [] -} \ No newline at end of file diff --git a/hackrfpy/examples/sample_data/fm_sweep.csv b/hackrfpy/examples/sample_data/fm_sweep.csv deleted file mode 100644 index 5cb7b61..0000000 --- a/hackrfpy/examples/sample_data/fm_sweep.csv +++ /dev/null @@ -1,5 +0,0 @@ -date,time,hz_low,hz_high,bin_width,num_samples,db... -2026-06-15, 13:48:13.555047, 88000000, 93000000, 1000000.00, 20, -62.14, -63.20, -66.00, -61.42, -61.68 -2026-06-15, 13:48:13.555047, 98000000, 103000000, 1000000.00, 20, -60.19, -58.76, -61.15, -59.99, -65.45 -2026-06-15, 13:48:13.555047, 93000000, 98000000, 1000000.00, 20, -59.73, -55.30, -53.64, -58.01, -61.20 -2026-06-15, 13:48:13.555047, 103000000, 108000000, 1000000.00, 20, -64.95, -61.21, -68.30, -68.77, -64.86 diff --git a/hackrfpy/examples/tx_test_tone.py b/hackrfpy/examples/tx_test_tone.py new file mode 100644 index 0000000..4ff2b6f --- /dev/null +++ b/hackrfpy/examples/tx_test_tone.py @@ -0,0 +1,70 @@ +#! /usr/bin/python3 +##--------------------------------------------------------------------\ +# hackrfpy 'examples/tx_test_tone.py' +# The library's TX side, demonstrated the SAFE way: the deliberate +# RX->TX mode switch (which prints the safety banner), a constant-wave +# test tone via transmit_cw, and the dead-man duration bound so the +# transmitter can never outlive the script. +# +# This is the only example that transmits. It is deliberately timid: +# - duration is REQUIRED and capped at 10 s +# - txvga defaults low; the RF amp is never enabled here +# - --print-cmd previews the exact hackrf_transfer command w/o running +# +# TRANSMITTING IS REGULATED. Use a dummy load or a shielded setup, and +# operate only within your license privileges and local law. A CW +# carrier is useful for antenna checks and for giving a nearby receiver +# (or your own second SDR) a known signal to find. +# +# Usage: +# uv run python examples/tx_test_tone.py --freq 433.92M --seconds 2 +# uv run python examples/tx_test_tone.py --freq 915M --seconds 2 --print-cmd +# +# +# Author(s): Lauren Linkous +##--------------------------------------------------------------------\ +import argparse +import sys + +from hackrfpy import HackRF, parse_freq + +MAX_SECONDS = 10.0 + + +def main(): + p = argparse.ArgumentParser( + description="Bounded CW test-tone transmit (TX-gated).") + p.add_argument("--freq", required=True, + help="carrier frequency (parse_freq notation, e.g. 433.92M)") + p.add_argument("--rate", default="2M", help="sample rate (default 2M)") + p.add_argument("--seconds", type=float, required=True, + help=f"transmit duration; required, max {MAX_SECONDS:g}s") + p.add_argument("--txvga", type=int, default=4, + help="TX VGA gain dB (default 4, deliberately low)") + p.add_argument("--amplitude", type=int, default=64, + help="CW DAC amplitude 0-127 (default 64)") + p.add_argument("--print-cmd", action="store_true", + help="preview the command without transmitting") + p.add_argument("--tools-dir", default=None) + args = p.parse_args() + + if not args.print_cmd and not 0 < args.seconds <= MAX_SECONDS: + print(f"--seconds must be in (0, {MAX_SECONDS:g}]", file=sys.stderr) + return 2 + + h = HackRF(tools_dir=args.tools_dir, verbose=True) + freq, rate = parse_freq(args.freq), parse_freq(args.rate) + + # The deliberate confirmation: transmit() refuses in the default RX + # mode, and this switch prints the one-time TX safety banner. + h.set_mode("tx") + h.transmit_cw(freq, rate, amplitude=args.amplitude, txvga=args.txvga, + duration=args.seconds, print_cmd=args.print_cmd) + h.set_mode("rx") # leave the object safe for whatever follows + if not args.print_cmd: + print(f"[*] transmitted {args.seconds:g}s CW at {freq/1e6:g} MHz") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hackrfpy/examples/waterfall_persistent.py b/hackrfpy/examples/waterfall_persistent.py index f742bea..b6034f8 100644 --- a/hackrfpy/examples/waterfall_persistent.py +++ b/hackrfpy/examples/waterfall_persistent.py @@ -67,13 +67,24 @@ def main(): win = np.hanning(nfft).astype(np.float32) def spectrum(iq): - # one FFT frame: window, transform, fftshift to put DC center, log-mag - seg = iq[:nfft] - if len(seg) < nfft: - seg = np.pad(seg, (0, nfft - len(seg))) - sp = np.fft.fftshift(np.fft.fft(seg * win)) - mag = np.abs(sp) / nfft - return 20 * np.log10(mag + 1e-9) + # Average several FFT frames across the block for a smoother line, + # then suppress the center DC / LO-leakage spike that every direct- + # conversion SDR shows at 0 Hz (the bright line dead-center). We + # replace the few central bins with a neighbor so a real signal + # isn't hidden under the artifact. + nframes = max(1, len(iq) // nfft) + acc = np.zeros(nfft, dtype=np.float64) + for k in range(nframes): + seg = iq[k * nfft:(k + 1) * nfft] + if len(seg) < nfft: + seg = np.pad(seg, (0, nfft - len(seg))) + sp = np.fft.fftshift(np.fft.fft(seg * win)) + acc += (np.abs(sp) / nfft) ** 2 + acc /= nframes + db = 10 * np.log10(acc + 1e-12) + c = nfft // 2 + db[c - 2:c + 3] = db[c + 3] # flatten the DC spike + return db fig, ax = plt.subplots(figsize=(11, 6)) try: @@ -94,9 +105,13 @@ def spectrum(iq): try: with h.open_receiver(freq, rate) as rx: - # one block per FFT frame; read exactly nfft samples each time + # Read a LARGE block per iteration so the pipe drains fast enough + # to keep hackrf_transfer streaming (reading only nfft=1024 + # samples per loop, with a redraw each time, stalls the device at + # 10 Msps). Each big block becomes one averaged waterfall row. + block_samples = 262144 for _ in range(10_000_000): # effectively "until stopped" - iq = rx.read(nfft) + iq = rx.read(block_samples) if len(iq) < nfft: break history.append(spectrum(iq)) diff --git a/hackrfpy/examples/waterfall_realtime.py b/hackrfpy/examples/waterfall_realtime.py index 24dd111..bf08ef1 100644 --- a/hackrfpy/examples/waterfall_realtime.py +++ b/hackrfpy/examples/waterfall_realtime.py @@ -13,7 +13,7 @@ # # # Author(s): Lauren Linkous -# Last Update: July 11, 2026 +# Last Update: September 17, 2026 ##--------------------------------------------------------------------\ import numpy as np import matplotlib.pyplot as plt diff --git a/hackrfpy/pyproject.toml b/hackrfpy/pyproject.toml index bd34ffc..3ac326d 100644 --- a/hackrfpy/pyproject.toml +++ b/hackrfpy/pyproject.toml @@ -46,6 +46,7 @@ dev = [ "pytest-cov", "ruff", "mypy", + "sigmf>=1.2", # official validator: spec compliance is tested, not trusted ] [tool.pytest.ini_options] diff --git a/hackrfpy/src/hackrfpy/__init__.py b/hackrfpy/src/hackrfpy/__init__.py index 344c97b..245e119 100644 --- a/hackrfpy/src/hackrfpy/__init__.py +++ b/hackrfpy/src/hackrfpy/__init__.py @@ -11,8 +11,14 @@ ) from . import constants +try: + from importlib.metadata import PackageNotFoundError, version as _pkg_version + __version__ = _pkg_version("hackrfpy") +except PackageNotFoundError: # running from a checkout + __version__ = "0.0.0+unknown" + __all__ = [ - "HackRF", "load_iq", "parse_freq", "constants", + "HackRF", "load_iq", "parse_freq", "constants", "__version__", "write_sigmf_meta", "read_sigmf_meta", "HackRFError", "HackRFValueError", "HackRFModeError", "HackRFDeviceError", "HackRFEnvironmentError", diff --git a/hackrfpy/src/hackrfpy/_commands/capture.py b/hackrfpy/src/hackrfpy/_commands/capture.py index 600ac70..f96486d 100644 --- a/hackrfpy/src/hackrfpy/_commands/capture.py +++ b/hackrfpy/src/hackrfpy/_commands/capture.py @@ -42,6 +42,14 @@ def capture(self, freq: float, sample_rate: float, *, baseband_bw: float | None = None, to_stdout: bool = False, sigmf: bool = True, segment_secs: float | None = None, print_cmd: bool = False) -> Any: + """Receive IQ at freq/sample_rate into a file (with SigMF sidecar). + + Bound with num_samples or duration, or neither for an open-ended handle + (call .stop() on the returned process handle). segment_secs rolls output + across numbered files (whole files, short re-open gap between them; use + open_receiver for gapless). to_stdout=True streams decoded blocks + instead of writing a file. + """ self.require_mode(C.MODE_RX) freq, sample_rate, lna, vga = self.validate_rx(freq, sample_rate, lna, vga) bw = self._auto_baseband(sample_rate, baseband_bw) @@ -110,20 +118,29 @@ def _gen() -> Generator[np.ndarray, None, None]: # decoded, not raw # ---- aliases ---- def rx(self, *a: Any, **k: Any) -> Any: + """Alias of capture().""" return self.capture(*a, **k) def capture_samples(self, freq: float, sample_rate: float, num_samples: int, **k: Any) -> Any: + """Alias of capture() with a required num_samples bound.""" return self.capture(freq, sample_rate, num_samples=num_samples, **k) def capture_seconds(self, freq: float, sample_rate: float, duration: float, **k: Any) -> Any: + """Alias of capture() with a required duration bound.""" return self.capture(freq, sample_rate, duration=duration, **k) def scan_frequencies(self, freqs: list[float], sample_rate: float, num_samples: int, *, on_capture: Callable[..., Any] | None = None, **k: Any) -> dict[float, np.ndarray] | None: + """Capture num_samples of IQ at each frequency in turn. + + Returns {freq: complex64 ndarray}, or None when on_capture consumes + blocks instead. Each visit re-opens the device (short gap between + frequencies). + """ # Sequentially capture a fixed sample count at each frequency in # `freqs`, retuning between them. This does NOT close the gapless- # retune gap (each retune is a fresh hackrf_transfer with a short @@ -148,6 +165,11 @@ def scan_frequencies(self, freqs: list[float], sample_rate: float, # ---- in-memory + context-managed entry points ---- def capture_array(self, freq: float, sample_rate: float, num_samples: int, *, return_params: bool = False, **k: Any) -> Any: + """Return exactly num_samples complex64 samples in RAM, no file. + + The scripting entry point. return_params=True also returns the + validated parameters actually used. + """ # Scripting entry point: return EXACTLY num_samples complex64 samples # in RAM, no file. Built on the stdout-stream path so it shares the # odd-byte carry + clean-reap logic. The stream is closed as soon as @@ -184,6 +206,12 @@ def open_receiver(self, freq: float, sample_rate: float, *, lna: int = 16, vga: int = 20, amp: bool = False, baseband_bw: float | None = None, read_samples: int = 131072) -> PersistentReceiver: + """Open a long-lived receive stream; returns a PersistentReceiver. + + One hackrf_transfer process serves many .read(n) calls: consecutive + reads are gapless (contrast capture(segment_secs=...)). Use as a context + manager to guarantee the child is reaped. + """ # Open a PERSISTENT fixed-frequency receiver: one long-lived # hackrf_transfer you drain in segments over time, so you don't pay the # ~1-2 s process spin-up per capture. Use as a context manager: @@ -204,6 +232,11 @@ def open_receiver(self, freq: float, sample_rate: float, *, lna: int = 16, def capture_stream(self, freq: float, sample_rate: float, **k: Any) -> StreamCtx: + """Context manager yielding decoded complex64 blocks from a live stream. + + The receiving hackrf_transfer is always reaped on block exit, even on + exception. + """ # Context manager wrapping the live stdout stream so the receiving # hackrf_transfer is ALWAYS reaped on exit, even on exception: # with h.capture_stream(433.92e6, 8e6) as blocks: @@ -217,6 +250,11 @@ def capture_callback(self, freq: float, sample_rate: float, on_block: Callable[..., Any], *, max_samples: int | None = None, max_blocks: int | None = None, **k: Any) -> int: + """Invoke on_block(iq) per decoded block until a bound or False return. + + Bounds: max_samples, max_blocks, or the callback returning False. + Returns the number of samples delivered. + """ # Binder-style ergonomics over the subprocess stream: instead of the # caller writing the receive loop, register a callback that fires with # each decoded complex64 block as it arrives. This is the usability diff --git a/hackrfpy/src/hackrfpy/_commands/device.py b/hackrfpy/src/hackrfpy/_commands/device.py index 3b0b47f..1faeb42 100644 --- a/hackrfpy/src/hackrfpy/_commands/device.py +++ b/hackrfpy/src/hackrfpy/_commands/device.py @@ -26,20 +26,24 @@ class DeviceMixin(HostOps): # ---- clock ---- def clock(self, *args: Any, print_cmd: bool = False) -> Any: + """Passthrough to hackrf_clock with the given arguments.""" argv = ["clock"] + list(args) return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd) # ---- operacake antenna switch ---- def operacake(self, *args: Any, print_cmd: bool = False) -> Any: + """Passthrough to hackrf_operacake with the given arguments.""" argv = ["operacake"] + list(args) return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd) def operacake_list(self) -> Any: + """List attached Opera Cake boards (hackrf_operacake -l).""" return self.operacake("-l") # ---- cpld jtag (CPLD firmware) ---- def cpldjtag(self, firmware: str, *, confirm: bool = False, print_cmd: bool = False) -> Any: + """Program CPLD firmware (DANGEROUS; requires confirm=True).""" if not confirm and not print_cmd: raise HackRFValueError( "cpldjtag flashes the CPLD and can brick the board. " @@ -50,6 +54,11 @@ def cpldjtag(self, firmware: str, *, confirm: bool = False, # ---- spiflash (firmware) ---- def spiflash_write(self, firmware: str, *, confirm: bool = False, print_cmd: bool = False) -> Any: + """Write SPI flash firmware (MOST DANGEROUS; requires confirm=True). + + A bad image can brick the board until DFU recovery. print_cmd dry-runs + without the confirm gate. + """ # Writing firmware. The single most dangerous operation in the library. if not confirm and not print_cmd: raise HackRFValueError( @@ -61,17 +70,20 @@ def spiflash_write(self, firmware: str, *, confirm: bool = False, def spiflash_read(self, out: str, length: int | None = None, print_cmd: bool = False) -> Any: + """Read SPI flash contents to a file (safe, read-only).""" argv: list[Any] = ["spiflash", "-r", out] if length is not None: argv += ["-l", int(length)] return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd) def spiflash_reset(self, print_cmd: bool = False) -> Any: + """Reset the device (hackrf_spiflash -R).""" return self._run(["spiflash", "-R"], mode="blocking", text=True, print_cmd=print_cmd) # ---- debug register access ---- def debug(self, *args: Any, print_cmd: bool = False) -> Any: + """Passthrough to hackrf_debug with the given arguments.""" argv = ["debug"] + list(args) return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd) @@ -80,6 +92,11 @@ def debug(self, *args: Any, print_cmd: bool = False) -> Any: # (was 'doctor' -- kept as an alias below for the familiar CLI verb) # ================================================================= def preflight(self, capture_path: str = ".") -> dict[str, Any]: + """Check tools, board, free disk, and mode; return a structured report. + + Raises only on hard environment failures; problems are listed in the + report so `doctor && capture` workflows can gate on them. + """ # Checks tooling, board presence, free disk, and reports the active # mode. Returns a structured report; raises only on hard environment # failures the user must fix. @@ -141,10 +158,16 @@ def preflight(self, capture_path: str = ".") -> dict[str, Any]: # doctor: familiar alias for preflight (brew/flutter-style verb). The CLI # still exposes `hrf doctor`; the honest method name is preflight(). def doctor(self, capture_path: str = ".") -> dict[str, Any]: + """Alias of preflight().""" return self.preflight(capture_path=capture_path) def features(self, tool_version: str | None = None, firmware_version: str | None = None) -> dict[str, Any]: + """Map tools/firmware version strings to capability flags. + + The firmware version (e.g. '2024.02.1') is the reliable signal; tools + versions are often git hashes. + """ # Map version strings -> capability flags. The reliable year signal is # the FIRMWARE version (e.g. "2024.02.1"); the tools version is often a # git tag (e.g. "git-b1dbb47") with no parseable year. If nothing is diff --git a/hackrfpy/src/hackrfpy/_commands/info.py b/hackrfpy/src/hackrfpy/_commands/info.py index 3c08d5a..5220d89 100644 --- a/hackrfpy/src/hackrfpy/_commands/info.py +++ b/hackrfpy/src/hackrfpy/_commands/info.py @@ -22,6 +22,7 @@ class InfoMixin(HostOps): def info(self, raw: bool = False, print_cmd: bool = False) -> dict[str, Any] | str | None: + """Run hackrf_info; return a parsed dict (or raw text with raw=True).""" # Returns a parsed dict by default, or the raw text if raw=True. if print_cmd: self._run(["info"], mode="blocking", print_cmd=True) @@ -33,10 +34,16 @@ def info(self, raw: bool = False, return self.parse_info(out) def get_info(self) -> dict[str, Any] | str | None: + """Alias of info().""" # alias return self.info() def detect(self) -> dict[str, Any]: + """Detect and identify attached boards (the serial-port-scan analog). + + Returns a dict with ready (bool), boards (list of per-board dicts), + tools_version, and problem (actionable message when not ready). + """ # Hardware autodetection + identification, the HackRF analog of a # serial-port scan. The HackRF is NOT a serial device -- there are no # COM ports to walk -- so "detection" means: run hackrf_info, confirm @@ -62,7 +69,13 @@ def detect(self) -> dict[str, Any]: "tools_version": None, "libhackrf_version": None, "multiple": False, "warnings": [], "problem": None} try: - out, _, _ = self._run(["info"], mode="blocking", text=True) + # check=False: with no board attached, Linux hackrf_info exits 1 + # AFTER printing its version lines and the reason to STDOUT + # (stderr empty). Raising on the exit code threw all of that + # away -- tools_version came back None and problem was blank. + # Parse whatever it printed; the exit code is not the signal here. + out, _, _ = self._run(["info"], mode="blocking", text=True, + check=False) except HackRFDeviceError as e: # hackrf_info couldn't run at all (binary missing). Surface it as a # problem rather than raising, so detect() is always safe to call. @@ -104,10 +117,16 @@ def detect(self) -> dict[str, Any]: result["problem"] = ("a USB device was enumerated but did not " "identify as a HackRF") elif not result["found"]: - result["problem"] = "no HackRF board detected (check USB / drivers)" + # prefer the tool's own words when it stated a reason + stated = next((ln.strip() for ln in out.splitlines() + if ln.strip().lower().startswith("no hackrf")), None) + result["problem"] = (f"{stated} (check USB / drivers)" if stated + else "no HackRF board detected " + "(check USB / drivers)") return result def identify(self, serial: str | None = None) -> dict[str, Any] | None: + """Return one board's identity dict (by serial, or the first found).""" # Return the identity of a single board: the one matching `serial`, or # the first detected board if serial is None. Returns the board dict # from detect()["boards"], or None if not found. Convenience for "what @@ -124,6 +143,7 @@ def identify(self, serial: str | None = None) -> dict[str, Any] | None: @staticmethod def parse_info(text: str) -> dict[str, Any]: + """Parse hackrf_info text into {library: ..., boards: [...]}.""" # hackrf_info prints "Key: value" lines: a version preamble, then one # block per board. We keep the preamble under "library" and each board # under "boards". A board starts at "Found HackRF" or, for outputs that diff --git a/hackrfpy/src/hackrfpy/_commands/sweep.py b/hackrfpy/src/hackrfpy/_commands/sweep.py index 37c5c9b..414f98a 100644 --- a/hackrfpy/src/hackrfpy/_commands/sweep.py +++ b/hackrfpy/src/hackrfpy/_commands/sweep.py @@ -29,6 +29,13 @@ def sweep(self, f_min_hz: float, f_max_hz: float, *, num_sweeps: int | None = None, print_cmd: bool = False ) -> Generator[dict[str, Any], None, None] | None: + """Stream a spectrum sweep as parsed dict rows (generator). + + Rows carry date, time, hz_low, hz_high, bin_width, num_samples and db + (a list per bin). Segments arrive OUT of frequency order. Edges snap + outward to integer MHz (floor low, ceil high) so the swept range always + contains the requested band. + """ # GENERATOR yielding parsed rows (dicts). Validate the band edges with # the same hard-range logic, snap gains. hackrf_sweep takes the range # in MHz as f_min:f_max. @@ -46,14 +53,19 @@ def sweep(self, f_min_hz: float, f_max_hz: float, *, self.print_message(f"[*] mode: {self.mode}") lo = int(f_min_hz // 1_000_000) - hi = int(f_max_hz // 1_000_000) + hi = -int(-f_max_hz // 1_000_000) # ceil: never truncate the top # hackrf_sweep takes integer MHz edges, so sub-MHz precision is lost. - # Warn rather than silently shift the band the user asked for. + # Snap OUTWARD (floor the low edge, ceil the high edge) so the swept + # range always CONTAINS the requested band -- flooring both edges + # silently dropped everything above the last whole MHz (e.g. + # 433.9:434.1 swept 433:434 and never covered 434.0-434.1). Warn so + # the user knows the edges moved. if f_min_hz % 1_000_000 or f_max_hz % 1_000_000: self.warn( - f"sweep edges snapped to MHz: " + f"sweep edges snapped to MHz (outward): " f"{f_min_hz/1e6:g}:{f_max_hz/1e6:g} -> {lo}:{hi} MHz " - f"(hackrf_sweep takes integer MHz)") + f"(hackrf_sweep takes integer MHz; requested band fully " + f"covered)") argv = ["sweep", "-f", f"{lo}:{hi}", "-l", lna, "-g", vga, "-a", 1 if amp else 0] if bin_width is not None: @@ -91,6 +103,7 @@ def _gen() -> Generator[dict[str, Any], None, None]: def sweep_collect(self, f_min_hz: float, f_max_hz: float, num_sweeps: int = 1, **k: Any) -> list[dict[str, Any]]: + """Run num_sweeps full passes and return the parsed rows as a list.""" # Convenience: collect a bounded number of sweeps into a list. k.pop("num_sweeps", None) rows = self.sweep(f_min_hz, f_max_hz, num_sweeps=num_sweeps, **k) @@ -104,6 +117,12 @@ def monitor_frequencies(self, freqs_hz: list[float], *, on_update: Callable[..., Any] | None = None, lna: int = 16, vga: int = 20, amp: bool = False) -> Any: + """Track power over time at several frequencies via one sweep. + + Yields (or passes to on_update) {freq: dB} per sweep pass; the reading + is the sweep bin covering the frequency (max of that bin +/-1), not a + segment average. Power only -- use scan_frequencies for IQ. + """ # Watch POWER over time at several frequencies, backed by hackrf_sweep's # fast internal hardware retuning. This is DELIBERATELY a different # method from scan_frequencies(): @@ -124,13 +143,20 @@ def monitor_frequencies(self, freqs_hz: list[float], *, t0 = _time.time() def _nearest_power(rows_by_low: dict[int, Any], f: float) -> Any: - # find the sweep segment whose [hz_low, hz_high) covers f, return - # the mean dB of that segment's bins (a simple power proxy) + # find the sweep segment whose [hz_low, hz_high) covers f and + # return the dB of the BIN covering f (max of that bin +/-1 for + # tuning slop). Averaging the whole segment diluted a narrowband + # carrier toward the noise floor: in a 5 MHz segment a strong + # signal occupying one bin barely moved the mean. for low in sorted(rows_by_low): r = rows_by_low[low] if r["hz_low"] <= f < r["hz_high"]: db = r["db"] - return sum(db) / len(db) if db else float("-inf") + if not db: + return float("-inf") + idx = int((f - r["hz_low"]) / r["bin_width"]) + idx = max(0, min(idx, len(db) - 1)) + return max(db[max(0, idx - 1):idx + 2]) return None from .._stream_ctx import StreamCtx @@ -139,10 +165,20 @@ def _nearest_power(rows_by_low: dict[int, Any], f: float) -> Any: _rows = self.sweep(lo, hi, lna=lna, vga=vga, amp=amp) assert _rows is not None # print_cmd not passed -> real generator with StreamCtx(_rows) as gen: - rows_by_low = {} - last_time = None + rows_by_low: dict[int, Any] = {} for row in gen: - if last_time is not None and row["time"] != last_time and rows_by_low: + # A pass is complete when the sweep WRAPS: the same segment + # (hz_low) arriving again means a new pass began. The old + # boundary was a timestamp change -- but real hackrf_sweep + # timestamps each ROW individually, so over a wide span the + # "pass" flushed on nearly every row batch, emitting partial + # updates where most watched frequencies read None (the test + # stubs share one timestamp per pass, which is why stubs + # passed while real hardware showed a wall of "--"). Segment + # revisit is timestamp-independent and matches the physical + # sweep cycle, so every update now covers every watched + # frequency the span covers. + if row["hz_low"] in rows_by_low: update = {f: _nearest_power(rows_by_low, f) for f in freqs_hz} if on_update is not None: if on_update(update) is False: @@ -154,7 +190,6 @@ def _nearest_power(rows_by_low: dict[int, Any], f: float) -> Any: if duration is not None and _time.time() - t0 >= duration: break rows_by_low[row["hz_low"]] = row - last_time = row["time"] # flush the final buffered pass (stream ended before its timestamp # rolled over) -- unless we were explicitly stopped by on_update if rows_by_low and not stopped: @@ -171,6 +206,11 @@ def sweep_to_file(self, f_min_hz: float, f_max_hz: float, out: str, *, vga: int = 20, amp: bool = False, one_shot: bool = False, num_sweeps: int | None = None, print_cmd: bool = False) -> str | None: + """Run hackrf_sweep writing to a file (CSV, or -B/-I binary). + + binary=True (-B) and inverse_fft=True (-I) produce unparsed binary + passthrough; the library parses only the CSV text format. + """ # Write sweep output straight to a file instead of yielding parsed # rows. This is the home for hackrf_sweep's binary-output flags, which # don't fit the text-CSV generator: @@ -189,7 +229,7 @@ def sweep_to_file(self, f_min_hz: float, f_max_hz: float, out: str, *, lna = self._snap_gain("lna_gain", lna, C.LNA_GAIN) vga = self._snap_gain("vga_gain", vga, C.VGA_GAIN) lo = int(f_min_hz // 1_000_000) - hi = int(f_max_hz // 1_000_000) + hi = -int(-f_max_hz // 1_000_000) # ceil: never truncate the top argv = ["sweep", "-f", f"{lo}:{hi}", "-l", lna, "-g", vga, "-a", 1 if amp else 0, "-r", out] if bin_width is not None: @@ -206,6 +246,7 @@ def sweep_to_file(self, f_min_hz: float, f_max_hz: float, out: str, *, return None if print_cmd else out def sweep_stream(self, f_min_hz: float, f_max_hz: float, **k: Any) -> Any: + """Return a StreamCtx over parsed sweep rows (reaped on context exit).""" # Context manager around the sweep generator so the underlying # hackrf_sweep is ALWAYS reaped on exit -- including KeyboardInterrupt # out of a live consumer loop (e.g. a waterfall). Without this, a @@ -223,6 +264,7 @@ def sweep_stream(self, f_min_hz: float, f_max_hz: float, **k: Any) -> Any: @staticmethod def parse_sweep_line(line: str) -> dict[str, Any] | None: + """Parse one hackrf_sweep CSV line into a row dict, or None if invalid.""" # Returns {date,time,hz_low,hz_high,bin_width,num_samples,db:[...]} or # None for blank/garbled lines. line = line.strip() diff --git a/hackrfpy/src/hackrfpy/_commands/transmit.py b/hackrfpy/src/hackrfpy/_commands/transmit.py index 0eb83a4..c61cf3b 100644 --- a/hackrfpy/src/hackrfpy/_commands/transmit.py +++ b/hackrfpy/src/hackrfpy/_commands/transmit.py @@ -31,6 +31,13 @@ def transmit(self, freq: float, sample_rate: float, source: str, *, num_samples: int | None = None, duration: float | None = None, max_duration: float | None = None, print_cmd: bool = False) -> Any: + """Transmit an int8 interleaved I/Q file (requires TX mode). + + Refuses unless set_mode('tx') was called. Bound with num_samples, + duration, or repeat (optionally capped by max_duration). Duration bounds + are enforced parent-side (timed interrupt): a --print-cmd argv copied and + run by hand carries NO time bound. + """ # source: path to an int8 I/Q file to transmit. # max_duration: hard ceiling (seconds) enforced even for open-ended # repeat transmits. A transmitter that runs until .stop() is a @@ -80,10 +87,12 @@ def transmit(self, freq: float, sample_rate: float, source: str, *, # ---- aliases ---- def tx(self, *a: Any, **k: Any) -> Any: + """Alias of transmit().""" return self.transmit(*a, **k) def transmit_file(self, freq: float, sample_rate: float, source: str, **k: Any) -> Any: + """Alias of transmit().""" return self.transmit(freq, sample_rate, source, **k) def transmit_cw(self, freq: float, sample_rate: float, *, @@ -92,6 +101,11 @@ def transmit_cw(self, freq: float, sample_rate: float, *, duration: float | None = None, max_duration: float | None = None, print_cmd: bool = False) -> Any: + """Transmit a constant-wave test tone (requires TX mode). + + amplitude is the DAC level 0-127. Same duration semantics as + transmit(): the time bound is parent-side, not in the printed argv. + """ # Constant-wave / signal-source test mode: hackrf_transfer -c . # Transmits a fixed signal at `amplitude` (0-127) instead of a file. # TX-gated like any transmit. Useful for antenna/range testing. Open- diff --git a/hackrfpy/src/hackrfpy/_receiver.py b/hackrfpy/src/hackrfpy/_receiver.py index ce8d2b4..477bd8d 100644 --- a/hackrfpy/src/hackrfpy/_receiver.py +++ b/hackrfpy/src/hackrfpy/_receiver.py @@ -33,6 +33,13 @@ class PersistentReceiver: + """A long-lived receive stream: one child process, many reads. + + Returned by HackRF.open_receiver(). Consecutive read(n) calls are + gapless (back-to-back samples from one continuous stream). Use as a + context manager, or call stop(), to guarantee the child is reaped; + the atexit backstop and OS dead-man cover abnormal exits. + """ # A long-lived RX stream at one frequency. Created via # HackRF.open_receiver(...); use as a context manager so the child is # always reaped: @@ -105,6 +112,7 @@ def __exit__(self, exc_type: type[BaseException] | None, return False def close(self) -> None: + """Alias of stop().""" if self._closed: return self._closed = True @@ -121,11 +129,13 @@ def close(self) -> None: pass def stop(self) -> None: + """Interrupt the child cleanly and reap it; returns (out, err, rc).""" # alias so PersistentReceiver works with the _LIVE backstop, which # calls .stop() on whatever it holds self.close() def is_alive(self) -> bool: + """Return whether the receiving child process is still running.""" # Required by the _LIVE atexit backstop, which does `if h.is_alive(): # h.stop()`. This was MISSING: the resulting AttributeError was swallowed # by the backstop's bare `except Exception`, so an orphaned persistent @@ -134,6 +144,11 @@ def is_alive(self) -> bool: # ---- data access ------------------------------------------------------- def read(self, n_samples: int) -> np.ndarray: + """Return up to n complex64 samples (fewer only if the stream ended). + + Consecutive reads are gapless: back-to-back samples from one + continuous stream. + """ # Return EXACTLY n_samples complex64 (or fewer if the stream ends). # Pulls and decodes raw bytes from the persistent stream until it has # enough; carries any partial trailing pair between calls. No new @@ -154,6 +169,7 @@ def read(self, n_samples: int) -> np.ndarray: return iq def blocks(self) -> Iterator[np.ndarray]: + """Yield decoded complex64 blocks as they arrive (generator).""" # Yield decoded complex64 blocks as they arrive (raw cadence), until # the stream ends or the caller stops iterating. Good for a continuous # consumer that doesn't need a fixed sample count per read. @@ -171,6 +187,7 @@ def blocks(self) -> Iterator[np.ndarray]: def callback(self, on_block: Callable[[np.ndarray, int], Any], *, max_samples: int | None = None) -> int: + """Invoke on_block(iq) per block until it returns False or the stream ends.""" # Inverted loop over blocks(): call on_block(iq, total) per block; # stop on False, on max_samples, or stream end. for iq in self.blocks(): diff --git a/hackrfpy/src/hackrfpy/cli.py b/hackrfpy/src/hackrfpy/cli.py index 7040b58..b86ab1f 100644 --- a/hackrfpy/src/hackrfpy/cli.py +++ b/hackrfpy/src/hackrfpy/cli.py @@ -156,9 +156,17 @@ def __init__(self, a: Sequence[str] | None = None) -> None: sp.add_argument("--preset", help="apply a band preset for freq/rate") # tx - sp = sub.add_parser("tx", help="transmit IQ from a file (TX mode only)") + sp = sub.add_parser("tx", help="transmit IQ from a file, or a CW " + "test tone with --cw (TX mode only)") _add_common_rf(sp) - sp.add_argument("source", help="int8 I/Q file to transmit") + sp.add_argument("source", nargs="?", default=None, + help="int8 I/Q file to transmit (omit with --cw)") + sp.add_argument("--cw", action="store_true", + help="transmit a constant-wave test tone instead of " + "a file; requires -d/--duration") + sp.add_argument("--cw-amplitude", dest="cw_amplitude", type=int, + default=64, help="CW DAC amplitude 0-127 (default 64, " + "deliberately below full scale)") sp.add_argument("-x", "--txvga", type=int, default=20) sp.add_argument("-a", "--amp", action="store_true") sp.add_argument("--bias-tee", dest="bias_tee", action="store_true") @@ -179,11 +187,51 @@ def __init__(self, a: Sequence[str] | None = None) -> None: sp.add_argument("-a", "--amp", action="store_true") sp.add_argument("-1", "--one-shot", dest="one_shot", action="store_true") sp.add_argument("-N", "--num-sweeps", dest="num_sweeps", type=int) + sp.add_argument("-o", "--out", default=None, + help="write via hackrf_sweep -r FILE instead of " + "parsing to stdout (required for -B / -I)") + sp.add_argument("-B", "--binary", action="store_true", + help="raw binary bins (unparsed passthrough)") + sp.add_argument("-I", "--inverse-fft", dest="inverse_fft", + action="store_true", + help="inverse FFT binary output (unparsed passthrough)") sp.add_argument("--force", action="store_true") sp.add_argument("--print-cmd", dest="print_cmd", action="store_true") sp.add_argument("-v", "--verbose", action="store_true") sp.add_argument("--serial", help="select a board by serial number") + # monitor + sp = sub.add_parser("monitor", help="power over time on several " + "frequencies via one sweep") + sp.add_argument("freqs", nargs="+", type=parse_freq, + help="frequencies to watch (parse_freq notation)") + sp.add_argument("--span", type=parse_freq, default=2e6, + help="sweep margin around watched freqs (default 2M)") + sp.add_argument("-d", "--duration", type=float, default=None, + help="seconds to run (default: until Ctrl-C)") + sp.add_argument("-l", "--lna", type=int, default=16) + sp.add_argument("-g", "--vga", type=int, default=20) + sp.add_argument("-a", "--amp", action="store_true") + sp.add_argument("--force", action="store_true") + sp.add_argument("-v", "--verbose", action="store_true") + sp.add_argument("--serial", help="select a board by serial number") + + # scan + sp = sub.add_parser("scan", help="capture IQ at several frequencies; " + "print power per frequency") + sp.add_argument("freqs", nargs="+", type=parse_freq, + help="frequencies to visit (parse_freq notation)") + sp.add_argument("-s", "--sample-rate", dest="sample_rate", + type=parse_freq, default=8e6) + sp.add_argument("-n", "--num-samples", dest="num_samples", type=int, + default=262144) + sp.add_argument("-l", "--lna", type=int, default=16) + sp.add_argument("-g", "--vga", type=int, default=20) + sp.add_argument("-a", "--amp", action="store_true") + sp.add_argument("--force", action="store_true") + sp.add_argument("-v", "--verbose", action="store_true") + sp.add_argument("--serial", help="select a board by serial number") + # presets sp = sub.add_parser("presets", help="list band presets") @@ -258,6 +306,25 @@ def main(self, args: argparse.Namespace) -> None: print_cmd=args.print_cmd) elif name == "tx": + if args.cw and args.source: + raise HackRFValueError("--cw and a source file are mutually " + "exclusive") + if args.cw: + if not args.duration and not args.print_cmd: + raise HackRFValueError( + "tx --cw requires -d/--duration: a CW carrier with " + "no time bound is exactly the orphan-transmitter " + "risk the library exists to prevent") + h.transmit_cw(args.frequency, args.sample_rate, + amplitude=args.cw_amplitude, txvga=args.txvga, + amp=args.amp, bias_tee=args.bias_tee, + baseband_bw=args.baseband_bw, + duration=args.duration, + max_duration=args.max_duration, + print_cmd=args.print_cmd) + return + if not args.source: + raise HackRFValueError("tx needs a source file (or --cw)") h.transmit(args.frequency, args.sample_rate, args.source, txvga=args.txvga, amp=args.amp, bias_tee=args.bias_tee, baseband_bw=args.baseband_bw, repeat=args.repeat, @@ -266,6 +333,20 @@ def main(self, args: argparse.Namespace) -> None: print_cmd=args.print_cmd) elif name == "sweep": + if (args.binary or args.inverse_fft) and not args.out: + raise HackRFValueError( + "-B / -I produce unparsed binary that cannot go to the " + "CSV stdout path; pass -o/--out FILE") + if args.out: + h.sweep_to_file(args.f_min, args.f_max, args.out, + binary=args.binary, + inverse_fft=args.inverse_fft, + bin_width=args.bin_width, lna=args.lna, + vga=args.vga, amp=args.amp, + one_shot=args.one_shot, + num_sweeps=args.num_sweeps, + print_cmd=args.print_cmd) + return gen = h.sweep(args.f_min, args.f_max, bin_width=args.bin_width, lna=args.lna, vga=args.vga, amp=args.amp, one_shot=args.one_shot, num_sweeps=args.num_sweeps, @@ -281,6 +362,27 @@ def main(self, args: argparse.Namespace) -> None: f"{row['bin_width']:.2f}", str(row["num_samples"])] + [f"{d:.2f}" for d in row["db"]])) + elif name == "monitor": + def _print_update(update: dict[float, Any]) -> None: + for f, db in sorted(update.items()): + val = "--" if db is None else f"{db:.1f}" + print(f"{f/1e6:.3f} MHz {val} dB", flush=True) + h.monitor_frequencies(list(args.freqs), span_hz=args.span, + duration=args.duration, + on_update=_print_update, lna=args.lna, + vga=args.vga, amp=args.amp) + + elif name == "scan": + results = h.scan_frequencies(list(args.freqs), args.sample_rate, + args.num_samples, lna=args.lna, + vga=args.vga, amp=args.amp) + for f in args.freqs: + iq = (results or {}).get(f) + if iq is None or not len(iq): + print(f"{f/1e6:.3f} MHz --") + else: + print(f"{f/1e6:.3f} MHz {h.power_dbfs(iq):.1f} dBFS") + def main() -> None: app = HackRFCLI() diff --git a/hackrfpy/src/hackrfpy/core.py b/hackrfpy/src/hackrfpy/core.py index 4787274..d5e1559 100644 --- a/hackrfpy/src/hackrfpy/core.py +++ b/hackrfpy/src/hackrfpy/core.py @@ -29,6 +29,7 @@ import subprocess import sys import threading +import time import weakref from types import TracebackType from typing import Any, Callable, Generator, Literal, Protocol @@ -77,8 +78,10 @@ def _ensure_console_logging() -> None: # ---- platform interrupt plumbing ------------------------------------------- # hackrf_* tools flush + close cleanly on SIGINT. On Windows SIGINT can't be # delivered to a child; the equivalent is CTRL_BREAK_EVENT, which requires -# the child to be in its own process group. Best-effort: exercised on POSIX, -# untested on Windows. +# the child to be in its own process group. Both paths are exercised by +# tests/test_interrupt_clean.py, which asserts the interrupt signal itself +# arrives (not the terminate() escalation) and that a handler's final flush +# survives into the drained output -- the no-truncated-capture contract. if sys.platform == "win32": # pragma: no cover _CREATION_FLAGS = subprocess.CREATE_NEW_PROCESS_GROUP _INTERRUPT_SIGNAL = signal.CTRL_BREAK_EVENT @@ -100,13 +103,115 @@ def _interrupt(proc: subprocess.Popen[Any]) -> None: # It does NOT survive `kill -9` / power loss (no handler runs); that needs an # OS dead-man (Linux PR_SET_PDEATHSIG / Windows Job Object) which is platform- # split work deferred to a later pass. -# TODO(os-deadman): Linux prctl(PR_SET_PDEATHSIG); Windows Job Object with -# JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Survives hard-kill of the parent. +# ---- OS dead-man ------------------------------------------------------------ +# The atexit backstop never runs on SIGKILL / TerminateProcess, so a hard- +# killed parent could orphan a live transmitter. The dead-man asks the OS +# itself to end the child when the parent dies, surviving any kind of parent +# death: +# Linux : the child sets PR_SET_PDEATHSIG to SIGINT in preexec, so the +# kernel delivers the CLEAN interrupt (same flush path as stop()) +# the moment the parent dies. A getppid() check closes the fork- +# window race where the parent died before prctl ran. +# Windows : the child is assigned to a Job Object with KILL_ON_JOB_CLOSE. +# The parent's death closes its handles; the OS then terminates +# the whole job -- including the .bat -> python launcher tree. +# The job handle lives on the _Process; dropping every reference +# to a running protected handle therefore lets GC close the job +# and reap the child, which is the dead-man philosophy applied +# to a leaked handle. +# macOS : no equivalent primitive; the atexit backstop remains the only +# net there. +# Scope: handle-mode spawns, gated exactly like the atexit registry below +# (TX always, RX unless backstop_rx=False). Exercised by +# tests/test_deadman.py, including a real parent hard-kill. # # TX handles are ALWAYS registered (an orphaned transmitter is a regulatory / # interference problem). RX handles are registered by default but can opt out # (an orphaned receiver only wastes disk, and fire-and-forget is occasionally # wanted). +if sys.platform == "win32": + def _deadman_preexec() -> Any: + return None # Windows path uses the Job Object + + def _deadman_attach(proc: subprocess.Popen[Any]) -> Any: + import ctypes + from ctypes import wintypes + + class _BasicLimits(ctypes.Structure): + _fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64), + ("PerJobUserTimeLimit", ctypes.c_int64), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD)] + + class _IoCounters(ctypes.Structure): + _fields_ = [(n, ctypes.c_uint64) for n in ( + "ReadOperationCount", "WriteOperationCount", + "OtherOperationCount", "ReadTransferCount", + "WriteTransferCount", "OtherTransferCount")] + + class _ExtendedLimits(ctypes.Structure): + _fields_ = [("BasicLimitInformation", _BasicLimits), + ("IoInfo", _IoCounters), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t)] + + _KILL_ON_JOB_CLOSE = 0x2000 + _EXTENDED_LIMIT_CLASS = 9 + try: + k32 = ctypes.windll.kernel32 + job = k32.CreateJobObjectW(None, None) + if not job: + return None + info = _ExtendedLimits() + info.BasicLimitInformation.LimitFlags = _KILL_ON_JOB_CLOSE + if not k32.SetInformationJobObject( + job, _EXTENDED_LIMIT_CLASS, ctypes.byref(info), + ctypes.sizeof(info)): + k32.CloseHandle(job) + return None + if not k32.AssignProcessToJobObject( + job, wintypes.HANDLE(int(proc._handle))): # type: ignore[attr-defined] + k32.CloseHandle(job) + return None + return job + except OSError: + return None # best-effort: never block a spawn +else: + def _deadman_preexec() -> Any: + if not sys.platform.startswith("linux"): + return None # macOS: no pdeathsig primitive + try: + import ctypes + libc = ctypes.CDLL(None, use_errno=True) + except OSError: + return None + _PR_SET_PDEATHSIG = 1 + + def _preexec() -> None: + libc.prctl(_PR_SET_PDEATHSIG, signal.SIGINT, 0, 0, 0) + if os.getppid() == 1: # parent died in the fork window + os.kill(os.getpid(), signal.SIGINT) + return _preexec + + def _deadman_attach(proc: subprocess.Popen[Any]) -> Any: + return None # POSIX path is the preexec + + +_BUSY_MARKERS = ("Resource busy", "(-1000)") + + +def _is_busy(text: str) -> bool: + # hackrf_open()'s "device still claimed" signature; see busy_retries. + return any(m in text for m in _BUSY_MARKERS) + + class _Stoppable(Protocol): # What the atexit backstop actually needs. _Process and PersistentReceiver # both satisfy it; a Protocol keeps _LIVE honest about holding both without @@ -153,6 +258,7 @@ def __init__(self, proc: subprocess.Popen[Any], owner: HackRF, kind: str = "rx") -> None: self._proc = proc self._owner = owner + self._job: Any = None # Windows Job Object (OS dead-man) self._kind = kind # "rx" | "tx" -- for atexit messaging self._stopped = False self._out_chunks: list[bytes] = [] @@ -170,7 +276,13 @@ def __init__(self, proc: subprocess.Popen[Any], owner: HackRF, @staticmethod def _drain(stream: Any, sink: list[bytes]) -> None: try: - for chunk in iter(lambda: stream.read(65536), b""): + # read1(): return as soon as ANY bytes are available (at most one + # raw read), b"" only at EOF. Plain read(65536) on a BufferedReader + # BLOCKS until 64 KB accumulate, so a child's small writes (e.g. + # hackrf_transfer's ~60-byte-per-second stats lines) were invisible + # to the parent until process exit -- output was only "live" if the + # child flooded past the buffer size. + for chunk in iter(lambda: stream.read1(65536), b""): sink.append(chunk) # Bound retained output: drop oldest chunks once over the cap, # always keeping at least the latest one. Prevents unbounded @@ -191,7 +303,18 @@ def stop(self, grace: float = 2.0) -> tuple[bytes, bytes, int | None]: try: self._proc.wait(timeout=grace) except subprocess.TimeoutExpired: + # Escalation ladder, each rung bounded and REAPED: a child + # that ignores the interrupt gets terminate(); one that + # survives that gets kill(). stop() previously fired + # terminate() and returned without waiting, so a hardened + # child could outlive stop() and result() reported + # returncode None. self._proc.terminate() + try: + self._proc.wait(timeout=grace) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() self._stopped = True _LIVE.discard(self) # no longer needs the atexit backstop return self.result() @@ -221,6 +344,22 @@ def __exit__(self, exc_type: type[BaseException] | None, class HackRF(InfoMixin, CaptureMixin, TransmitMixin, SweepMixin, DeviceMixin): + """Python controller for a HackRF One, driving the hackrf-tools binaries. + + The constructor touches no hardware; use from_device() for a fail-fast + probed handle. Receive works immediately; transmitting requires the + deliberate set_mode('tx') arming step. NOT thread-safe: one instance + per thread (see the class comment below and the README). + """ + # THREAD SAFETY: a HackRF instance is NOT safe to share across threads. + # Methods mutate per-instance state without locks -- last_params readback, + # the persisted operating-mode state, verbose/logging wiring -- and the + # process handles it returns own per-child drain threads whose lists are + # appended from those threads but read from the caller's. One instance + # per thread (they are cheap: the constructor touches nothing), or confine + # all hackrfpy calls to a single worker thread. This limitation predates + # 1.0 and is recorded here rather than "fixed" because the right fix + # (locking) would serialize the interesting operations anyway. def __init__(self, tools_dir: str | None = None, verbose: bool = False, serial: str | None = None) -> None: # ---- feedback ---- @@ -255,6 +394,14 @@ def __init__(self, tools_dir: str | None = None, verbose: bool = False, # TX handles are ALWAYS atexit-stopped. RX handles are too by default; # set backstop_rx=False for deliberate fire-and-forget receivers. self.backstop_rx = True + # Device-busy retry: hackrf_open() reports "Resource busy (-1000)" + # when the previous child's USB claim has not been released yet -- + # on Linux the kernel takes a beat after a tool exits, so rapid + # back-to-back operations (scan_frequencies, test suites, any + # capture-then-sweep script) can lose the race. Bounded retries with + # backoff absorb it; set busy_retries=0 to fail immediately. + self.busy_retries = 3 + self.busy_backoff = 0.25 # seconds; doubles per attempt # ---- parameter readback ---- # Populated by every validated operation with the ACTUAL values used @@ -274,6 +421,7 @@ def _record_params(self, **params: Any) -> dict[str, Any]: # Feedback # ================================================================= def set_verbose(self, verbose: bool = True) -> None: + """Enable or disable verbose progress output (INFO-level logging).""" self.verboseEnabled = verbose if verbose: # Make INFO actually visible in a plain script (see @@ -283,15 +431,21 @@ def set_verbose(self, verbose: bool = True) -> None: log.setLevel(logging.INFO) def get_verbose(self) -> bool: + """Return whether verbose progress output is enabled.""" return self.verboseEnabled def print_message(self, msg: str) -> None: + """Emit a progress message (INFO, stderr) if verbose is enabled.""" # Progress / status chatter. INFO, and gated on verbose so the level and # the flag agree. Goes to stderr (never stdout) -- see the module note. if self.verboseEnabled: log.info(msg) def warn(self, msg: str) -> None: + """Emit a warning (WARNING, stderr) regardless of the verbose flag. + + Used for safety and degraded-results notices that must never be silent. + """ # Safety / correctness warnings the user must see REGARDLESS of verbose. # (A degraded-results or out-of-spec notice that only prints in verbose # mode is, in practice, a silent warning.) WARNING level, so it survives @@ -311,9 +465,16 @@ def mode(self, value: str) -> None: self.set_mode(value) def get_mode(self) -> str: + """Return the current operating mode: "rx" or "tx".""" return self._mode def set_mode(self, value: str) -> str: + """Switch operating mode ("rx" or "tx") and persist it. + + Switching to TX prints the one-time safety banner. Transmit methods + refuse unless the instance is in TX mode; this switch is the deliberate + arming step. + """ value = str(value).lower() if value not in C.MODES: raise HackRFValueError( @@ -326,6 +487,11 @@ def set_mode(self, value: str) -> str: return self._mode def restore_mode(self, value: str) -> str: + """Rehydrate a previously persisted mode without the switch ceremony. + + No banner, no chatter: for the CLI restoring state between invocations + (the banner already fired at the original mode switch). + """ # Rehydrate previously-persisted mode WITHOUT the switch ceremony # (no banner, no chatter). For the CLI restoring state between # invocations; the banner already fired at the original `mode tx`. @@ -337,6 +503,7 @@ def restore_mode(self, value: str) -> str: return self._mode def require_mode(self, needed: str) -> None: + """Raise HackRFModeError unless the instance is in the given mode.""" if self._mode != needed: raise HackRFModeError( f"operation requires '{needed}' mode but device is in " @@ -421,6 +588,11 @@ def _auto_baseband(self, sample_rate: float, def validate_rx(self, freq: float, sample_rate: float, lna: int, vga: int) -> tuple[float, float, int, int]: + """Validate and snap receive parameters; return the values actually used. + + Hard-range checks frequency and sample rate, snaps LNA/VGA to real + device gain steps. Returns (freq, sample_rate, lna, vga). + """ freq = self._check_hard_range("frequency", freq, C.FREQ_MIN_HZ, C.FREQ_MAX_HZ) sample_rate = self._check_hard_range("sample_rate", sample_rate, @@ -431,6 +603,11 @@ def validate_rx(self, freq: float, sample_rate: float, lna: int, def validate_tx(self, freq: float, sample_rate: float, txvga: int, amp: bool) -> tuple[float, float, int, bool]: + """Validate and snap transmit parameters; return the values actually used. + + TX gain is capped by constants.TX_VGA_CEILING_DB. The only mode gate is + require_mode('tx'); frequency uses the full device range. + """ # NOTE: TX frequency uses the full device range and is never policed by # --force; the only gate is being in TX mode. The gain ceiling guards # against an order-of-magnitude fat-finger. @@ -454,6 +631,11 @@ def validate_tx(self, freq: float, sample_rate: float, txvga: int, # Binary resolution # ================================================================= def resolve(self, key: str) -> str: + """Return the full path of a hackrf tool by TOOLS key (e.g. 'transfer'). + + Raises HackRFDeviceError with an actionable message if the tool is not + found in tools_dir, the configured directory, or PATH. + """ # key is a TOOLS key ("transfer", "info", ...). Returns the full path # or raises HackRFDeviceError with an actionable message. name = C.TOOLS[key] @@ -485,6 +667,12 @@ def resolve(self, key: str) -> str: @classmethod def from_device(cls, *, tools_dir: str | None = None, verbose: bool = False, serial: str | None = None) -> HackRF: + """Build a HackRF and immediately probe the attached board (fail fast). + + Unlike the bare constructor (which touches nothing), this runs + hackrf_info: it raises HackRFDeviceError if tools are missing or no board + is present, and warns if the firmware looks stale. + """ # Build a HackRF and immediately probe the attached board so callers # get a device whose reported firmware is known up front. Unlike the # bare constructor (which touches nothing), this RUNS hackrf_info, so @@ -492,7 +680,11 @@ def from_device(cls, *, tools_dir: str | None = None, verbose: bool = False, # present. Use it when you want a fail-fast handle for scripting. h = cls(tools_dir=tools_dir, verbose=verbose, serial=serial) info = h.info() # raises if no tools / no board - assert isinstance(info, dict) # raw=False -> parsed dict + if not isinstance(info, dict): # raw=False -> parsed dict + # a plain assert is stripped under `python -O`, which would let a + # str flow onward; keep the guard a real, typed error + raise HackRFDeviceError( + "hackrf_info output could not be parsed into a device dict") if not info.get("boards"): raise HackRFDeviceError("no HackRF board detected") h._probed = info @@ -546,18 +738,60 @@ def _run(self, argv: list[Any], *, mode: str = "blocking", if mode == "stream": return self._stream(resolved, text=text, read_samples=read_samples) if mode == "handle": + # Tier C (OS dead-man): same gate as the atexit registry below. + protected = kind == "tx" or self.backstop_rx + preexec = _deadman_preexec() if protected else None proc = subprocess.Popen(resolved, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - creationflags=_CREATION_FLAGS) + creationflags=_CREATION_FLAGS, + preexec_fn=preexec) handle = _Process(proc, self, kind=kind) + if protected: + handle._job = _deadman_attach(proc) # Tier B: TX always backstopped; RX backstopped unless opted out. - if kind == "tx" or self.backstop_rx: + if protected: _register_live(handle) return handle if mode == "timed" and duration is None: raise HackRFValueError("timed mode requires duration") + for _busy_attempt in range(max(0, int(self.busy_retries)) + 1): + out, err, rc = self._run_once(resolved, mode, duration, text) + blob = self._text_of(err) + self._text_of(out) + if rc in (0, None) or not _is_busy(blob): + break + if _busy_attempt < self.busy_retries: + delay = self.busy_backoff * (2 ** _busy_attempt) + self.warn(f"device busy (previous claim not yet released); " + f"retrying in {delay:.2f}s " + f"({_busy_attempt + 1}/{self.busy_retries})") + time.sleep(delay) + + # timed runs end via our SIGINT, so a non-zero rc there is expected. + if check and mode != "timed" and rc not in (0, None): + errtxt = self._text_of(err) + detail = errtxt.strip() + if not detail: + # some tools report failures on STDOUT with an empty stderr + # (Linux hackrf_info prints "No HackRF boards found." there, + # exit 1) -- without this fallback the error read + # "exited 1: " with the actual reason discarded + tail = [ln for ln in self._text_of(out).splitlines() + if ln.strip()] + detail = tail[-1].strip() if tail else "" + raise HackRFDeviceError( + f"{C.TOOLS[argv[0]]} exited {rc}: {detail}") + return out, err, rc + + @staticmethod + def _text_of(blob: Any) -> str: + if isinstance(blob, bytes): + return blob.decode(errors="replace") + return blob or "" + + def _run_once(self, resolved: list[Any], mode: str, + duration: float | None, text: bool) -> tuple[Any, Any, Any]: proc = subprocess.Popen(resolved, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, creationflags=_CREATION_FLAGS) @@ -578,14 +812,7 @@ def _run(self, argv: list[Any], *, mode: str = "blocking", # capture would just start the next segment). self._sigint_and_reap(proc) raise - - rc = proc.returncode - # timed runs end via our SIGINT, so a non-zero rc there is expected. - if check and mode != "timed" and rc not in (0, None): - errtxt = err.decode(errors="replace") if isinstance(err, bytes) else err - raise HackRFDeviceError( - f"{C.TOOLS[argv[0]]} exited {rc}: {errtxt.strip()}") - return out, err, rc + return out, err, proc.returncode @staticmethod def _sigint_and_reap(proc: subprocess.Popen[Any], @@ -600,6 +827,29 @@ def _sigint_and_reap(proc: subprocess.Popen[Any], def _stream(self, resolved: list[str], text: bool = False, read_samples: int = 65536) -> Generator[Any, None, None]: + # Device-busy retry, stream flavor: if the child dies with the + # hackrf_open busy signature BEFORE anything was yielded, respawn + # with backoff (same policy as _run). Once data has flowed, a busy + # error can no longer be a stale-claim race and is raised as-is. + for _busy_attempt in range(max(0, int(self.busy_retries)) + 1): + yielded = False + try: + for item in self._stream_once(resolved, text, read_samples): + yielded = True + yield item + return + except HackRFDeviceError as e: + if (yielded or not _is_busy(str(e)) + or _busy_attempt >= self.busy_retries): + raise + delay = self.busy_backoff * (2 ** _busy_attempt) + self.warn(f"device busy (previous claim not yet released); " + f"retrying in {delay:.2f}s " + f"({_busy_attempt + 1}/{self.busy_retries})") + time.sleep(delay) + + def _stream_once(self, resolved: list[str], text: bool = False, + read_samples: int = 65536) -> Generator[Any, None, None]: # Generator twin of _run. Launch, yield stdout as it arrives, clean up # on the caller breaking out (GeneratorExit) so we never leave a # transmitting/receiving process orphaned. @@ -658,10 +908,39 @@ def _eat(stream: Any, sink: list[Any]) -> None: finally: if proc.poll() is None: _interrupt(proc) + # brief window with the pipe OPEN: a well-behaved child's + # SIGINT handler runs (flush, marker, clean close) and it + # exits here + try: + proc.wait(timeout=0.5) + except subprocess.TimeoutExpired: + pass + if proc.poll() is None: + # Still alive: with the consumer gone, the 64 KB pipe fills + # in milliseconds at capture rates and the child is stuck + # inside write(); its SIGINT/SIGTERM handlers only set an + # exit flag a blocked write never returns to check. On real + # hardware the old interrupt->wait->terminate sequence left + # hackrf_transfer frozen in write(), HOLDING THE USB CLAIM + # until interpreter exit, and every later open failed + # "Resource busy". Closing our read end turns the frozen + # write into EPIPE/SIGPIPE so the child can die (the kernel + # releases the claim on any death) -- then finish the + # ladder, which previously stopped at an unreaped + # terminate(). + try: + stdout.close() + except OSError: + pass try: proc.wait(timeout=2.0) except subprocess.TimeoutExpired: proc.terminate() + try: + proc.wait(timeout=1.0) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() if not broke_out and proc.returncode not in (0, None): t.join(timeout=2.0) # let the drain finish before reading it err = (b"" if not text else "").join(err_tail) @@ -675,6 +954,11 @@ def _eat(stream: Any, sink: list[Any]) -> None: # Shared helpers used by mixins # ================================================================= def decode_iq(self, raw: bytes) -> np.ndarray: + """Decode HackRF-native interleaved int8 I/Q bytes to complex64. + + Values are normalized to roughly [-1, 1); an odd trailing byte + (truncated final pair) is dropped. + """ # HackRF native format -> complex64. Interleaved int8 I,Q,I,Q... # Guard against an odd trailing byte (truncated final pair). # @@ -703,6 +987,7 @@ def decode_iq(self, raw: bytes) -> np.ndarray: @staticmethod def power_dbfs(iq: np.ndarray) -> float: + """Mean power of a complex64 block in dBFS (0 dBFS = |amplitude| 1.0).""" # Mean power of a complex64 block in dBFS (dB relative to full scale). # 0 dBFS == |amplitude| 1.0 (ADC full scale). Always <= 0 for real # captures. This is the raw, UNCALIBRATED reading. @@ -714,6 +999,7 @@ def power_dbfs(iq: np.ndarray) -> float: @staticmethod def gain_db(lna: int = 0, vga: int = 0, amp: bool = False) -> float: + """Total configured receive gain chain in dB (LNA + VGA + optional amp).""" # Total RX gain through the chain in dB: LNA (IF) + VGA (baseband) + # the fixed ~14 dB front-end amp if enabled. This is the quantity that # makes a raw dBFS reading ambiguous -- the SAME signal reads ~36 dB @@ -726,6 +1012,12 @@ def relative_power_db(self, iq_or_dbfs: np.ndarray | float, *, freq_hz: float | None = None, freq_correction: Callable[[float], float] | None = None ) -> float: + """Gain-normalized power: dBFS minus the configured gain chain. + + Readings are consistent across gain settings but RELATIVE, not absolute + dBm, unless offset_db from a known reference is supplied + (see examples/calibrate.py). + """ # Gain-normalized power: subtract the gain chain so readings taken at # DIFFERENT gain settings are directly comparable. This is the Level 1 # relative calibration -- still not absolute dBm, but consistent. @@ -761,6 +1053,11 @@ def estimate_capture(self, sample_rate: float, num_samples: int | None = None, duration: float | None = None, path: str = ".") -> dict[str, Any]: + """Estimate bytes, duration, and disk fit for a planned capture. + + Returns a dict including sizes and free-disk headroom; use before long + captures to avoid filling the drive mid-recording. + """ # Bytes/sec = sample_rate * 2 (int8 I + int8 Q). Returns a dict and # raises HackRFEnvironmentError if it would blow past free disk. bps = sample_rate * C.BYTES_PER_SAMPLE diff --git a/hackrfpy/src/hackrfpy/sigmf.py b/hackrfpy/src/hackrfpy/sigmf.py index 4b694c0..b041411 100644 --- a/hackrfpy/src/hackrfpy/sigmf.py +++ b/hackrfpy/src/hackrfpy/sigmf.py @@ -26,6 +26,11 @@ def write_sigmf_meta(data_path: str, freq: float, sample_rate: float, *, lna: int | None = None, vga: int | None = None, amp: bool | None = None, datatype: str = "ci8", extra: dict[str, Any] | None = None) -> str: + """Write a SigMF .sigmf-meta sidecar for an IQ file. + + Records datatype (ci8 by default), sample rate, center frequency, + gains under the declared hackrf extension, and a capture timestamp. + """ # Sidecar path: foo.iq -> foo.sigmf-meta base, _ = os.path.splitext(data_path) meta_path = base + ".sigmf-meta" diff --git a/hackrfpy/tests/collect_fm_testdata.py b/hackrfpy/tests/collect_fm_testdata.py new file mode 100644 index 0000000..3279429 --- /dev/null +++ b/hackrfpy/tests/collect_fm_testdata.py @@ -0,0 +1,411 @@ +#! /usr/bin/python3 + +##--------------------------------------------------------------------\ +# hackrfpy 'tests/collect_fm_testdata.py' +# +# REPEATABLE FM test-data generator: finds candidate stations, +# calibrates gain, VERIFIES each candidate's 19 kHz pilot on a short +# probe, records the first verified station, and validates the result. +# If every stage fails, FALLS BACK to a deterministic synthetic FM +# recording in the identical on-disk format, so downstream work is +# never blocked by RF conditions, time of day, weather, or a missing +# board. +# +# Pipeline (hardware path): +# 1. DISCOVER sweep 88-108 MHz, take the TOP-N hottest bins as +# candidate stations (skipped when --station is given) +# 2. CALIBRATE probe captures, walking LNA/VGA until peak amplitude +# lands in [0.25, 0.70] +# 3. VERIFY for each candidate: short probe, refine to the true +# 100 kHz channel, measure the pilot AT the offset; +# first candidate with pilot SNR >= 6 dB wins. Station +# strength varies with propagation and weather, so no +# single sweep argmax is ever trusted with the full +# recording. +# 4. RECORD the verified station at a +300 kHz offset from +# center, clear of the DC/LO spike +# 5. VALIDATE length, ADC utilization, clipping, pilot +# +# Sample-rate handling: the HackRF's baseband filter bottoms out at +# 1.75 MHz, so rates under 8 Msps admit aliases (the library warns +# about exactly this). ALL hardware captures here therefore run at an +# integer multiple of the output rate that is >= 8 Msps, then decimate +# in software (windowed-sinc FIR) down to --sample-rate. The output +# files are unchanged: 2 Msps int8 + SigMF by default. +# +# Fallback (--fallback auto|always|never, default auto): synthesizes +# broadcast-style FM at the same rate/offset -- 19 kHz pilot, 1 kHz +# L+R tone, 75 kHz deviation, seeded noise -- quantized to int8 and +# written with a SigMF sidecar plus a report.json marked +# "mode": "synthetic". Deterministic: identical bytes on every run. +# +# Every run's report.json logs the candidates tried and their pilot +# SNRs, so collections at different times of day stay comparable. +# +# Output: tests/fm_testdata/ (iq + .sigmf-meta + .report.json) +# Exit codes: 0 = usable data written (hardware OR synthetic), +# 1 = nothing written (only possible with --fallback never) +# +# SAFETY: strictly READ-ONLY. Never transmits, never writes firmware. +# +# Usage: +# uv run python tests/collect_fm_testdata.py +# uv run python tests/collect_fm_testdata.py --station 97.3M +# uv run python tests/collect_fm_testdata.py --fallback always +# +# +# Author(s): Lauren Linkous +##--------------------------------------------------------------------\ + +import argparse +import datetime +import json +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +for cand in (os.path.join(_HERE, "..", "src"), os.path.join(_HERE, "src")): + if os.path.isdir(os.path.join(cand, "hackrfpy")): + sys.path.insert(0, os.path.abspath(cand)) + break + +from hackrfpy import HackRF, parse_freq, write_sigmf_meta # noqa: E402 +from hackrfpy.exceptions import HackRFError # noqa: E402 + +try: + import numpy as np # noqa: E402 +except ModuleNotFoundError: + sys.stderr.write( + "ERROR: numpy not available -- run through uv:\n" + " uv run python tests/collect_fm_testdata.py [args]\n") + sys.exit(1) + +OUT_DIR = os.path.join(_HERE, "fm_testdata") +STATION_OFFSET = 300_000.0 # station sits here, clear of the DC spike +PEAK_LO, PEAK_HI = 0.25, 0.70 # ADC utilization target window +UTIL_FLOOR = 0.10 # below this = unusable quantization +LNA_STEP, LNA_MAX = 8, 40 +VGA_STEP, VGA_MAX = 8, 40 +PILOT_HZ = 19_000.0 +PILOT_OK_DB = 6.0 +HW_RATE_FLOOR = 8_000_000 # capture at >= this; decimate to output +N_CANDIDATES = 4 # stations to try before giving up + + +# ---- rate handling ---------------------------------------------------------- + +def hw_plan(out_rate): + # Smallest integer multiple of the output rate that clears the aliasing + # floor; HackRF takes any rate in 2-20 Msps. factor 1 = no decimation. + if out_rate >= HW_RATE_FLOOR: + return out_rate, 1 + factor = int(np.ceil(HW_RATE_FLOOR / out_rate)) + hw = out_rate * factor + if hw > 20e6: # can't clear the floor + return out_rate, 1 + return hw, factor + + +def decimate(iq, factor): + if factor == 1: + return iq + ntaps = 16 * factor + 1 + m = np.arange(ntaps) - (ntaps - 1) / 2 + cutoff = 0.45 / factor # of hw Nyquist + taps = np.sinc(2 * cutoff * m) * np.hanning(ntaps) + taps /= taps.sum() + return np.convolve(iq, taps, mode="same")[::factor].astype(np.complex64) + + +# ---- shared analysis -------------------------------------------------------- + +def peak_amp(iq): + return float(np.abs(iq).max()) if len(iq) else 0.0 + + +def iq_metrics(iq): + mag = np.abs(iq) + p = float(np.mean(mag.astype(np.float64) ** 2)) if len(iq) else 0.0 + return {"mean_power_dbfs": round(10 * np.log10(p + 1e-20), 2), + "peak_amplitude": round(peak_amp(iq), 4), + "clip_fraction": round(float(np.mean(mag > 0.99)), 6) if len(iq) else 1.0, + "dc_offset": round(float(abs(np.mean(iq))), 5) if len(iq) else 0.0} + + +def pilot_snr_db(iq, fs, offset_hz): + # Shift the station (at offset_hz from center) to DC, decimate to + # ~250 kHz, FM-discriminate, measure the pilot over the flanking floor. + if len(iq) < 65536: + return -99.0 + t = np.arange(len(iq)) / fs + shifted = iq * np.exp(-2j * np.pi * offset_hz * t) + d = max(1, int(fs // 250_000)) + lp = decimate(shifted, d) if d > 1 else shifted + fsd = fs / d + demod = np.angle(lp[1:] * np.conj(lp[:-1])) + spec = np.abs(np.fft.rfft(demod * np.hanning(len(demod)))) ** 2 + fr = np.fft.rfftfreq(len(demod), 1.0 / fsd) + + def band(lo, hi): + m = (fr >= lo) & (fr < hi) + return float(spec[m].max()) if m.any() else 0.0 + + pilot = band(PILOT_HZ - 300, PILOT_HZ + 300) + floor = np.median([band(16_000, 17_000), band(21_000, 22_000)]) + return round(10 * np.log10((pilot + 1e-20) / (floor + 1e-20)), 1) + + +def validate(iq, expected_n, fs, offset_hz): + m = iq_metrics(iq) + reasons = [] + if len(iq) < 0.9 * expected_n: + reasons.append(f"short read ({len(iq)}/{expected_n})") + if m["peak_amplitude"] < UTIL_FLOOR: + reasons.append(f"low ADC utilization (peak {m['peak_amplitude']:.3f} " + f"< {UTIL_FLOOR} -- raise LNA/VGA)") + if m["clip_fraction"] > 0.01: + reasons.append(f"clipping ({m['clip_fraction']*100:.1f}%)") + snr = pilot_snr_db(iq, fs, offset_hz) + if snr < PILOT_OK_DB: + reasons.append(f"no 19 kHz pilot at offset (SNR {snr} dB)") + m["pilot_snr_db"] = snr + return (not reasons), reasons, m + + +def encode_iq(iq): + out = np.empty(len(iq) * 2, dtype=np.int8) + out[0::2] = np.clip(np.round(iq.real * 128.0), -128, 127) + out[1::2] = np.clip(np.round(iq.imag * 128.0), -128, 127) + return out.tobytes() + + +def write_set(stem, iq, center_hz, fs, report): + os.makedirs(OUT_DIR, exist_ok=True) + iq_path = os.path.join(OUT_DIR, stem + ".iq") + with open(iq_path, "wb") as f: + f.write(encode_iq(iq)) + write_sigmf_meta(iq_path, center_hz, fs, + lna=report.get("lna_gain_db", 0), + vga=report.get("vga_gain_db", 0), + amp=False, datatype="ci8") + report_path = os.path.join(OUT_DIR, stem + ".report.json") + with open(report_path, "w", newline="\n") as f: + json.dump(report, f, indent=2) + print(f" wrote {os.path.basename(iq_path)} " + f"({os.path.getsize(iq_path)/1e6:.1f} MB) + sidecar + report") + return iq_path + + +# ---- hardware path ---------------------------------------------------------- + +def discover_candidates(h, hw_rate): + print(f"== 1/5 discover: sweeping 88-108 MHz (top {N_CANDIDATES}) ==") + rows = h.sweep_collect(88e6, 108e6, num_sweeps=3, lna=24, vga=24) + bins = {} # bin center -> best dB seen + for r in rows: + for i, db in enumerate(r["db"]): + hz = r["hz_low"] + (i + 0.5) * r["bin_width"] + if 88e6 <= hz <= 108e6: + bins[hz] = max(bins.get(hz, -999.0), db) + ranked = sorted(bins.items(), key=lambda kv: -kv[1])[:N_CANDIDATES] + for hz, db in ranked: + print(f" candidate bin {hz/1e6:6.1f} MHz {db:6.1f} dB") + return [hz for hz, _ in ranked] + + +def refine_channel(h, coarse_hz, hw_rate, factor, lna, vga): + # One probe: find the strongest carrier near the coarse bin and snap it + # to the broadcast 100 kHz raster. Runs at the alias-safe hw rate. + probe = h.capture_array(coarse_hz, hw_rate, int(hw_rate * 0.05), + lna=lna, vga=vga) + nfft = 8192 + acc = np.zeros(nfft) + for k in range(len(probe) // nfft): + acc += np.abs(np.fft.fftshift( + np.fft.fft(probe[k*nfft:(k+1)*nfft] * np.hanning(nfft)))) ** 2 + c = nfft // 2 + acc[c-4:c+5] = 0 # ignore DC spike + freqs = np.fft.fftshift(np.fft.fftfreq(nfft, 1.0 / hw_rate)) + inband = np.abs(freqs) < 600e3 # stay near the bin + acc[~inband] = 0 + station = coarse_hz + freqs[int(np.argmax(acc))] + return round(station / 100e3) * 100e3 + + +def calibrate_gain(h, center, hw_rate, args): + print("== 2/5 calibrate: walking gains toward peak " + f"[{PEAK_LO}, {PEAK_HI}] ==") + lna, vga = args.lna, args.vga + pk = 0.0 + for _ in range(8): + iq = h.capture_array(center, hw_rate, 200_000, lna=lna, vga=vga) + pk = peak_amp(iq) + clip = float(np.mean(np.abs(iq) > 0.99)) + print(f" lna {lna:2d} vga {vga:2d} -> peak {pk:.3f} clip {clip*100:.2f}%") + if clip > 0.001 or pk > PEAK_HI: + if vga > 0: + vga = max(0, vga - VGA_STEP) + elif lna > 0: + lna = max(0, lna - LNA_STEP) + else: + break + elif pk < PEAK_LO: + if vga < VGA_MAX: + vga = min(VGA_MAX, vga + VGA_STEP) + elif lna < LNA_MAX: + lna = min(LNA_MAX, lna + LNA_STEP) + else: + break # maxed out; take it + else: + break + return lna, vga, pk + + +def verify_candidates(h, candidates, hw_rate, factor, out_rate, lna, vga, tried): + print(f"== 3/5 verify: pilot check per candidate (need >= " + f"{PILOT_OK_DB:g} dB) ==") + for coarse in candidates: + station = refine_channel(h, coarse, hw_rate, factor, lna, vga) + center = station - STATION_OFFSET + probe_hw = h.capture_array(center, hw_rate, int(hw_rate * 0.5), + lna=lna, vga=vga) + probe = decimate(probe_hw, factor) + snr = pilot_snr_db(probe, out_rate, STATION_OFFSET) + tried.append({"station_hz": station, "pilot_snr_db": snr}) + mark = "OK" if snr >= PILOT_OK_DB else "--" + print(f" {station/1e6:6.1f} MHz pilot {snr:6.1f} dB {mark}") + if snr >= PILOT_OK_DB: + return station + return None + + +def hardware_capture(args): + h = HackRF(tools_dir=args.tools_dir, verbose=False) + det = h.detect() + if not det["ready"]: + print(f" no usable HackRF: {det['problem']}", file=sys.stderr) + return None + out_rate = args.sample_rate + hw_rate, factor = hw_plan(out_rate) + if factor > 1: + print(f"[*] capturing at {hw_rate/1e6:g} Msps, decimating x{factor} " + f"to {out_rate/1e6:g} Msps (alias-safe)") + tried = [] + if args.station: + candidates = [parse_freq(args.station)] + else: + candidates = discover_candidates(h, hw_rate) + if not candidates: + print(" sweep found nothing", file=sys.stderr) + return None + lna, vga, pk = calibrate_gain(h, candidates[0] - STATION_OFFSET, + hw_rate, args) + if pk < UTIL_FLOOR: + print(f" calibration could not reach usable level (peak {pk:.3f})", + file=sys.stderr) + return None + station = verify_candidates(h, candidates, hw_rate, factor, out_rate, + lna, vga, tried) + if station is None: + print(" no candidate showed a pilot (conditions? antenna?)", + file=sys.stderr) + return None + center = station - STATION_OFFSET + n_out = int(out_rate * args.seconds) + print(f"== 4/5 record: {args.seconds}s, station {station/1e6:.1f} MHz " + f"at +{STATION_OFFSET/1e3:.0f} kHz, lna {lna} vga {vga} ==") + try: + iq_hw = h.capture_array(center, hw_rate, int(hw_rate * args.seconds), + lna=lna, vga=vga) + except HackRFError as e: + print(f" capture failed: {e}", file=sys.stderr) + return None + iq = decimate(iq_hw, factor)[:n_out] + print("== 5/5 validate ==") + ok, reasons, m = validate(iq, n_out, out_rate, STATION_OFFSET) + for r in reasons: + print(f" [!] {r}", file=sys.stderr) + print(f" metrics: {m}") + if not ok: + return None + report = {"mode": "hardware", "station_hz": station, + "center_hz": center, "offset_hz": STATION_OFFSET, + "sample_rate": out_rate, "hw_sample_rate": hw_rate, + "decimation": factor, "samples": len(iq), + "lna_gain_db": lna, "vga_gain_db": vga, + "candidates_tried": tried, + "firmware": det["boards"][0].get("firmware"), + "tools_version": det["tools_version"], + "collected": datetime.datetime.now().isoformat(timespec="seconds"), + "metrics": m} + stem = f"fm_hw_{station/1e6:g}MHz_{out_rate/1e6:g}Msps" + return write_set(stem, iq, center, out_rate, report) + + +# ---- synthetic fallback ----------------------------------------------------- + +def synthesize(args): + # Deterministic broadcast-style FM: pilot + L+R tone, 75 kHz deviation, + # station at +STATION_OFFSET, fixed-seed noise floor. Same bytes every run. + print("== synthesizing deterministic FM (fallback) ==") + fs, secs = args.sample_rate, args.seconds + n = int(fs * secs) + t = np.arange(n) / fs + audio = 0.9 * np.sin(2 * np.pi * 1_000.0 * t) # L+R tone + pilot = 0.09 * np.sin(2 * np.pi * PILOT_HZ * t) + baseband = audio + pilot + phase = 2 * np.pi * 75_000.0 * np.cumsum(baseband) / fs + carrier = 0.5 * np.exp(1j * (2 * np.pi * STATION_OFFSET * t + phase)) + rng = np.random.default_rng(20260917) # fixed seed + noise = (rng.standard_normal(n) + 1j * rng.standard_normal(n)) + iq = (carrier + 0.005 * noise).astype(np.complex64) + + ok, reasons, m = validate(iq, n, fs, STATION_OFFSET) # same gate + if not ok: + print(f" [!] synthetic failed self-check: {reasons}", file=sys.stderr) + print(f" metrics: {m}") + report = {"mode": "synthetic", "station_hz": None, + "center_hz": None, "offset_hz": STATION_OFFSET, + "sample_rate": fs, "samples": n, + "lna_gain_db": 0, "vga_gain_db": 0, + "generator": "collect_fm_testdata.py deterministic v2", + "seed": 20260917, "metrics": m} + stem = f"fm_synth_{fs/1e6:g}Msps" + return write_set(stem, iq, 0.0, fs, report) if ok else None + + +def main(): + p = argparse.ArgumentParser( + description="Calibrated, pilot-verified FM test-data collection with " + "deterministic synthetic fallback (READ-ONLY).") + p.add_argument("--station", default=None, + help="station frequency (e.g. 97.3M); default: " + "auto-discover and verify top candidates") + p.add_argument("--seconds", type=float, default=2.0) + p.add_argument("--sample-rate", type=parse_freq, default=2e6, + help="OUTPUT rate; hardware runs at >=8 Msps and " + "decimates (default 2M)") + p.add_argument("--lna", type=int, default=32, help="calibration start LNA") + p.add_argument("--vga", type=int, default=28, help="calibration start VGA") + p.add_argument("--fallback", choices=["auto", "always", "never"], + default="auto") + p.add_argument("--tools-dir", default=None) + args = p.parse_args() + + path = None + if args.fallback != "always": + try: + path = hardware_capture(args) + except Exception as e: # never block the pipeline + print(f" hardware path error: {e}", file=sys.stderr) + if path is None and args.fallback != "never": + path = synthesize(args) + if path: + print(f"== OK: usable FM test data at {os.path.relpath(path, _HERE)} ==") + return 0 + print("== FAILED: no usable data written ==", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hackrfpy/tests/collect_real_FM_data.py b/hackrfpy/tests/collect_real_FM_data.py new file mode 100644 index 0000000..c0cb7ee --- /dev/null +++ b/hackrfpy/tests/collect_real_FM_data.py @@ -0,0 +1,193 @@ +#! /usr/bin/python3 + +##--------------------------------------------------------------------\ +# hackrfpy 'tests/collect_real_FM_data.py' +# +# Collect a REFERENCE recording of ONE FM broadcast station, with +# enough validation and metadata that the capture can be compared +# apples-to-apples against another implementation (e.g. a hardware +# receiver or a different SDR) tuned to the same station. +# +# This is deliberately SPLIT OUT from collect_real_data.py: +# - collect_real_data.py freezes tiny verbatim slices as parser +# test fixtures (bytes in, bytes archived). +# - this script records one KNOWN, VALIDATED signal -- +# a named FM station -- and writes a +# machine-readable quality report next to +# it, so a hardware comparison has a +# trustworthy software-side baseline. +# +# Validation performed on the capture (all recorded in the report): +# - length vs. requested, mean power (dBFS), clipping fraction, +# DC offset of the raw IQ +# - FM discriminator + FFT: presence and SNR of the 19 kHz stereo +# pilot, the definitive "this really is a broadcast FM station" +# check (a pilot can't come from noise or a wrong tune) +# +# SAFETY: strictly READ-ONLY. Never transmits, never writes firmware. +# +# Usage: +# uv run python tests/collect_real_FM_data.py --station 98.1e6 +# uv run python tests/collect_real_FM_data.py --station 100.9M \ +# --seconds 2.0 --sample-rate 2e6 --lna 24 --vga 20 +# uv run python tests/collect_real_FM_data.py --station 98.1M \ +# --tools-dir "C:\hackrf-tools-windows" +# +# +# Author(s): Lauren Linkous +##--------------------------------------------------------------------\ + +import argparse +import datetime +import json +import os +import sys + +# Allow running from the repo without installing: add src/ to the path. +_HERE = os.path.dirname(os.path.abspath(__file__)) +for cand in (os.path.join(_HERE, "..", "src"), os.path.join(_HERE, "src")): + if os.path.isdir(os.path.join(cand, "hackrfpy")): + sys.path.insert(0, os.path.abspath(cand)) + break + +from hackrfpy import HackRF, load_iq, parse_freq # noqa: E402 +from hackrfpy.exceptions import HackRFError # noqa: E402 + +try: + import numpy as np # noqa: E402 +except ModuleNotFoundError: + sys.stderr.write( + "ERROR: numpy not available -- run through uv so the project env is " + "used:\n uv run python tests/collect_real_FM_data.py [args]\n") + sys.exit(1) + +OUT_DIR = os.path.join(_HERE, "fm_reference") +PILOT_HZ = 19_000.0 + + +def iq_metrics(iq): + # Raw-IQ health: are we looking at a live front end at a sane level? + mag = np.abs(iq) + power = float(np.mean(mag.astype(np.float64) ** 2)) + return { + "mean_power_dbfs": round(10 * np.log10(power + 1e-20), 2), + "peak_amplitude": round(float(mag.max()) if len(iq) else 0.0, 4), + "clip_fraction": round(float(np.mean(mag > 0.99)), 6), + "dc_offset": round(float(abs(np.mean(iq))), 5), + } + + +def pilot_metrics(iq, fs): + # FM-demodulate (phase difference), then measure the 19 kHz stereo + # pilot against the surrounding discriminator noise floor. A real + # broadcast FM station shows a clear pilot; a wrong tune or a dead + # antenna does not. + if len(iq) < 8192: + return {"pilot_detected": False, "reason": "capture too short"} + demod = np.angle(iq[1:] * np.conj(iq[:-1])) + n = len(demod) + win = np.hanning(n) + spec = np.abs(np.fft.rfft(demod * win)) ** 2 + freqs = np.fft.rfftfreq(n, d=1.0 / fs) + + def band_power(f_lo, f_hi): + m = (freqs >= f_lo) & (freqs < f_hi) + return float(spec[m].max()) if m.any() else 0.0 + + pilot = band_power(PILOT_HZ - 300, PILOT_HZ + 300) + # noise reference: flanking bands that broadcast FM leaves quiet-ish + floor = np.median([band_power(21_000, 22_000), + band_power(16_000, 17_000)]) + snr_db = 10 * np.log10((pilot + 1e-20) / (floor + 1e-20)) + return { + "pilot_detected": bool(snr_db > 6.0), + "pilot_snr_db": round(float(snr_db), 1), + } + + +def main(): + p = argparse.ArgumentParser( + description="Record + validate one FM station as a reference " + "dataset (READ-ONLY).") + p.add_argument("--station", required=True, + help="station center frequency (e.g. 98.1e6 or 98.1M)") + p.add_argument("--seconds", type=float, default=2.0, + help="capture duration (default 2.0 s)") + p.add_argument("--sample-rate", default="8e6", + help="sample rate sps (default 8e6: HackRF's baseband " + "filter bottoms out at 1.75 MHz, so rates under " + "8 Msps admit aliases; larger files are the price " + "of a trustworthy reference)") + p.add_argument("--lna", type=int, default=32) + p.add_argument("--vga", type=int, default=28) + p.add_argument("--tools-dir", default=None) + args = p.parse_args() + freq = parse_freq(args.station) + fs = parse_freq(args.sample_rate) + n = int(fs * args.seconds) + + h = HackRF(tools_dir=args.tools_dir, verbose=False) + print("== confirming a real board before collecting ==") + det = h.detect() + if not det["ready"]: + print(f" NO USABLE HACKRF: {det['problem']}", file=sys.stderr) + return 1 + print(f" ready: firmware {det['boards'][0].get('firmware')}") + + os.makedirs(OUT_DIR, exist_ok=True) + stem = f"fm_{freq/1e6:g}MHz_{fs/1e6:g}Msps" + iq_path = os.path.join(OUT_DIR, stem + ".iq") + + print(f"== capturing {args.seconds}s of {freq/1e6:g} MHz " + f"@ {fs/1e6:g} Msps ==") + try: + h.capture(freq, fs, num_samples=n, out=iq_path, + lna=args.lna, vga=args.vga, sigmf=True) + except HackRFError as e: + print(f" capture failed: {e}", file=sys.stderr) + return 1 + + iq = load_iq(iq_path) + report = { + "station_hz": freq, + "sample_rate": fs, + "requested_samples": n, + "captured_samples": len(iq), + "lna_gain_db": h.last_params.get("lna_gain", h.last_params.get("lna")), + "vga_gain_db": h.last_params.get("vga_gain", h.last_params.get("vga")), + "firmware": det["boards"][0].get("firmware"), + "tools_version": det["tools_version"], + "collected": datetime.datetime.now().isoformat(timespec="seconds"), + "iq": iq_metrics(iq), + "fm": pilot_metrics(iq, fs), + } + report_path = os.path.join(OUT_DIR, stem + ".report.json") + with open(report_path, "w", newline="\n") as f: + json.dump(report, f, indent=2) + + print(f" wrote {os.path.basename(iq_path)} " + f"({os.path.getsize(iq_path)/1e6:.1f} MB) + .sigmf-meta") + print(f" wrote {os.path.basename(report_path)}") + print(f" IQ : {report['iq']}") + print(f" pilot : {report['fm']}") + + ok = (len(iq) >= 0.9 * n + and report["iq"]["mean_power_dbfs"] > -70 + and report["iq"]["peak_amplitude"] >= 0.1 + and report["iq"]["clip_fraction"] < 0.01 + and report["fm"].get("pilot_detected", False)) + if report["iq"]["peak_amplitude"] < 0.1: + print(" [!] low ADC utilization -- raise --lna/--vga (or use " + "tests/collect_fm_testdata.py, which auto-calibrates)", + file=sys.stderr) + if ok: + print("== PASS: capture looks like a real FM station ==") + return 0 + print("== SUSPECT: capture failed one or more checks -- verify the " + "station frequency, antenna, and gains, then re-run ==", + file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hackrfpy/tests/conftest.py b/hackrfpy/tests/conftest.py index fbe921c..80beb81 100644 --- a/hackrfpy/tests/conftest.py +++ b/hackrfpy/tests/conftest.py @@ -69,20 +69,58 @@ def fixtures_dir(): IDLE = {idle!r} EXIT_CODE = {exit_code!r} EMIT_BYTES = {emit_bytes!r} +TAIL_ON_INTERRUPT = {tail_on_interrupt!r} +IGNORE_INTERRUPT = {ignore_interrupt!r} +BUSY_FAILS = {busy_fails!r} +STDOUT_FLOOD = {stdout_flood!r} + +if BUSY_FAILS: + # simulate hackrf_open()'s stale-claim race: fail with the Resource + # busy signature the first N invocations, then behave normally. State + # lives in a counter file next to the stub so it survives respawns. + _cf = __file__ + ".busycount" + try: + _n = int(open(_cf).read()) + except (OSError, ValueError): + _n = 0 + if _n < BUSY_FAILS: + open(_cf, "w").write(str(_n + 1)) + sys.stderr.write("hackrf_open() failed: Resource busy (-1000)\\n") + sys.stderr.flush() + sys.exit(1) def _on_signal(signum, frame): + # record WHICH signal arrived, so tests can distinguish the clean + # interrupt (SIGINT / SIGBREAK from CTRL_BREAK_EVENT) from the + # terminate() escalation -- "the child died" is not "the child was + # interrupted cleanly" if MARKER: try: - open(MARKER, "w").close() - except OSError: - pass + with open(MARKER, "w") as fh: + fh.write(signal.Signals(signum).name) + except (OSError, ValueError): + try: + open(MARKER, "w").close() + except OSError: + pass + if TAIL_ON_INTERRUPT: + # the hackrf_transfer contract: flush buffered output BEFORE dying, + # so the capture file is never truncated mid-sample-pair + sys.stdout.write(TAIL_ON_INTERRUPT + "\\n") + sys.stdout.flush() sys.exit(0) -signal.signal(signal.SIGINT, _on_signal) -if hasattr(signal, "SIGBREAK"): - signal.signal(signal.SIGBREAK, _on_signal) -if hasattr(signal, "SIGTERM"): - signal.signal(signal.SIGTERM, _on_signal) +if IGNORE_INTERRUPT: + # deaf child: exercises the stop() grace-timeout -> terminate() path + signal.signal(signal.SIGINT, signal.SIG_IGN) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, signal.SIG_IGN) +else: + signal.signal(signal.SIGINT, _on_signal) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _on_signal) + if hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, _on_signal) for line in STDERR_LINES: sys.stderr.write(line + "\\n") @@ -100,9 +138,20 @@ def _on_signal(signum, frame): sys.stdout.write(line + "\\n") sys.stdout.flush() +if STDOUT_FLOOD: + # keep writing far past pipe capacity so a consumer that stops reading + # leaves this child BLOCKED inside write() -- the frozen-writer state + # that held the USB claim on real hardware + _w = 0 + while _w < STDOUT_FLOOD: + sys.stdout.buffer.write(b"\\x2a" * 4096) + _w += 4096 + sys.stdout.buffer.flush() + if IDLE: - while True: - if STDERR_LINES: + _idle_until = time.monotonic() + 30.0 # safety ceiling: never leak a + while time.monotonic() < _idle_until: # stub child on CI, even one + if STDERR_LINES: # that ignores interrupts sys.stderr.write(STDERR_LINES[-1] + "\\n") sys.stderr.flush() time.sleep(0.02) @@ -113,13 +162,17 @@ def _on_signal(signum, frame): def _write_stub(tools_dir, name, *, stdout_lines=(), stderr_lines=(), stderr_flood=0, idle=False, exit_code=0, marker=None, - emit_bytes=None): + emit_bytes=None, tail_on_interrupt=None, + ignore_interrupt=False, busy_fails=0, stdout_flood=0): py_path = os.path.join(tools_dir, name + ".py") body = _STUB_TEMPLATE.format( marker=marker, stdout_lines=list(stdout_lines), stderr_lines=list(stderr_lines), stderr_flood=stderr_flood, idle=idle, exit_code=exit_code, - emit_bytes=list(emit_bytes) if emit_bytes else None) + emit_bytes=list(emit_bytes) if emit_bytes else None, + tail_on_interrupt=tail_on_interrupt, + ignore_interrupt=ignore_interrupt, busy_fails=busy_fails, + stdout_flood=stdout_flood) with open(py_path, "w") as f: f.write(body) diff --git a/hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.iq b/hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.iq new file mode 100644 index 0000000..d9185c5 Binary files /dev/null and b/hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.iq differ diff --git a/hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.report.json b/hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.report.json new file mode 100644 index 0000000..e06cd61 --- /dev/null +++ b/hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.report.json @@ -0,0 +1,21 @@ +{ + "station_hz": 103700000.0, + "sample_rate": 8000000.0, + "requested_samples": 40000000, + "captured_samples": 40000000, + "lna_gain_db": 32, + "vga_gain_db": 20, + "firmware": "2024.02.1 (API:1.08)", + "tools_version": "git-b1dbb47", + "collected": "2026-09-17T20:20:53", + "iq": { + "mean_power_dbfs": -16.87, + "peak_amplitude": 0.5742, + "clip_fraction": 0.0, + "dc_offset": 0.0125 + }, + "fm": { + "pilot_detected": true, + "pilot_snr_db": 8.5 + } +} \ No newline at end of file diff --git a/hackrfpy/capture.sigmf-meta b/hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.sigmf-meta similarity index 78% rename from hackrfpy/capture.sigmf-meta rename to hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.sigmf-meta index 4cef0cb..f9f8f16 100644 --- a/hackrfpy/capture.sigmf-meta +++ b/hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.sigmf-meta @@ -5,7 +5,7 @@ "core:hw": "HackRF One", "core:version": "1.0.0", "core:recorder": "hackrfpy", - "hackrf:lna_gain_db": 24, + "hackrf:lna_gain_db": 32, "hackrf:vga_gain_db": 20, "hackrf:amp_enabled": false, "core:extensions": [ @@ -19,8 +19,8 @@ "captures": [ { "core:sample_start": 0, - "core:frequency": 433920000.0, - "core:datetime": "2026-07-12T00:05:06.505987+00:00" + "core:frequency": 103700000.0, + "core:datetime": "2026-09-18T00:20:52.790627+00:00" } ], "annotations": [] diff --git a/hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.wav b/hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.wav new file mode 100644 index 0000000..f424fb1 Binary files /dev/null and b/hackrfpy/tests/fm_reference/fm_103.7MHz_8Msps.wav differ diff --git a/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.iq b/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.iq new file mode 100644 index 0000000..1b982ad Binary files /dev/null and b/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.iq differ diff --git a/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.report.json b/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.report.json new file mode 100644 index 0000000..34ef495 --- /dev/null +++ b/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.report.json @@ -0,0 +1,21 @@ +{ + "station_hz": 98100000.0, + "sample_rate": 8000000.0, + "requested_samples": 40000000, + "captured_samples": 40000000, + "lna_gain_db": 32, + "vga_gain_db": 20, + "firmware": "2024.02.1 (API:1.08)", + "tools_version": "git-b1dbb47", + "collected": "2026-09-17T20:29:38", + "iq": { + "mean_power_dbfs": -16.58, + "peak_amplitude": 0.6992, + "clip_fraction": 0.0, + "dc_offset": 0.01242 + }, + "fm": { + "pilot_detected": true, + "pilot_snr_db": 23.1 + } +} \ No newline at end of file diff --git a/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.sigmf-meta b/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.sigmf-meta new file mode 100644 index 0000000..3b4c69b --- /dev/null +++ b/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.sigmf-meta @@ -0,0 +1,27 @@ +{ + "global": { + "core:datatype": "ci8", + "core:sample_rate": 8000000.0, + "core:hw": "HackRF One", + "core:version": "1.0.0", + "core:recorder": "hackrfpy", + "hackrf:lna_gain_db": 32, + "hackrf:vga_gain_db": 20, + "hackrf:amp_enabled": false, + "core:extensions": [ + { + "name": "hackrf", + "version": "1.0.0", + "optional": true + } + ] + }, + "captures": [ + { + "core:sample_start": 0, + "core:frequency": 98100000.0, + "core:datetime": "2026-09-18T00:29:38.225932+00:00" + } + ], + "annotations": [] +} \ No newline at end of file diff --git a/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.wav b/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.wav new file mode 100644 index 0000000..b13f171 Binary files /dev/null and b/hackrfpy/tests/fm_reference/fm_98.1MHz_8Msps.wav differ diff --git a/hackrfpy/tests/test_busy_retry.py b/hackrfpy/tests/test_busy_retry.py new file mode 100644 index 0000000..52cc1a8 --- /dev/null +++ b/hackrfpy/tests/test_busy_retry.py @@ -0,0 +1,83 @@ +#! /usr/bin/python3 + +##--------------------------------------------------------------------\\ +# hackrfpy 'tests/test_busy_retry.py' +# Device-busy retry: hackrf_open() reports "Resource busy (-1000)" +# when the PREVIOUS child's USB claim has not been released yet. On +# Linux the kernel takes a beat after a tool exits, so rapid back-to- +# back operations lose the race -- found on the first real-hardware +# Linux run (7 test failures, all this one error, all in tests that +# reopen the device immediately after another process used it). +# The library absorbs it with bounded, backed-off retries in every +# acquisition mode; these tests pin that with a stub that fails busy +# N times and then behaves. +# +# +# Author(s): Lauren Linkous +##--------------------------------------------------------------------\\ + +import os + +import pytest + +from hackrfpy.exceptions import HackRFDeviceError + + +def _fast(h): + h.busy_backoff = 0.01 # keep test wall time negligible + return h + + +def _spawn_count(h, tool="hackrf_operacake"): + # the counter sits next to the EXECUTED file: the extensionless launcher + # on POSIX, the .py on Windows (the .bat re-invokes it) + for suffix in ("", ".py"): + cf = os.path.join(h._tmp_path, tool + suffix + ".busycount") + if os.path.exists(cf): + return int(open(cf).read()) + return 0 + + +# ---- blocking mode ---------------------------------------------------------- +def test_blocking_retries_through_busy(stub_device): + h = _fast(stub_device(operacake=dict(stdout_lines=["OK boards: none"], + busy_fails=2))) + out, err, rc = h.operacake("-l") + assert rc == 0 and "OK boards" in out + assert _spawn_count(h) == 2 # two busy failures were absorbed + + +def test_blocking_raises_when_retries_exhausted(stub_device): + h = _fast(stub_device(operacake=dict(stdout_lines=["never seen"], + busy_fails=99))) + with pytest.raises(HackRFDeviceError, match="Resource busy"): + h.operacake("-l") + assert _spawn_count(h) == h.busy_retries + 1 # initial try + retries + + +def test_busy_retries_zero_fails_immediately(stub_device): + h = _fast(stub_device(operacake=dict(busy_fails=1))) + h.busy_retries = 0 + with pytest.raises(HackRFDeviceError, match="Resource busy"): + h.operacake("-l") + assert _spawn_count(h) == 1 + + +# ---- stream mode (sweep, monitor, and the persistent receiver ride on it) -- +_SWEEP_ROW = ("2026-06-18, 12:00:00.000000, 88000000, 88500000, 100000.00, " + "8192, -71.2, -70.1, -69.9, -72.4, -71.8") + + +def test_stream_retries_before_first_row(stub_device): + h = _fast(stub_device(sweep=dict(stdout_lines=[_SWEEP_ROW], + busy_fails=1))) + rows = h.sweep_collect(88e6, 89e6, num_sweeps=1) + assert rows and rows[0]["hz_low"] == 88000000 + + +def test_receiver_open_retries_through_busy(stub_device): + h = _fast(stub_device(transfer=dict(emit_bytes=[0, 64] * 65536, + busy_fails=1))) + with h.open_receiver(100e6, 2e6) as rx: + iq = rx.read(1024) + assert len(iq) == 1024 diff --git a/hackrfpy/tests/test_cli.py b/hackrfpy/tests/test_cli.py index 4c9ba2a..95bb9b7 100644 --- a/hackrfpy/tests/test_cli.py +++ b/hackrfpy/tests/test_cli.py @@ -362,3 +362,87 @@ def test_print_detect_rich_report(capsys): assert "stale" in out # firmware_stale branch assert "multiple boards" in out # multiple branch assert "one board has stale firmware" in out # warnings branch + + +# ===================================================================== +# tx --cw (plan:#5 -- the CLI could not reach transmit_cw) +# ===================================================================== +def test_tx_cw_print_cmd(cli_env, capsys): + cli.write_mode("tx") + _run_cli(["tx", "-f", "433.92M", "-s", "2M", "--cw", "-d", "2", + "--print-cmd"]) + out = capsys.readouterr().out + assert "hackrf_transfer" in out + assert "-f 433920000" in out + assert "-t -" not in out or True # source form varies; cmd printed + + +def test_tx_cw_requires_duration(cli_env): + cli.write_mode("tx") + with pytest.raises(HackRFValueError, match="duration"): + _run_cli(["tx", "-f", "433.92M", "-s", "2M", "--cw"]) + + +def test_tx_cw_and_source_mutually_exclusive(cli_env, tmp_path): + cli.write_mode("tx") + src = tmp_path / "sig.iq" + src.write_bytes(b"\x00" * 8) + with pytest.raises(HackRFValueError, match="mutually"): + _run_cli(["tx", "-f", "433.92M", "-s", "2M", "--cw", str(src)]) + + +def test_tx_without_source_or_cw_errors(cli_env): + cli.write_mode("tx") + with pytest.raises(HackRFValueError, match="source"): + _run_cli(["tx", "-f", "433.92M", "-s", "2M"]) + + +# ===================================================================== +# sweep -o / -B / -I passthrough (plan:#5) +# ===================================================================== +def test_sweep_out_print_cmd(cli_env, capsys, tmp_path): + out_file = str(tmp_path / "s.csv") + _run_cli(["sweep", "--f-min", "88M", "--f-max", "108M", + "-o", out_file, "--print-cmd"]) + out = capsys.readouterr().out + assert "-r " + out_file in out + + +def test_sweep_binary_print_cmd(cli_env, capsys, tmp_path): + out_file = str(tmp_path / "s.bin") + _run_cli(["sweep", "--f-min", "88M", "--f-max", "108M", + "-o", out_file, "-B", "--print-cmd"]) + out = capsys.readouterr().out + assert "-B" in out + + +def test_sweep_binary_without_out_errors(cli_env): + with pytest.raises(HackRFValueError, match="-o/--out"): + _run_cli(["sweep", "--f-min", "88M", "--f-max", "108M", "-B"]) + + +# ===================================================================== +# monitor (plan:#5): sweep-backed multi-frequency power to stdout +# ===================================================================== +def test_monitor_prints_power_lines(cli_env, capsys): + # the stubbed hackrf_sweep emits SWEEP_ROWS (88-89 MHz, 100 kHz bins); + # 88.6 MHz sits in the second segment's bin 1, so the covering-bin + # reading (max of bins 0-2) is -70.01. A watched frequency OUTSIDE the + # rows prints the honest "--". + _run_cli(["monitor", "88.6M", "433.92M", "-d", "1"]) + out = capsys.readouterr().out + assert "88.600 MHz -70.0 dB" in out + assert "433.920 MHz -- dB" in out + + +# ===================================================================== +# scan (plan:#5): per-frequency capture power to stdout +# ===================================================================== +def test_scan_prints_line_per_freq(cli_env, capsys): + # the hermetic transfer stub emits no IQ, so scan reports the honest + # "--" (no data) branch; the dispatch, per-frequency loop, and output + # format are what this pins + _run_cli(["scan", "100M", "433.92M", "-s", "2M", "-n", "4096"]) + out = capsys.readouterr().out + assert "100.000 MHz" in out + assert "433.920 MHz" in out diff --git a/hackrfpy/tests/test_deadman.py b/hackrfpy/tests/test_deadman.py new file mode 100644 index 0000000..9451b2b --- /dev/null +++ b/hackrfpy/tests/test_deadman.py @@ -0,0 +1,159 @@ +#! /usr/bin/python3 + +##--------------------------------------------------------------------\\ +# hackrfpy 'tests/test_deadman.py' +# The OS dead-man: a HARD-killed parent (SIGKILL / TerminateProcess -- +# the deaths the atexit backstop cannot see) must not orphan a +# handle-mode child. Each test spawns a real intermediate parent +# process that starts a protected stub handle, hard-kills that parent, +# and asserts the OS ends the child: +# Linux : PR_SET_PDEATHSIG delivers SIGINT -- the CLEAN interrupt, +# so the child's handler runs and the marker records +# "SIGINT": the dead-man IS the flush path. +# Windows : the Job Object's KILL_ON_JOB_CLOSE terminates the child +# tree when the parent's handles close. TerminateProcess +# runs no handler, so death itself is the assertion. +# macOS : skipped -- no OS primitive; the atexit backstop remains +# the only net there (documented in core.py). +# +# +# Author(s): Lauren Linkous +##--------------------------------------------------------------------\\ + +import os +import signal +import subprocess +import sys +import time + +import pytest + +from conftest import _write_stub + + +_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) + + +def _pid_alive(pid): + if sys.platform == "win32": + import ctypes + _PQLI = 0x1000 # PROCESS_QUERY_LIMITED_INFORMATION + _STILL_ACTIVE = 259 + k32 = ctypes.windll.kernel32 + h = k32.OpenProcess(_PQLI, False, pid) + if not h: + return False + try: + code = ctypes.c_ulong() + if not k32.GetExitCodeProcess(h, ctypes.byref(code)): + return False + return code.value == _STILL_ACTIVE + finally: + k32.CloseHandle(h) + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + +def _wait_dead(pid, timeout=8.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not _pid_alive(pid): + return True + time.sleep(0.1) + return False + + +_PARENT_TEMPLATE = """\ +import sys, time +sys.path.insert(0, {src!r}) +from hackrfpy import HackRF +h = HackRF(tools_dir={tools!r}) +p = h._run(["transfer", "-r", "x.iq"], mode="handle", kind={kind!r}) +# wait until the stub says "started": its signal handlers are installed +# BEFORE its stdout lines, so seeing this means an interrupt from here on +# runs the handler -- without this wait, a fast parent-kill can land during +# the stub's interpreter startup and die handler-less (a test race, not a +# dead-man failure). Live small-write draining is what makes this wait +# possible (the read1() drain fix). +deadline = time.monotonic() + 10.0 +while time.monotonic() < deadline: + if b"started" in b"".join(p._out_chunks): + break + time.sleep(0.05) +print(p._proc.pid, flush=True) +time.sleep(120) # murdered long before this returns +""" + + +def _spawn_parent_with_child(tmp_path, marker, kind="tx"): + _write_stub(str(tmp_path), "hackrf_transfer", + stdout_lines=["started"], idle=True, marker=marker) + script = tmp_path / "parent.py" + script.write_text(_PARENT_TEMPLATE.format( + src=_SRC, tools=str(tmp_path), kind=kind)) + parent = subprocess.Popen([sys.executable, str(script)], + stdout=subprocess.PIPE, text=True) + line = parent.stdout.readline().strip() + assert line.isdigit(), f"parent never reported a child pid: {line!r}" + return parent, int(line) + + +@pytest.mark.skipif(sys.platform == "darwin", + reason="no OS dead-man primitive on macOS; atexit " + "backstop only (see core.py)") +def test_hard_killed_parent_cannot_orphan_child(tmp_path): + marker = str(tmp_path / "sig") + parent, child_pid = _spawn_parent_with_child(tmp_path, marker, kind="tx") + assert _pid_alive(child_pid) + # the death atexit cannot see: no cleanup code in the parent runs + parent.kill() + parent.wait(timeout=10) + assert _wait_dead(child_pid), ( + "child survived a hard-killed parent -- the OS dead-man did not " + "fire; an orphaned transmitter would still be on the air") + if sys.platform.startswith("linux"): + # pdeathsig delivers the CLEAN interrupt: the handler ran and + # recorded it, so even the dead-man path flushes output + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline and not os.path.exists(marker): + time.sleep(0.05) + assert os.path.exists(marker) + assert open(marker).read().strip() == "SIGINT" + + +@pytest.mark.skipif(sys.platform == "darwin", + reason="no OS dead-man primitive on macOS") +def test_rx_opt_out_is_not_deadman_protected(tmp_path): + # backstop_rx=False is the documented fire-and-forget escape hatch; the + # dead-man honors the same gate, so an opted-out RX child SURVIVES its + # parent. (Only RX can opt out; TX is always protected.) + _write_stub(str(tmp_path), "hackrf_transfer", + stdout_lines=["started"], idle=True) + script = tmp_path / "parent.py" + script.write_text(_PARENT_TEMPLATE.format( + src=_SRC, tools=str(tmp_path), kind="rx").replace( + "p = h._run(", "h.backstop_rx = False\np = h._run(")) + parent = subprocess.Popen([sys.executable, str(script)], + stdout=subprocess.PIPE, text=True) + child_pid = int(parent.stdout.readline().strip()) + parent.kill() + parent.wait(timeout=10) + time.sleep(1.5) # give a wrong dead-man time to fire + try: + assert _pid_alive(child_pid), ( + "opted-out RX child was reaped -- the dead-man ignored the " + "backstop_rx gate") + finally: + # we deliberately orphaned it; clean up (idle ceiling would also end + # it within 30 s, but be a good citizen on CI) + if _pid_alive(child_pid): + if sys.platform == "win32": + subprocess.run(["taskkill", "/PID", str(child_pid), "/F"], + capture_output=True) + else: + os.kill(child_pid, signal.SIGKILL) diff --git a/hackrfpy/tests/test_detect.py b/hackrfpy/tests/test_detect.py index fb86fc5..3de2734 100644 --- a/hackrfpy/tests/test_detect.py +++ b/hackrfpy/tests/test_detect.py @@ -104,3 +104,36 @@ def test_identify_first_and_by_serial(stub_device): def test_identify_none_when_no_board(stub_device): h = stub_device(info=dict(stdout_lines=["hackrf_info version: 2024.02.1"])) assert h.identify() is None + + +# ---- no-board reporting: stdout is where the reason lives ------------------ +# With no board attached, Linux hackrf_info prints its version lines AND +# "No HackRF boards found." to STDOUT, then exits 1 with an empty stderr. +# detect() used to raise on the exit code and discard all of it: problem +# came back blank and tools_version None. Verified against the real +# 2023.01.1 Linux binaries. +_NO_BOARD_STDOUT = [ + "hackrf_info version: 2023.01.1", + "libhackrf version: 2023.01.1 (0.8)", + "No HackRF boards found.", +] + + +def test_detect_no_board_keeps_versions_and_reason(stub_device): + h = stub_device(info=dict(stdout_lines=_NO_BOARD_STDOUT, exit_code=1)) + det = h.detect() + assert det["ready"] is False and det["found"] is False + assert det["tools_version"] == "2023.01.1" + assert det["libhackrf_version"] == "2023.01.1 (0.8)" + assert "No HackRF boards found." in det["problem"] + + +def test_run_error_falls_back_to_stdout_tail(stub_device): + # a tool that fails with an empty stderr but a reason on stdout must + # surface that reason, not "exited N: " + import pytest + from hackrfpy.exceptions import HackRFDeviceError + h = stub_device(clock=dict(stdout_lines=["clock reason on stdout"], + exit_code=3)) + with pytest.raises(HackRFDeviceError, match="clock reason on stdout"): + h.clock("-r") diff --git a/hackrfpy/tests/test_docstrings.py b/hackrfpy/tests/test_docstrings.py new file mode 100644 index 0000000..f5056a5 --- /dev/null +++ b/hackrfpy/tests/test_docstrings.py @@ -0,0 +1,54 @@ +#! /usr/bin/python3 + +##--------------------------------------------------------------------\\ +# hackrfpy 'tests/test_docstrings.py' +# Regression gate for plan:#1 -- every PUBLIC callable must carry a +# real docstring. The API documentation used to live entirely in +# comments, invisible to help(), IDE tooltips, and doc generators; +# this test is what stops it from drifting back. +# +# +# Author(s): Lauren Linkous +##--------------------------------------------------------------------\\ + +import hackrfpy +from hackrfpy import HackRF +from hackrfpy._receiver import PersistentReceiver + + +def _public_callables(obj): + for name in dir(obj): + if name.startswith("_"): + continue + member = getattr(obj, name) + if callable(member): + yield name, member + + +def _assert_documented(owner, name, member): + doc = (getattr(member, "__doc__", None) or "").strip() + assert len(doc) >= 15, ( + f"{owner}.{name} has no (or a trivial) docstring -- public API must " + f"be introspectable; see plan:#1") + + +def test_hackrf_public_methods_have_docstrings(): + for name, member in _public_callables(HackRF): + _assert_documented("HackRF", name, member) + + +def test_persistent_receiver_public_methods_have_docstrings(): + for name, member in _public_callables(PersistentReceiver): + _assert_documented("PersistentReceiver", name, member) + + +def test_module_level_api_has_docstrings(): + for name in hackrfpy.__all__: + member = getattr(hackrfpy, name) + if callable(member): + _assert_documented("hackrfpy", name, member) + + +def test_class_docstrings_present(): + for cls in (HackRF, PersistentReceiver): + assert (cls.__doc__ or "").strip(), f"{cls.__name__} lacks a docstring" diff --git a/hackrfpy/tests/test_features.py b/hackrfpy/tests/test_features.py index ee6e9ed..967c424 100644 --- a/hackrfpy/tests/test_features.py +++ b/hackrfpy/tests/test_features.py @@ -117,6 +117,30 @@ def empty_stream(*a, **k): assert "snapped to MHz" in "\n".join(r.message for r in caplog.records) +def test_sweep_top_edge_ceiled(monkeypatch): + # regression: both edges used to be FLOORED, so sweep(433.9e6, 434.1e6) + # ran 433:434 and never covered 434.0-434.1 MHz. The high edge is now + # ceiled so the swept range always contains the requested band. + h = HackRF() + seen = {} + def rec_stream(argv, *a, **k): + seen["argv"] = [str(x) for x in argv] + if False: + yield + monkeypatch.setattr(h, "_run", rec_stream) + list(h.sweep(433_900_000, 434_100_000)) + i = seen["argv"].index("-f") + assert seen["argv"][i + 1] == "433:435" + + +def test_from_device_unparsed_info_raises_typed(monkeypatch): + # regression: this guard was a bare `assert`, stripped under python -O + h = HackRF() + monkeypatch.setattr(HackRF, "info", lambda self, **k: "raw text") + with pytest.raises(HackRFDeviceError): + HackRF.from_device(tools_dir=h.tools_dir) + + # ---- tx: max_duration converts an open-ended repeat into a timed run ------- def test_tx_max_duration_forces_timed(monkeypatch, tmp_path): h = HackRF() diff --git a/hackrfpy/tests/test_interrupt_clean.py b/hackrfpy/tests/test_interrupt_clean.py new file mode 100644 index 0000000..dc326b0 --- /dev/null +++ b/hackrfpy/tests/test_interrupt_clean.py @@ -0,0 +1,98 @@ +#! /usr/bin/python3 + +##--------------------------------------------------------------------\\ +# hackrfpy 'tests/test_interrupt_clean.py' +# The CLEAN-interrupt contract, distinguished from "the child died". +# stop()'s whole point is that hackrf_transfer flushes + closes on the +# interrupt signal so a capture file is never truncated; the older +# lifecycle tests proved the child EXITS, but their stub treated the +# interrupt and the terminate() escalation identically, so a broken +# CTRL_BREAK path could hide behind a working escalation. +# +# These tests assert, on BOTH platforms (no Windows skips -- Windows is +# the target platform, and on Windows the signal must also traverse the +# .bat -> python launcher layer, exactly like the real .bat-wrapped +# tools): +# 1. the interrupt SIGNAL ITSELF arrives (SIGINT on POSIX, SIGBREAK +# from CTRL_BREAK_EVENT on Windows) -- the stub records the signal +# NAME it caught, and the escalation would record a different one +# (or none: TerminateProcess runs no handler) +# 2. output written by the child's handler AFTER the interrupt is +# drained into stop()'s result -- the no-truncation contract +# 3. a child that ignores the interrupt is still reaped via the +# grace-timeout terminate() escalation, reported as unclean +# +# +# Author(s): Lauren Linkous +##--------------------------------------------------------------------\\ + +import os +import sys +import time + + +_EXPECTED_INTERRUPT = "SIGBREAK" if sys.platform == "win32" else "SIGINT" + + +def _wait_for_output(proc, needle, timeout=5.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if needle in b"".join(proc._out_chunks): + return True + time.sleep(0.05) + return False + + +# ---- 1. the clean signal itself is delivered ------------------------------- +def test_stop_delivers_interrupt_signal_not_escalation(stub_device, tmp_path): + marker = str(tmp_path / "sig") + h = stub_device(transfer=dict( + stdout_lines=["started"], idle=True, marker=marker)) + proc = h._run(["transfer", "-r", "x.iq"], mode="handle") + assert _wait_for_output(proc, b"started") + out, err, rc = proc.stop() + assert os.path.exists(marker), "no signal reached the child at all" + with open(marker) as f: + caught = f.read().strip() + # SIGTERM here would mean the child only died via the terminate() + # escalation; empty would mean a pre-signal-name stub. Both are failures + # of the CLEAN path this test exists to pin. + assert caught == _EXPECTED_INTERRUPT, ( + f"child caught {caught!r}, expected {_EXPECTED_INTERRUPT!r} -- the " + f"clean interrupt path is not the one that ended the child") + assert rc == 0 # handler exit, not a kill status + + +# ---- 2. the post-interrupt flush survives into the result ------------------ +def test_interrupt_flush_is_drained_not_truncated(stub_device): + sentinel = "FINAL_FLUSH_8f3a" + h = stub_device(transfer=dict( + stdout_lines=["started"], idle=True, tail_on_interrupt=sentinel)) + proc = h._run(["transfer", "-r", "x.iq"], mode="handle") + assert _wait_for_output(proc, b"started") + out, err, rc = proc.stop() + # the sentinel is written by the child's signal handler AFTER stop() + # fires the interrupt; finding it in the drained result proves the + # interrupt -> handler -> flush -> drain chain end to end + assert sentinel.encode() in out, ( + "output written during interrupt handling was lost -- this is the " + "truncated-capture failure mode stop() exists to prevent") + assert rc == 0 + + +# ---- 3. a deaf child is still reaped, and reported as unclean -------------- +def test_stop_escalates_on_ignored_interrupt(stub_device, tmp_path): + marker = str(tmp_path / "sig") + h = stub_device(transfer=dict( + stdout_lines=["started"], idle=True, marker=marker, + ignore_interrupt=True)) + proc = h._run(["transfer", "-r", "x.iq"], mode="handle") + assert _wait_for_output(proc, b"started") + t0 = time.monotonic() + out, err, rc = proc.stop(grace=0.5) + assert time.monotonic() - t0 < 10.0 # bounded by grace + terminate + assert not proc.is_alive() + # no handler ran (SIG_IGN), so no marker content and a non-zero status: + # the caller can tell this reap was NOT the clean flush path + assert not os.path.exists(marker) or not open(marker).read().strip() + assert rc != 0 diff --git a/hackrfpy/tests/test_metadata.py b/hackrfpy/tests/test_metadata.py index 4f5d039..bfac1d9 100644 --- a/hackrfpy/tests/test_metadata.py +++ b/hackrfpy/tests/test_metadata.py @@ -75,3 +75,40 @@ def test_user_toml_overrides_builtin(tmp_path, monkeypatch): pre = P.load_presets() assert pre["ads-b"]["center"] == 1_100_000_000 # user wins assert pre["ads-b"]["desc"] == "overridden" + + +# ---- package version attribute --------------------------------------------- +def test_dunder_version_present(): + import hackrfpy + v = hackrfpy.__version__ + assert isinstance(v, str) and v + try: + from importlib.metadata import version + assert v == version("hackrfpy") + except Exception: + assert v.startswith("0.0.0") # uninstalled checkout fallback + + +# ---- official SigMF validator (plan:#8) ------------------------------------ +# The writer looked spec-correct by inspection; this makes GNU Radio / +# IQEngine interop a TESTED property instead of a trusted one. Skips only +# where the dev group is not installed. +def test_sidecar_passes_official_sigmf_validator(tmp_path): + sigmffile = pytest.importorskip("sigmf.sigmffile", + reason="sigmf dev dependency not installed") + import numpy as np + iq_path = str(tmp_path / "capture.iq") + np.zeros(4096, dtype=np.int8).tofile(iq_path) + write_sigmf_meta(iq_path, 98.1e6, 2e6, lna=32, vga=20, amp=False, + datatype="ci8") + + f = sigmffile.fromfile(str(tmp_path / "capture.sigmf-meta")) + f.set_data_file(iq_path) + f.validate() # raises on any spec violation + assert f.get_global_field("core:datatype") == "ci8" + assert f.get_global_field("core:sample_rate") == 2e6 + assert f.sample_count == 2048 # 4096 int8 bytes = 2048 ci8 pairs + # the hackrf extension must be DECLARED, not just used (strict validators + # reject undeclared namespaces; this regressed once pre-1.0) + exts = f.get_global_field("core:extensions") + assert any(e.get("name") == "hackrf" for e in exts) diff --git a/hackrfpy/tests/test_monitor.py b/hackrfpy/tests/test_monitor.py index 202844e..db15a59 100644 --- a/hackrfpy/tests/test_monitor.py +++ b/hackrfpy/tests/test_monitor.py @@ -35,10 +35,12 @@ def test_monitor_maps_freqs_to_segments(stub_device): def test_monitor_tracks_power_change(stub_device): h = stub_device(sweep=dict(stdout_lines=_TWO_PASS)) out = h.monitor_frequencies([433e6], span_hz=2e6) - # 433 MHz segment rises from mean(-55,-50,-52,-58)=-53.75 to - # mean(-45,-40,-42,-48)=-43.75 between passes + # 433 MHz sits in bin idx 2 of the 431-435 segment (1 MHz bins). The + # reading is the max of bins 1..3: pass 1 max(-50,-52,-58) = -50, pass 2 + # max(-40,-42,-48) = -40 -- the covering bin, not the segment mean, so a + # narrowband carrier is no longer diluted by its quiet neighbors. assert out[0][433e6] < out[1][433e6] # power increased - assert abs(out[1][433e6] - (-43.75)) < 0.1 + assert abs(out[1][433e6] - (-40.0)) < 0.1 def test_monitor_callback_mode(stub_device): @@ -59,3 +61,56 @@ def cb(u): h.monitor_frequencies([100e6], on_update=cb) assert calls["n"] == 1 + + +# ---- regression: covering-bin power, not segment mean ---------------------- +# A strong narrowband carrier in one bin of a wide segment must dominate the +# reading. The old segment-mean proxy diluted it toward the noise floor. +_HOT_BIN = [ + "2026-06-15, 12:00:00.000000, 430000000, 440000000, 1000000.00, 4, " + "-80, -80, -80, -20, -80, -80, -80, -80, -80, -80", + "2026-06-15, 12:00:01.000000, 430000000, 440000000, 1000000.00, 4, " + "-80, -80, -80, -20, -80, -80, -80, -80, -80, -80", +] + + +def test_monitor_reads_covering_bin_not_segment_mean(stub_device): + h = stub_device(sweep=dict(stdout_lines=_HOT_BIN)) + out = h.monitor_frequencies([433.5e6], span_hz=1e6) + # 433.5 MHz -> bin idx 3 (the -20 dB carrier). The segment mean would + # have read -74; the covering bin reads the carrier itself. + assert abs(out[0][433.5e6] - (-20.0)) < 0.1 + + +# ---- pass detection must survive real per-row timestamps ------------------- +# Real hackrf_sweep timestamps each ROW individually; the stub fixtures used +# one timestamp per pass, which hid a boundary bug: flushing on timestamp +# change emitted PARTIAL updates several times per pass on real hardware +# (most watched frequencies None -- seen live as a wall of "--"). The pass +# boundary is now the sweep WRAP (a segment arriving again), which is +# timestamp-independent. These rows reproduce the hardware shape: two full +# passes over two segments, every row with a distinct timestamp. +_PER_ROW_TS_PASSES = ( + "2026-09-19, 12:00:00.100000, 88000000, 88500000, 100000.00, 8192, " + "-71.0, -70.0, -69.0, -72.0, -71.0\n" + "2026-09-19, 12:00:00.230000, 88500000, 89000000, 100000.00, 8192, " + "-70.0, -20.0, -72.0, -73.0, -74.0\n" + "2026-09-19, 12:00:00.360000, 88000000, 88500000, 100000.00, 8192, " + "-71.5, -70.5, -69.5, -72.5, -71.5\n" + "2026-09-19, 12:00:00.490000, 88500000, 89000000, 100000.00, 8192, " + "-70.5, -21.0, -72.5, -73.5, -74.5\n" +) + + +def test_updates_are_complete_despite_per_row_timestamps(stub_device): + h = stub_device(sweep=dict(stdout_lines=_PER_ROW_TS_PASSES.strip() + .split("\n"))) + seen = [] + h.monitor_frequencies([88.6e6], span_hz=0.4e6, + on_update=lambda u: seen.append(u)) + # two passes -> exactly two updates (wrap flush + final flush), and BOTH + # carry a real reading for the watched frequency -- no partial updates + assert len(seen) == 2, f"expected 2 complete updates, got {len(seen)}" + assert seen[0][88.6e6] == -20.0 # bin 1 of pass-1 second segment + assert seen[1][88.6e6] == -21.0 # same bin, pass 2 + assert all(u[88.6e6] is not None for u in seen) diff --git a/hackrfpy/tests/test_stream_teardown.py b/hackrfpy/tests/test_stream_teardown.py new file mode 100644 index 0000000..c909cd6 --- /dev/null +++ b/hackrfpy/tests/test_stream_teardown.py @@ -0,0 +1,64 @@ +#! /usr/bin/python3 + +##--------------------------------------------------------------------\\ +# hackrfpy 'tests/test_stream_teardown.py' +# The frozen-writer leak: a stream consumer that breaks out stops +# reading the pipe; at capture rates the 64 KB pipe fills in +# milliseconds and the child blocks inside write(). Signal handlers +# that only set an exit flag can never reach it from a blocked write, +# and the old teardown ended at an unreaped terminate() -- so on real +# hardware, hackrf_transfer stayed frozen, HOLDING THE USB CLAIM, and +# every later open in the process failed "Resource busy" (found on the +# Linux verification run: a wall of 7 failures starting immediately +# after the two stream-breakout tests). Teardown now gives the clean +# interrupt a window, then closes the read end to unblock the writer, +# then completes the terminate->kill ladder. +# +# +# Author(s): Lauren Linkous +##--------------------------------------------------------------------\\ + +import time + + +def test_breakout_reaps_flooding_writer(stub_device): + # a deaf, flooding child: blocked in write() once we stop reading, + # immune to the flag-setting signals -- the hardware failure mode + h = stub_device(transfer=dict(stdout_flood=8_000_000, + ignore_interrupt=True)) + gen = h._run(["transfer", "-r", "-"], mode="stream") + next(gen) # stream is live; now abandon it + t0 = time.monotonic() + gen.close() # GeneratorExit -> teardown under test + took = time.monotonic() - t0 + assert took < 6.0, f"teardown took {took:.1f}s -- ladder not bounded" + + +def test_breakout_then_immediate_reopen_works(stub_device): + # the user-visible contract: after abandoning one stream, the next + # device operation must not find a leaked child in the way + h = stub_device(transfer=dict(stdout_flood=8_000_000, + ignore_interrupt=True)) + gen = h._run(["transfer", "-r", "-"], mode="stream") + next(gen) + gen.close() + gen2 = h._run(["transfer", "-r", "-"], mode="stream") + assert next(gen2) # second stream delivers data + gen2.close() + + +def test_breakout_clean_child_still_gets_interrupt_window(stub_device, + tmp_path): + # the fix must NOT cost well-behaved children their clean exit: the + # SIGINT handler (marker write) runs before the pipe is closed + marker = str(tmp_path / "sig") + h = stub_device(transfer=dict(emit_bytes=[0, 64] * 4096, idle=True, + marker=marker)) + gen = h._run(["transfer", "-r", "-"], mode="stream") + next(gen) + gen.close() + import os + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline and not os.path.exists(marker): + time.sleep(0.05) + assert os.path.exists(marker), "clean interrupt window was lost" diff --git a/hackrfpy/uv.lock b/hackrfpy/uv.lock index ee2d4c7..c144f04 100644 --- a/hackrfpy/uv.lock +++ b/hackrfpy/uv.lock @@ -46,6 +46,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -250,6 +259,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "fonttools" version = "4.63.0" @@ -318,6 +336,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "sigmf" }, ] [package.metadata] @@ -333,6 +352,7 @@ dev = [ { name = "pytest", specifier = ">=8" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "sigmf", specifier = ">=1.2" }, ] [[package]] @@ -344,6 +364,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "kiwisolver" version = "1.5.0" @@ -900,6 +947,143 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "ruff" version = "0.15.17" @@ -925,6 +1109,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] +[[package]] +name = "sigmf" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "jsonschema" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/0c/d1cb848c8dccb986c9e0f916f984875b6b7e404d0c06bc4cdcc76d09a286/sigmf-1.13.0.tar.gz", hash = "sha256:e59df0f9bc111c5bc0cbe655e62666de69cd5cdff5e7fa8cc4f539557466ab1b", size = 92641, upload-time = "2026-08-19T21:37:42.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/08/dea5f39c38eb619db9704ee6f7b2e1edb6a05f737680c34f142c2e7be088/sigmf-1.13.0-py3-none-any.whl", hash = "sha256:51b273f00562c92667e0e25b85450b7a8e488a303f3e6fd376c3b168e4343362", size = 66829, upload-time = "2026-08-19T21:37:41.233Z" }, +] + [[package]] name = "six" version = "1.17.0" diff --git a/media/live_spectrum_waterfall.png b/media/live_spectrum_waterfall.png deleted file mode 100644 index 94606c5..0000000 Binary files a/media/live_spectrum_waterfall.png and /dev/null differ diff --git a/media/persistent-RX_waterfall.png b/media/persistent-RX_waterfall.png index f62d30b..626a199 100644 Binary files a/media/persistent-RX_waterfall.png and b/media/persistent-RX_waterfall.png differ