From ac8d4cadde66f56eaa314aa3489c9a604e969bee Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 01:51:22 -0400 Subject: [PATCH 01/17] contrib and setup test --- .github/ISSUE_TEMPLATE/bug_report.yml | 72 +++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 11 +++ .github/ISSUE_TEMPLATE/feature_request.yml | 47 +++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 36 +++++++++ .github/workflows/tests.yml | 81 +++++++++++++++++++ CITATION.cff | 6 +- CODE_OF_CONDUCT.md | 63 +++++++++++++++ CONTRIBUTING.md | 94 ++++++++++++++++++++++ README.md | 3 +- SECURITY.md | 50 ++++++++++++ 10 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/tests.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d1e290c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,72 @@ +name: Bug report +description: Something isn't working as documented +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for the report. This library wraps the `hackrf-tools` binaries, so + please include tool/OS details — most issues are environment-specific. + + If this involves **transmitting**, do not include instructions that would + help someone operate illegally; describe the software behavior only. + - type: textarea + id: what-happened + attributes: + label: What happened + description: What did you expect, and what happened instead? + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: The exact command or minimal code. Include `print_cmd=True` / `--print-cmd` output if relevant. + render: shell + validations: + required: true + - type: input + id: os + attributes: + label: Operating system + placeholder: "e.g. Windows 11 23H2 / Ubuntu 24.04 / macOS 14" + validations: + required: true + - type: input + id: python + attributes: + label: Python version + placeholder: "output of: python --version" + validations: + required: true + - type: input + id: hackrfpy + attributes: + label: hackrfpy version + placeholder: "output of: pip show hackrfpy | grep Version" + validations: + required: true + - type: textarea + id: tools + attributes: + label: hackrf-tools / firmware version + description: Paste the output of `hackrf_info` (redact serial numbers if you wish). + render: shell + validations: + required: false + - type: dropdown + id: hardware + attributes: + label: Was a HackRF board connected? + options: + - "Yes, a real board was connected" + - "No, this reproduces without hardware" + - "Not sure" + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs / traceback + description: Full traceback or stderr output. Run with `verbose=True` / `-v` if possible. + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4d74ffc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Official HackRF documentation + url: https://hackrf.readthedocs.io/ + about: Authoritative reference for device behavior, valid ranges, and TX safety. Check here first. + - name: HackRF firmware & tools (Great Scott Gadgets) + url: https://github.com/greatscottgadgets/hackrf + about: Report firmware, libhackrf, or hackrf-tools issues upstream — not here. + - name: Question / usage help + url: https://github.com/LC-Linkous/hackRF_python/discussions + about: For "how do I..." questions, open a Discussion rather than an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..15c6340 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,47 @@ +name: Feature request +description: Suggest a new capability or improvement +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Before filing: **signal processing (demod, FFTs, waterfalls) is out of + scope** for this repo — it stops at delivering `complex64`. See + `project_summary.md` for the split with the planned DSP project. + - type: textarea + id: problem + attributes: + label: Problem / use case + description: What are you trying to do that the library doesn't currently support? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What would the method / CLI command / behavior look like? A sketch of the signature is helpful. + validations: + required: false + - type: dropdown + id: area + attributes: + label: Area + options: + - Receive / capture + - Sweep + - Transmit + - Info / preflight / detect + - Device management (clock/operacake/spiflash/debug) + - Decoding / file helpers / SigMF + - CLI + - Docs / examples + - Other + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Existing workarounds, or why the passthrough methods (`clock`/`operacake`/`debug`) don't cover it. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9cef33a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ + + +## Summary + + +Closes # + +## Type of change + +- [ ] Bug fix +- [ ] New command / feature +- [ ] Docs / examples +- [ ] Test / CI / tooling +- [ ] Refactor (no behavior change) + +## How it was tested + +- [ ] `uv run pytest -m "not hardware"` passes locally +- [ ] `uv run ruff check .` and `uv run ruff format --check .` pass +- [ ] Tested against **real hardware** (describe below) +- [ ] Tested with **stubs only** (no board) + + + +## Checklist for new/changed commands + +- [ ] Binary invocation goes through `_run(argv, mode=...)` (no direct subprocess) +- [ ] Added a command-construction test asserting the exact `hackrf_*` argv via `print_cmd` +- [ ] Added a validation-error test asserting **nothing runs** on bad input +- [ ] Any new envelope limits live in `constants.py`, not hard-coded in the method +- [ ] Process-lifecycle changes covered with the cross-platform stub factory (`tests/conftest.py`) +- [ ] README Method Reference updated; example added under `examples/` if it's a public method + +## Notes for reviewers + + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ce21297 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,81 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Cancel superseded runs on the same ref (saves CI minutes on rapid pushes). +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + # The installable project lives in the hackrfpy/ subdirectory (the one with + # pyproject.toml + the pytest config + the `hardware` marker). + working-directory: hackrfpy + +jobs: + test: + name: pytest (${{ matrix.os }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # windows-latest is first because it is the platform this library + # targets; the cross-platform lifecycle stubs must pass there. + os: [windows-latest, ubuntu-latest, macos-latest] + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Sync environment (numpy + dev group, editable install) + 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 + + - 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' + uses: codecov/codecov-action@v5 + with: + files: hackrfpy/coverage.xml + fail_ci_if_error: false + + lint: + name: ruff + mypy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: "3.12" + + - name: Sync environment + run: uv sync + + - name: Ruff lint + run: uv run ruff check . + + - name: Ruff format check + run: uv run ruff format --check . + + - name: Mypy + # Non-blocking for now: flip `continue-on-error` to false once the + # type surface is clean and you want mypy to gate merges. + continue-on-error: true + run: uv run mypy src/hackrfpy \ No newline at end of file diff --git a/CITATION.cff b/CITATION.cff index 1403b32..206bd93 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -13,8 +13,8 @@ authors: - family-names: "Linkous" given-names: "Lauren" orcid: "https://orcid.org/0000-0001-9945-5125" -version: "0.1.0" -date-released: "2026-06-15" +version: "1.0.0" +date-released: "2026-06-16" license: GPL-2.0-or-later repository-code: "https://github.com/LC-Linkous/hackRF_python" url: "https://github.com/LC-Linkous/hackRF_python" @@ -26,4 +26,4 @@ keywords: - rf - spectrum - sigmf - - instrumentation \ No newline at end of file + - instrumentation diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6b12db5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,63 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers responsible for enforcement via the repo's +private reporting channels (see SECURITY.md) or by opening a confidential report +through GitHub. All complaints will be reviewed and investigated promptly and +fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a655384 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Contributing to hackrfpy + +Thanks for your interest in improving hackrfpy. This is an **unofficial** +community wrapper around the `hackrf-tools` binaries. It is not endorsed by +Great Scott Gadgets, and nothing here should be read as an authoritative +statement about device behavior — always defer to the +[official HackRF documentation](https://hackrf.readthedocs.io/). + +This project deals with hardware that can **transmit**. Please read the safety +notes in the README before contributing anything that touches the transmit +path. + +## Ways to contribute + +- **Report a bug** — open an issue with the bug template. Include your OS, + Python version, `hackrf-tools` version (`hackrf_info`), and the exact command + 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`. +- **Improve docs** — README fixes, clearer examples, and beginner notes are all + welcome and don't require hardware. +- **Submit code** — see the workflow below. + +## Development setup + +The installable project lives in the `hackrfpy/` subdirectory (the one with +`pyproject.toml`). This project uses [uv](https://docs.astral.sh/uv/). + +```bash +cd hackrfpy +uv sync # numpy + dev group, editable install of hackrfpy +uv run pytest # hardware tests self-skip without a board +``` + +Run everything through `uv run` so the synced environment is used. A bare +`python ...` can silently create or use a second environment. + +## Before you open a pull request + +Run these from the `hackrfpy/` directory: + +```bash +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) +``` + +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. + +## Adding a command + +The `HackRF` class is composed from mixins in `src/hackrfpy/_commands/`, wired +together in `core.py`. To add a command: + +1. Add the method to the appropriate mixin (`capture.py`, `sweep.py`, + `transmit.py`, `info.py`, or `device.py`). +2. Route the binary invocation through `_run(argv, mode=...)` — don't spawn + subprocesses directly. The four modes are `blocking`, `timed`, `handle`, + and `stream`. +3. Add a **command-construction test**: assert the exact `hackrf_*` argv on the + happy path via `print_cmd=True`, and assert that **nothing runs** on the + validation-error path. +4. If your change touches process lifecycle (start/stop/reap/interrupt), cover + it with the cross-platform stub-binary factory in `tests/conftest.py` so it + is exercised on Windows as well as POSIX. + +Any value the library treats as a limit (frequency, sample rate, gain steps, +filter bandwidths) belongs in `constants.py` — the single source of truth the +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. + +## 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/`. + +## License + +By contributing, you agree that your contributions are licensed under the +project's **GPL-2.0-or-later** license. diff --git a/README.md b/README.md index 410c34e..5873450 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ [![PyPI - Wheel](https://img.shields.io/pypi/wheel/hackrfpy.svg)](https://pypi.org/project/hackrfpy/) [![Downloads](https://static.pepy.tech/badge/hackrfpy)](https://pepy.tech/project/hackrfpy) [![License: GPL v2](https://img.shields.io/badge/License-GPL_v2-blue.svg)](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) - +[![Tests](https://github.com/LC-Linkous/hackRF_python/actions/workflows/tests.yml/badge.svg)](https://github.com/LC-Linkous/hackRF_python/actions/workflows/tests.yml) +[![codecov](https://codecov.io/gh/LC-Linkous/hackRF_python/branch/main/graph/badge.svg)](https://codecov.io/gh/LC-Linkous/hackRF_python) ## An UNOFFICIAL Python CLI + scripting wrapper for the HackRF One diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d52c690 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,50 @@ +# Security Policy + +## Supported versions + +This is an unofficial, community-maintained project under active development. +Security-relevant fixes are applied to the latest release on the `main` branch +only. There is no back-porting to older versions. + +| Version | Supported | +| ------- | ------------------ | +| latest (`main` / newest release) | :white_check_mark: | +| older releases | :x: | + +## Reporting a vulnerability + +Please **do not open a public issue** for security-sensitive reports. + +Use GitHub's private vulnerability reporting: +**Security → Report a vulnerability** on the repository, or the "Report a +vulnerability" button under the Security tab. This opens a private advisory +visible only to the maintainers. + +When reporting, please include: + +- A description of the issue and its impact +- Steps to reproduce (OS, Python version, `hackrf-tools` version) +- Any relevant logs or a minimal proof of concept + +You can expect an initial acknowledgment within a reasonable window. Once a fix +is available, we will coordinate disclosure and credit the reporter unless +anonymity is requested. + +## Scope + +In-scope examples: + +- Command injection or unsafe argument handling in how the library builds and + runs `hackrf-tools` invocations +- Path handling issues in file read/write helpers (IQ files, SigMF sidecars, + firmware dumps) +- Unsafe handling of subprocess input/output that could affect the host + +Out of scope: + +- Vulnerabilities in `hackrf-tools`, `libhackrf`, or HackRF firmware — report + those upstream at https://github.com/greatscottgadgets/hackrf +- **RF safety and legality.** Transmitting is regulated. Operating outside your + legal authorization, damaging equipment, or interfering with licensed + services is a user responsibility, not a software vulnerability. The transmit + gate and gain ceiling are guardrails, not guarantees. From 3251b617044ff3676fdea8f38a9c4bb87d205a5b Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 10:54:43 -0400 Subject: [PATCH 02/17] header template update examples --- hackrfpy/examples/benchmark.py | 4 ++++ hackrfpy/examples/calibrate.py | 4 ++++ hackrfpy/examples/capture_to_file.py | 4 ++++ hackrfpy/examples/collect_sample_data.py | 4 ++++ hackrfpy/examples/device_explorer.py | 4 ++++ hackrfpy/examples/persistent_capture.py | 4 ++++ hackrfpy/examples/power_meter.py | 4 ++++ hackrfpy/examples/scan_then_capture.py | 4 ++++ hackrfpy/examples/sweep_collect.py | 4 ++++ hackrfpy/examples/waterfall_persistent.py | 4 ++++ hackrfpy/examples/waterfall_realtime.py | 4 ++++ 11 files changed, 44 insertions(+) diff --git a/hackrfpy/examples/benchmark.py b/hackrfpy/examples/benchmark.py index bc4854d..d90d4d2 100644 --- a/hackrfpy/examples/benchmark.py +++ b/hackrfpy/examples/benchmark.py @@ -21,6 +21,10 @@ # uv run python examples/benchmark.py # uv run python examples/benchmark.py --rate 20e6 --seconds 3 # uv run python examples/benchmark.py --tools-dir "C:\hackrf-tools-windows" +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import argparse import sys diff --git a/hackrfpy/examples/calibrate.py b/hackrfpy/examples/calibrate.py index cfa7374..a7501fb 100644 --- a/hackrfpy/examples/calibrate.py +++ b/hackrfpy/examples/calibrate.py @@ -28,6 +28,10 @@ # uv run python examples/calibrate.py --ref-freq 100e6 --ref-dbm -30 # Then use the printed offset: # h.relative_power_db(iq, offset_db=) +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import argparse import json diff --git a/hackrfpy/examples/capture_to_file.py b/hackrfpy/examples/capture_to_file.py index 80af7d6..cd47e9d 100644 --- a/hackrfpy/examples/capture_to_file.py +++ b/hackrfpy/examples/capture_to_file.py @@ -6,6 +6,10 @@ # recording parameters. Neither needs a device or a HackRF() instance, # so any downstream consumer can use them standalone. # Built ON TOP of the library; not required by it. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import numpy as np from hackrfpy import HackRF, load_iq, read_sigmf_meta diff --git a/hackrfpy/examples/collect_sample_data.py b/hackrfpy/examples/collect_sample_data.py index 676e890..b456391 100644 --- a/hackrfpy/examples/collect_sample_data.py +++ b/hackrfpy/examples/collect_sample_data.py @@ -28,6 +28,10 @@ # uv run python examples/collect_sample_data.py --seconds 1.0 --sample-rate 4e6 # # (Use `uv run` so the project environment with numpy is used.) +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import argparse diff --git a/hackrfpy/examples/device_explorer.py b/hackrfpy/examples/device_explorer.py index 781b0f9..7db81aa 100644 --- a/hackrfpy/examples/device_explorer.py +++ b/hackrfpy/examples/device_explorer.py @@ -5,6 +5,10 @@ # firmware + capabilities + any device warnings. Read-only. This is the # HackRF analog of a serial-port autodetect -- it confirms the device is # present and usable before you build anything on top of it. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import sys from hackrfpy import HackRF diff --git a/hackrfpy/examples/persistent_capture.py b/hackrfpy/examples/persistent_capture.py index 51cec60..c2085a3 100644 --- a/hackrfpy/examples/persistent_capture.py +++ b/hackrfpy/examples/persistent_capture.py @@ -18,6 +18,10 @@ # Usage: # uv run python examples/waterfall_persistent.py --freq 100e6 --rate 8e6 # uv run python examples/waterfall_persistent.py --freq 2437e6 --rate 10e6 +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import argparse import sys diff --git a/hackrfpy/examples/power_meter.py b/hackrfpy/examples/power_meter.py index bb32dad..511ea0b 100644 --- a/hackrfpy/examples/power_meter.py +++ b/hackrfpy/examples/power_meter.py @@ -9,6 +9,10 @@ # Usage: # uv run python examples/power_meter.py --freq 100e6 # uv run python examples/power_meter.py --freq 433.92e6 --rate 2e6 +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import argparse import sys diff --git a/hackrfpy/examples/scan_then_capture.py b/hackrfpy/examples/scan_then_capture.py index 0fe84aa..09f24b7 100644 --- a/hackrfpy/examples/scan_then_capture.py +++ b/hackrfpy/examples/scan_then_capture.py @@ -3,6 +3,10 @@ # hackrfpy 'examples/scan_then_capture.py' # Pipeline the class can express that CLI-only tools can't: sweep a band, # find the strongest bin, then capture at that frequency. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import numpy as np from hackrfpy import HackRF diff --git a/hackrfpy/examples/sweep_collect.py b/hackrfpy/examples/sweep_collect.py index e9c16ba..4bd84d0 100644 --- a/hackrfpy/examples/sweep_collect.py +++ b/hackrfpy/examples/sweep_collect.py @@ -2,6 +2,10 @@ ##--------------------------------------------------------------------\ # hackrfpy 'examples/sweep_collect.py' # Collect one sweep across a band and save the bin powers to CSV. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import csv from hackrfpy import HackRF diff --git a/hackrfpy/examples/waterfall_persistent.py b/hackrfpy/examples/waterfall_persistent.py index d27d04e..f742bea 100644 --- a/hackrfpy/examples/waterfall_persistent.py +++ b/hackrfpy/examples/waterfall_persistent.py @@ -17,6 +17,10 @@ # # Usage: # uv run python examples/waterfall_persistent.py --freq 100e6 --rate 8e6 +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import argparse import sys diff --git a/hackrfpy/examples/waterfall_realtime.py b/hackrfpy/examples/waterfall_realtime.py index 918170c..a094e12 100644 --- a/hackrfpy/examples/waterfall_realtime.py +++ b/hackrfpy/examples/waterfall_realtime.py @@ -10,6 +10,10 @@ # sweep pass can be partial at the flush boundary, so every spectrum line is # conformed to a fixed width before being stacked into the image (a naive # np.array(history) crashes on the resulting ragged rows). +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import numpy as np import matplotlib.pyplot as plt From b0344881cdf28d9632200f15d2e0a228efefd358 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 10:54:56 -0400 Subject: [PATCH 03/17] header template update src --- hackrfpy/src/hackrfpy/_receiver.py | 4 ++++ hackrfpy/src/hackrfpy/_stream_ctx.py | 4 ++++ hackrfpy/src/hackrfpy/cli.py | 3 ++- hackrfpy/src/hackrfpy/constants.py | 4 +++- hackrfpy/src/hackrfpy/core.py | 4 +++- hackrfpy/src/hackrfpy/exceptions.py | 4 +++- hackrfpy/src/hackrfpy/presets.py | 4 +++- hackrfpy/src/hackrfpy/sigmf.py | 4 +++- 8 files changed, 25 insertions(+), 6 deletions(-) diff --git a/hackrfpy/src/hackrfpy/_receiver.py b/hackrfpy/src/hackrfpy/_receiver.py index edaccf4..88b05d5 100644 --- a/hackrfpy/src/hackrfpy/_receiver.py +++ b/hackrfpy/src/hackrfpy/_receiver.py @@ -13,6 +13,10 @@ # frequency", NOT "one process I can retune". For multiple frequencies use # scan_frequencies (separate captures) or monitor_frequencies (sweep). True # gapless retuning needs the C library. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import numpy as np diff --git a/hackrfpy/src/hackrfpy/_stream_ctx.py b/hackrfpy/src/hackrfpy/_stream_ctx.py index 1098cba..5bc2309 100644 --- a/hackrfpy/src/hackrfpy/_stream_ctx.py +++ b/hackrfpy/src/hackrfpy/_stream_ctx.py @@ -5,6 +5,10 @@ # Shared context manager that guarantees a live capture/sweep generator # is closed (and its child process reaped) on block exit, including on # exception or KeyboardInterrupt. Used by capture_stream and sweep_stream. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ diff --git a/hackrfpy/src/hackrfpy/cli.py b/hackrfpy/src/hackrfpy/cli.py index a327372..2485596 100644 --- a/hackrfpy/src/hackrfpy/cli.py +++ b/hackrfpy/src/hackrfpy/cli.py @@ -14,7 +14,8 @@ # instantiates HackRF once, calls the method, and maps typed exceptions to # clean stderr + exit codes. All real work lives in core + mixins. # -# Author(s): +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import argparse diff --git a/hackrfpy/src/hackrfpy/constants.py b/hackrfpy/src/hackrfpy/constants.py index 0f196e1..e3b5856 100644 --- a/hackrfpy/src/hackrfpy/constants.py +++ b/hackrfpy/src/hackrfpy/constants.py @@ -16,7 +16,9 @@ # state. Editing them changes what the library will accept/reject; it does # not change anything on the board. # -# Author(s): +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ # ---- hard ranges (reject-by-default; --force / allow_out_of_spec downgrades diff --git a/hackrfpy/src/hackrfpy/core.py b/hackrfpy/src/hackrfpy/core.py index 9810060..e06d483 100644 --- a/hackrfpy/src/hackrfpy/core.py +++ b/hackrfpy/src/hackrfpy/core.py @@ -14,7 +14,9 @@ # (sweep CSV), or open-ended (rx until stopped). So _run takes a `mode` # argument the way a serial wrapper takes a length flag. # -# Author(s): +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import os diff --git a/hackrfpy/src/hackrfpy/exceptions.py b/hackrfpy/src/hackrfpy/exceptions.py index 8b861e3..7156e66 100644 --- a/hackrfpy/src/hackrfpy/exceptions.py +++ b/hackrfpy/src/hackrfpy/exceptions.py @@ -9,7 +9,9 @@ # stderr with a clean exit code. Importing scripts get real, catchable # exceptions instead of sentinel return values. # -# Author(s): +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ diff --git a/hackrfpy/src/hackrfpy/presets.py b/hackrfpy/src/hackrfpy/presets.py index 3bfbc02..c72ace3 100644 --- a/hackrfpy/src/hackrfpy/presets.py +++ b/hackrfpy/src/hackrfpy/presets.py @@ -12,7 +12,9 @@ # Built-ins can be extended/overridden from a user TOML # (~/.config/hackrfpy/presets.toml) via load_presets(). # -# Author(s): +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import os diff --git a/hackrfpy/src/hackrfpy/sigmf.py b/hackrfpy/src/hackrfpy/sigmf.py index bc9b473..f9f3f8d 100644 --- a/hackrfpy/src/hackrfpy/sigmf.py +++ b/hackrfpy/src/hackrfpy/sigmf.py @@ -9,7 +9,9 @@ # the SigMF core namespace; HackRF native format is interleaved signed 8-bit # I/Q -> datatype "ci8". # -# Author(s): +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import json From 1ed1defacc2ced49eeccc0ec3eec77c807da986d Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 10:55:07 -0400 Subject: [PATCH 04/17] moved md notes summary --- hackrfpy/src/hackrfpy/comparison.md | 175 ---------------------------- 1 file changed, 175 deletions(-) delete mode 100644 hackrfpy/src/hackrfpy/comparison.md diff --git a/hackrfpy/src/hackrfpy/comparison.md b/hackrfpy/src/hackrfpy/comparison.md deleted file mode 100644 index 60da9cc..0000000 --- a/hackrfpy/src/hackrfpy/comparison.md +++ /dev/null @@ -1,175 +0,0 @@ -# comparison.md — hackrfpy vs other Python HackRF libraries - -An honest look at where this library sits among the Python options for the -HackRF One. The goal is **not** to claim it's the best at everything — each of -these makes a different, defensible tradeoff. The goal is to identify the niche -hackrfpy fills and be clear about what it gives up to fill it. - -## The one decision that separates them - -Every Python HackRF library makes a single architectural choice that determines -almost everything else: **bind `libhackrf` (the C library) or wrap the -`hackrf-tools` binaries (the command-line programs).** - -* **libhackrf binders** (`python_hackrf`, `pyhackrf2`, `pyhackrf`, the various - `py-hackrf-ctypes` / `pylibhackrf` projects) call the C library's functions - directly — through ctypes, Cython, or a C extension. They get low-level - control and real-time sample callbacks, at the cost of a compiler/headers - dependency and a callback-based programming model. -* **hackrfpy wraps the binaries.** It runs `hackrf_info`, `hackrf_transfer`, - `hackrf_sweep`, etc. as subprocesses and manages their I/O and lifecycle. It - gives up real-time callback control and adds subprocess overhead, and in - exchange gets a dependency-free install, full tool coverage, and a place to - put a validation/safety layer. - -Almost every difference below traces back to this one fork. - -## The landscape - -| Library | Approach | Install | TX | Sweep | Device mgmt (clock/spiflash/etc.) | Maintained | -|---|---|---|---|---|---|---| -| **hackrfpy** (this) | wraps **binaries** | `pip` + binaries on system; no compiler | yes (gated) | yes | **all 8 tools** | active | -| **python_hackrf** | Cython → libhackrf | `pip` + compiler + libhackrf headers/libusb | yes | yes | info, sweep, operacake, transfer only | active, most complete binder | -| **pyhackrf2** | Python → libhackrf (ctypes) | `pip` + libusb/libhackrf | yes | yes | minimal | moderate | -| **pyhackrf** (dressel/4thel00z) | ctypes → libhackrf | `pip` + libusb/fftw | partial | via callback | minimal | stale | -| **py-hackrf-ctypes / pylibhackrf** | ctypes / C ext → libhackrf | manual build | varies | via callback | minimal | stale/experimental | - -(Approaches verified against each project's own PyPI/GitHub description; feature -columns reflect what each documents, not exhaustive testing.) - -## What the libhackrf binders do well (and where hackrfpy now stands) - -Being honest about the architectural tradeoff, and what has and hasn't been narrowed: - -* **Real-time sample callbacks.** *Gap narrowed.* The binders expose libhackrf's - callback model — register a function that fires as buffers arrive. hackrfpy - now offers the same *ergonomics* via `capture_callback(freq, rate, on_block)`: - your function is called with each decoded `complex64` block as it streams, and - returns `False` to stop. The mechanism differs (it rides the subprocess stream - rather than a libhackrf USB-thread callback), so it is not zero-copy — but the - measured block-delivery latency is low: on a laptop at 20 Msps, median ~0.04 ms - between blocks with ~0.5 ms jitter (`examples/benchmark.py`). So *when data is - flowing*, the "latency floor is the pipe" concern is sub-millisecond; the real - constraint at top rates is throughput (above), not per-block latency. -* **Lower overhead at high rates.** *Gap narrowed; measured, with an honest - ceiling.* The decode path was optimized to construct `complex64` directly - (≈3× faster; benchmarked at ~190–390 MB/s depending on machine, i.e. 5–10× the - 40 MB/s the device produces at 20 Msps — so **decode is not the bottleneck**). - The remaining cost is the subprocess + kernel pipe. Measured on a laptop with a - shared USB bus (`examples/benchmark.py`): a 20 Msps capture received **100% of - samples with no bulk loss** and only a single startup-transient shortfall over - 120M samples, but the consumer drained the pipe at a **steady-state ~14 Msps**, - so the capture ran slower than real-time (the pipe back-pressures and - `hackrf_transfer` blocks rather than dropping). The practical reading: for - *capture-to-file or batch* work you lose nothing; for a *live real-time* - consumer at the very top of the rate range, the pipe drain is a real ceiling a - binder avoids. There is also a fixed ~1–2 s per-capture **startup cost** - (process launch + USB setup + device settle) the in-process binders don't pay. - These numbers are machine-specific — run the benchmark on your own setup. -* **Fine-grained mid-stream control.** *Genuine remaining gap.* `hackrf_transfer` - takes one frequency and runs until stopped; it cannot retune mid-stream, so - hackrfpy cannot either — that is the binary's limit, not a missing feature. - What hackrfpy adds is `scan_frequencies([...], rate, n)` to make sequential - multi-frequency capture a single clean call, but each retune is still a fresh - process with a short re-open gap. **True gapless retuning needs the C library** - — if that's your requirement, use a binder. -* **Android.** `python_hackrf` documents an Android build path. hackrfpy targets - desktop OSes. Unchanged. - -If your work is "process every sample in real time with the lowest possible -latency, retuning on the fly," a libhackrf binder — `python_hackrf` is the most -complete and actively maintained — is still the better fit. The gap is now -narrower (callback ergonomics and fast decode), but the architectural ceiling is -real and this document won't pretend otherwise. - -## What hackrfpy does that the others don't - -The niche, stated plainly: - -* **Dependency-free install, especially on Windows.** Because it wraps the - binaries, there is no C compiler, no `libhackrf.h`, no `libusb` headers, no - `PYTHON_HACKRF_CFLAGS`/`LDFLAGS` to set. The binders all require a build - toolchain and the native headers present at install time — the exact friction - that makes HackRF-in-Python painful on Windows. hackrfpy needs only the - `hackrf-tools` binaries (which ship prebuilt) and pip. This is the single - biggest practical differentiator. -* **Complete tool coverage.** hackrfpy wraps **all eight** `hackrf-tools` - binaries, including the ones the binders explicitly skip. `python_hackrf`'s - own docs list `hackrf_clock`, `hackrf_cpldjtag`, `hackrf_debug`, and - `hackrf_spiflash` as "Will not be implemented." hackrfpy wraps clock, debug, - and operacake as full argument passthroughs and provides brick-guarded - spiflash/cpldjtag. If you need clock configuration or firmware operations from - Python, this is currently the option. -* **A validation / safety layer.** None of the binders police your inputs — they - pass values straight to libhackrf. hackrfpy adds a reject-by-default operating - envelope (catch a 60 GHz typo for 6 GHz), gain snapping with honest - notification, a sub-recommended-sample-rate warning, an explicit RX/TX mode - gate so you can't transmit by accident, a TX gain ceiling against - order-of-magnitude fat-fingers, and a `max_duration` dead-man for open-ended - transmits. This is a deliberate "help you not break your hardware or the law" - layer that a thin binding has no place to put. -* **Lifecycle safety for scripts.** Clean child reaping on `KeyboardInterrupt`, - context managers (`capture_stream`, `sweep_stream`) that guarantee the radio - is stopped on exit, and an `atexit` backstop so a dying script doesn't leave a - transmitter running. These matter precisely because subprocess management is - the risk hackrfpy takes on — so it's where the engineering went. -* **A real CLI.** `hrf` is a first-class command-line tool (`hrf detect`, - `hrf rx`, `hrf sweep`, `hrf doctor`), not just a library. The binders are - libraries first; `python_hackrf` has a CLI but centered on info/sweep. -* **Self-describing captures.** SigMF sidecar metadata written automatically, so - a recording carries its frequency/rate/gains instead of being a headerless - int8 blob. -* **Hardware-validated parsing.** The parsers are tested against frozen verbatim - output from a real HackRF One (info, sweep, IQ), including real quirks like - out-of-order sweep segments and continuation-line fields. - -## Honest weaknesses - -Where hackrfpy is genuinely behind, beyond the architectural give-ups above: - -* **Younger and fewer users** than the established binders. It is validated - against a real HackRF One (hardware-marked tests pass; parsers are pinned to - verbatim device output), but it has far less field exposure across diverse - setups, firmware versions, and edge cases than libraries that have been in - wide use for years. Maturity is about breadth of real-world exposure, and - that takes time and users. -* **Subprocess dependence on tool behavior.** It relies on the `hackrf-tools` - stdout format and exit codes, which it parses and version-checks — but a - future tools change could require parser updates. A binder against the stable - C ABI is insulated from that. -* **No real-time callback path.** Restated because it's the real ceiling: if you - outgrow "stream blocks from a subprocess," the architecture won't follow you - into zero-latency callback territory without becoming a different library. -* **Not a DSP library.** By design — it yields `complex64` and stops. The binders - don't do DSP either, but their callback model sits closer to where you'd build - it. - -## Who should use which - -* **Use a libhackrf binder (`python_hackrf` first) if:** you need real-time - per-buffer callbacks, minimum latency at high sample rates, mid-stream - retuning, Android support, or you're already in a compiled-toolchain - environment where the build dependency is free. -* **Use hackrfpy if:** you want a clean `pip` install with no compiler - (especially on Windows), a scriptable + CLI workflow, full access to *all* the - hackrf tools including clock/debug/firmware, a safety/validation layer that - guards against expensive mistakes, robust script lifecycle (clean stop, no - orphaned transmitters), and self-describing recordings — and your data path is - "capture/sweep to file or to `complex64`," not real-time callback DSP. - -## The one-line version - -The other libraries are **C bindings** that give you low-level, real-time -control at the cost of a build toolchain. hackrfpy is a **binary wrapper** that -gives you a dependency-free install, complete tool coverage, and a safety layer. -It now matches the binders' callback *ergonomics* (`capture_callback`) and has a -fast decode path, so the usability gap is small; the remaining hard difference -is true gapless mid-stream retuning, which needs the C library. Different tools -for different jobs; the niche is "scriptable HackRF control that installs cleanly -on Windows and tries to keep you from breaking things." - ---- - -*Landscape current as of mid-2026; library capabilities and maintenance status -change. Verify against each project's current PyPI/GitHub before relying on a -specific claim. Feature columns reflect each project's own documentation.* From f875ff6ceea9bb29a89d19bd5a5702edb69840e3 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 10:55:14 -0400 Subject: [PATCH 05/17] header template update tests --- hackrfpy/tests/collect_real_data.py | 4 + hackrfpy/tests/conftest.py | 4 + hackrfpy/tests/test_bugfixes.py | 4 + hackrfpy/tests/test_calibration.py | 4 + hackrfpy/tests/test_cli.py | 349 +++++++++++++++++++++++++ hackrfpy/tests/test_cli_dryrun.py | 4 + hackrfpy/tests/test_compat.py | 4 + hackrfpy/tests/test_detect.py | 4 + hackrfpy/tests/test_examples.py | 4 + hackrfpy/tests/test_features.py | 4 + hackrfpy/tests/test_hardware.py | 4 + hackrfpy/tests/test_lifecycle_xplat.py | 4 + hackrfpy/tests/test_metadata.py | 4 + hackrfpy/tests/test_monitor.py | 4 + hackrfpy/tests/test_parsing.py | 4 + hackrfpy/tests/test_real_output.py | 4 + hackrfpy/tests/test_realtime.py | 4 + hackrfpy/tests/test_receiver.py | 4 + hackrfpy/tests/test_resolve.py | 4 + hackrfpy/tests/test_safety.py | 5 +- hackrfpy/tests/test_validation.py | 4 + 21 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 hackrfpy/tests/test_cli.py diff --git a/hackrfpy/tests/collect_real_data.py b/hackrfpy/tests/collect_real_data.py index 88b419f..c02724a 100644 --- a/hackrfpy/tests/collect_real_data.py +++ b/hackrfpy/tests/collect_real_data.py @@ -23,6 +23,10 @@ # python tests/collect_real_data.py --tools-dir "C:\hackrf-tools-windows" # python tests/collect_real_data.py --anonymize # scrub serials # python tests/collect_real_data.py --sweep-band 88:108 --rx-freq 100e6 +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import argparse diff --git a/hackrfpy/tests/conftest.py b/hackrfpy/tests/conftest.py index 4c82b50..fbe921c 100644 --- a/hackrfpy/tests/conftest.py +++ b/hackrfpy/tests/conftest.py @@ -5,6 +5,10 @@ # Shared pytest fixtures + the hardware-marker self-skip + the # CROSS-PLATFORM stub-binary factory (so lifecycle/handle/stream tests # run on Windows, not just where bash exists). +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import os diff --git a/hackrfpy/tests/test_bugfixes.py b/hackrfpy/tests/test_bugfixes.py index 0d45da6..70ceaee 100644 --- a/hackrfpy/tests/test_bugfixes.py +++ b/hackrfpy/tests/test_bugfixes.py @@ -4,6 +4,10 @@ # hackrfpy 'tests/test_bugfixes.py' # Regression tests for the 2026-06-10 bug-fix pass. Each test pins one # fixed behavior. No device needed (dry-run tests need hackrf-tools). +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import shutil diff --git a/hackrfpy/tests/test_calibration.py b/hackrfpy/tests/test_calibration.py index cfb3784..8a2ce1e 100644 --- a/hackrfpy/tests/test_calibration.py +++ b/hackrfpy/tests/test_calibration.py @@ -5,6 +5,10 @@ # Level-1 relative calibration helpers: power in dBFS, gain chain, and # gain-normalized relative power (with optional offset + freq correction). # Pure math, no hardware. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import numpy as np diff --git a/hackrfpy/tests/test_cli.py b/hackrfpy/tests/test_cli.py new file mode 100644 index 0000000..bc86775 --- /dev/null +++ b/hackrfpy/tests/test_cli.py @@ -0,0 +1,349 @@ +#! /usr/bin/python3 + +##--------------------------------------------------------------------\ +# hackrfpy 'tests/test_cli.py' +# +# End-to-end CLI coverage that DOES NOT need real hackrf-tools or a board. +# The trick (same one conftest uses for lifecycle tests): point the CLI's +# HackRF at a tools_dir of cross-platform STUB binaries, so resolve() +# succeeds and the shell exercises its full dispatch/parse/exit surface on +# Windows and POSIX alike. rx/tx/sweep use --print-cmd (nothing executes); +# info/detect/doctor run a functional hackrf_info stub; sweep's CSV loop +# runs a functional hackrf_sweep stub. State + presets are made hermetic by +# redirecting XDG_CONFIG_HOME to a tmp dir. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 +##--------------------------------------------------------------------\ + +import os +import stat +import sys +from types import SimpleNamespace + +import pytest + +from hackrfpy import cli +from hackrfpy.exceptions import HackRFValueError + + +# --- realistic hackrf_info output (mirrors tests/fixtures/hackrf_info.txt) --- +INFO_TEXT = ( + "hackrf_info version: 2024.02.1\n" + "libhackrf version: 2024.02.1 (0.9)\n" + "Found HackRF\n" + "Index: 0\n" + "Serial number: 0000000000000000457863c82b4f3f\n" + "Board ID Number: 2 (HackRF One)\n" + "Firmware Version: 2024.02.1 (API:1.08)\n" + "Part ID Number: 0xa000cb3c 0x004f4762\n" + "Hardware Revision: r9\n" +) + +# two well-formed hackrf_sweep CSV rows (date, time, lo, hi, binw, nsamp, db...) +SWEEP_ROWS = ( + "2026-06-10, 12:00:00.000000, 88000000, 88500000, 100000.00, 8192, " + "-71.23, -70.10, -69.95, -72.40, -71.88\n" + "2026-06-10, 12:00:00.000000, 88500000, 89000000, 100000.00, 8192, " + "-70.01, -71.12, -72.23, -73.34, -74.45\n" +) + + +def _write_tool(tools_dir, name, *, emit=""): + """Write a resolvable stub binary that prints `emit` then exits 0. + + POSIX : a shebang'd executable file `` (chmod +x) + Windows: a `.py` plus a `.bat` launcher on PATHEXT + resolve() only needs isfile + X_OK; the functional stubs additionally + emit canned stdout so info/detect/doctor/sweep have something to parse. + """ + prog = "import sys\n" + if emit: + prog += f"sys.stdout.write({emit!r})\n" + prog += "sys.exit(0)\n" + + if os.name == "nt": + py = os.path.join(tools_dir, name + ".py") + with open(py, "w") as f: + f.write(prog) + launcher = os.path.join(tools_dir, name + ".bat") + with open(launcher, "w") as f: + f.write(f'@echo off\r\n"{sys.executable}" "{py}" %*\r\n') + return launcher + + launcher = os.path.join(tools_dir, name) + with open(launcher, "w") as f: + f.write(f"#!{sys.executable}\n") + f.write(prog) + os.chmod(launcher, os.stat(launcher).st_mode + | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return launcher + + +@pytest.fixture +def cli_env(tmp_path, monkeypatch): + """Hermetic CLI environment: stub tools + a private config/state dir.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + tools = tmp_path / "tools" + tools.mkdir() + _write_tool(str(tools), "hackrf_info", emit=INFO_TEXT) + _write_tool(str(tools), "hackrf_transfer") # only resolved + _write_tool(str(tools), "hackrf_sweep", emit=SWEEP_ROWS) + + real_cls = cli.HackRF + + def _factory(*a, **k): + k.setdefault("tools_dir", str(tools)) + return real_cls(*a, **k) + + monkeypatch.setattr(cli, "HackRF", _factory) + return SimpleNamespace(tools=str(tools), tmp=tmp_path) + + +def _run_cli(argv): + app = cli.HackRFCLI(argv) + app.main(app.getArgs()) + + +# ===================================================================== +# argument parsing / help / version +# ===================================================================== +def test_version_action_exits_zero(capsys): + with pytest.raises(SystemExit) as exc: + cli.HackRFCLI(["--version"]) + assert exc.value.code == 0 + assert "hrf" in capsys.readouterr().out + + +def test_no_subcommand_prints_help(capsys): + _run_cli([]) + out = capsys.readouterr().out + assert "usage" in out.lower() + + +def test_bad_mode_choice_is_rejected(): + # argparse enforces the choices=C.MODES constraint + with pytest.raises(SystemExit): + cli.HackRFCLI(["mode", "sideways"]) + + +def test_sweep_requires_edges(): + with pytest.raises(SystemExit): + cli.HackRFCLI(["sweep"]) # --f-min / --f-max are required + + +# ===================================================================== +# mode state file (read/write round-trip, get/set dispatch) +# ===================================================================== +def test_mode_state_roundtrip(cli_env, capsys): + # default when no state file exists + _run_cli(["mode"]) + assert capsys.readouterr().out.strip() == "rx" + + # setting tx fires the safety banner and persists + _run_cli(["mode", "tx"]) + assert "TX MODE ARMED" in capsys.readouterr().out + + # a fresh read sees the persisted mode + _run_cli(["mode"]) + assert capsys.readouterr().out.strip() == "tx" + + +def test_read_mode_default_without_file(cli_env): + assert cli.read_mode() == "rx" + + +def test_write_then_read_mode(cli_env): + cli.write_mode("tx") + assert cli.read_mode() == "tx" + + +# ===================================================================== +# presets +# ===================================================================== +def test_presets_listing(cli_env, capsys): + _run_cli(["presets"]) + out = capsys.readouterr().out + assert "fm" in out + assert "ads-b" in out + + +# ===================================================================== +# info / detect / doctor (functional hackrf_info stub) +# ===================================================================== +def test_info_parsed_output(cli_env, capsys): + _run_cli(["info"]) + out = capsys.readouterr().out + assert "457863c82b4f3f" in out # serial from the stub + + +def test_info_print_cmd_does_not_parse(cli_env, capsys): + _run_cli(["info", "--print-cmd"]) + out = capsys.readouterr().out + assert "hackrf_info" in out + assert "457863c82b4f3f" not in out # nothing was executed/parsed + + +def test_detect_reports_ready_board(cli_env, capsys): + _run_cli(["detect"]) + out = capsys.readouterr().out + assert "hackrfpy detect" in out + assert "ready" in out + + +def test_detect_exits_nonzero_when_no_board(cli_env): + # rewrite the info stub to report no board; detect must exit 1 so that + # `hrf detect && hrf rx ...` short-circuits. + no_board = ("hackrf_info version: 2024.02.1\n" + "libhackrf version: 2024.02.1 (0.9)\n" + "No HackRF boards found.\n") + _write_tool(cli_env.tools, "hackrf_info", emit=no_board) + with pytest.raises(SystemExit) as exc: + _run_cli(["detect"]) + assert exc.value.code == 1 + + +def test_doctor_ok_with_core_tools(cli_env): + # all three core tools present -> no problems -> no exit + _run_cli(["doctor"]) + + +def test_doctor_exits_when_core_tool_missing(cli_env): + # remove a CORE tool so preflight reports a problem -> exit 1 + for fn in ("hackrf_sweep", "hackrf_sweep.bat", "hackrf_sweep.py"): + p = os.path.join(cli_env.tools, fn) + if os.path.exists(p): + os.remove(p) + with pytest.raises(SystemExit) as exc: + _run_cli(["doctor"]) + assert exc.value.code == 1 + + +# ===================================================================== +# rx dispatch (--print-cmd builds argv; --preset resolves freq) +# ===================================================================== +def test_rx_print_cmd_builds_transfer(cli_env, capsys): + _run_cli(["rx", "-f", "433.92M", "-s", "8M", "-n", "1000000", + "--print-cmd"]) + out = capsys.readouterr().out + assert "hackrf_transfer" in out + assert "-f 433920000" in out + assert "-n 1000000" in out + + +def test_rx_preset_supplies_frequency(cli_env, capsys): + _run_cli(["rx", "--preset", "fm", "-n", "1000", "--print-cmd"]) + out = capsys.readouterr().out + # fm preset: f_min 88 MHz becomes the center when -f is omitted + assert "-f 88000000" in out + + +def test_rx_without_freq_or_preset_raises(cli_env): + app = cli.HackRFCLI(["rx", "-s", "8M", "-n", "1000"]) + with pytest.raises(HackRFValueError): + app.main(app.getArgs()) + + +# ===================================================================== +# tx dispatch (requires persisted TX mode) +# ===================================================================== +def test_tx_print_cmd_requires_tx_mode(cli_env, tmp_path, capsys): + src = tmp_path / "sig.iq" + src.write_bytes(b"\x00\x01" * 16) + + cli.write_mode("tx") # arm TX for this invocation + _run_cli(["tx", str(src), "-f", "433.92M", "-s", "8M", "-x", "20", + "--print-cmd"]) + out = capsys.readouterr().out + assert "hackrf_transfer" in out + assert "-t" in out and "-x 20" in out + + +def test_tx_blocked_in_rx_mode(cli_env, tmp_path): + from hackrfpy.exceptions import HackRFModeError + src = tmp_path / "sig.iq" + src.write_bytes(b"\x00\x01" * 16) + # mode defaults to rx; the gate must reject transmit + app = cli.HackRFCLI(["tx", str(src), "-f", "433.92M", "-s", "8M"]) + with pytest.raises(HackRFModeError): + app.main(app.getArgs()) + + +# ===================================================================== +# sweep dispatch (print_cmd path + the CSV printing loop) +# ===================================================================== +def test_sweep_print_cmd(cli_env, capsys): + _run_cli(["sweep", "--f-min", "88M", "--f-max", "108M", "--print-cmd"]) + out = capsys.readouterr().out + assert "hackrf_sweep" in out + assert "-f 88:108" in out + + +def test_sweep_streams_csv_rows(cli_env, capsys): + # functional hackrf_sweep stub emits two rows then exits; the CLI parses + # and re-prints them as CSV. + _run_cli(["sweep", "--f-min", "88M", "--f-max", "89M"]) + out = capsys.readouterr().out + assert "88000000" in out + assert out.count("\n") >= 2 # both rows printed + + +# ===================================================================== +# module-level main(): exception -> exit code mapping +# ===================================================================== +def test_module_main_success(cli_env, monkeypatch): + monkeypatch.setattr(sys, "argv", ["hrf", "presets"]) + cli.main() # returns cleanly, no SystemExit + + +def test_module_main_maps_value_error(cli_env, monkeypatch): + # rx with no frequency -> HackRFValueError (exit code 2) + monkeypatch.setattr(sys, "argv", ["hrf", "rx", "-s", "8M", "-n", "1000"]) + with pytest.raises(SystemExit) as exc: + cli.main() + assert exc.value.code == 2 + + +def test_module_main_keyboard_interrupt(monkeypatch): + def _boom(*a, **k): + raise KeyboardInterrupt + monkeypatch.setattr(cli.HackRFCLI, "main", _boom) + monkeypatch.setattr(sys, "argv", ["hrf", "info"]) + with pytest.raises(SystemExit) as exc: + cli.main() + assert exc.value.code == 130 + + +# ===================================================================== +# _print_detect formatting (pure function; both branches) +# ===================================================================== +def test_print_detect_not_found(capsys): + cli._print_detect({ + "found": False, "problem": "no HackRF boards found", + "count": 0, "boards": [], "tools_version": None, + "libhackrf_version": None, "multiple": False, "ready": False, + }) + assert "no HackRF boards found" in capsys.readouterr().out + + +def test_print_detect_rich_report(capsys): + cli._print_detect({ + "found": True, "problem": "", "count": 2, + "tools_version": "2024.02.1", "libhackrf_version": "2024.02.1 (0.9)", + "boards": [ + {"index": 0, "is_hackrf": True, "serial": "abc", + "name": "HackRF One", "firmware": "2024.02.1", + "firmware_stale": False}, + {"index": 1, "is_hackrf": False, "serial": "def", + "name": "?", "firmware": "2019.01.1", + "firmware_stale": True}, + ], + "multiple": True, "ready": True, + "warnings": ["one board has stale firmware"], + }) + out = capsys.readouterr().out + assert "UNCONFIRMED" in out # the non-HackRF board + assert "stale" in out # firmware_stale branch + assert "multiple boards" in out # multiple branch + assert "one board has stale firmware" in out # warnings branch \ No newline at end of file diff --git a/hackrfpy/tests/test_cli_dryrun.py b/hackrfpy/tests/test_cli_dryrun.py index f48eba9..75c9a3b 100644 --- a/hackrfpy/tests/test_cli_dryrun.py +++ b/hackrfpy/tests/test_cli_dryrun.py @@ -4,6 +4,10 @@ # hackrfpy 'tests/test_cli_dryrun.py' # --print-cmd builds the right hackrf_* argv without running anything. # Skips cleanly if hackrf-tools aren't installed (resolve() would raise). +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import shutil diff --git a/hackrfpy/tests/test_compat.py b/hackrfpy/tests/test_compat.py index 2ffa306..66e6494 100644 --- a/hackrfpy/tests/test_compat.py +++ b/hackrfpy/tests/test_compat.py @@ -7,6 +7,10 @@ # return_params tuple, and the version feature-probe. Cross-platform # stubs (conftest.stub_device) so the process-lifecycle ones run on # Windows too. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import time diff --git a/hackrfpy/tests/test_detect.py b/hackrfpy/tests/test_detect.py index 7d60fd2..4f03568 100644 --- a/hackrfpy/tests/test_detect.py +++ b/hackrfpy/tests/test_detect.py @@ -5,6 +5,10 @@ # Hardware autodetection + identification. The HackRF is not a serial # device, so "detect" means: run hackrf_info, confirm each board is a # HackRF, report firmware/identity. Device-free via stub hackrf_info. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import pytest diff --git a/hackrfpy/tests/test_examples.py b/hackrfpy/tests/test_examples.py index 99bec2f..936fbca 100644 --- a/hackrfpy/tests/test_examples.py +++ b/hackrfpy/tests/test_examples.py @@ -7,6 +7,10 @@ # parse. This catches the "example imports something not exported" class # of bug (e.g. read_sigmf_meta) without needing hardware. Also covers # sweep_stream, the context manager the waterfall example relies on. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import ast diff --git a/hackrfpy/tests/test_features.py b/hackrfpy/tests/test_features.py index 479ec85..405fa97 100644 --- a/hackrfpy/tests/test_features.py +++ b/hackrfpy/tests/test_features.py @@ -7,6 +7,10 @@ # core-vs-optional split, sweep MHz-edge warning, and the TX dead-man cap. # Cross-platform stubs (conftest.stub_device) stand in for hackrf_* so # these run on Windows too. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import time diff --git a/hackrfpy/tests/test_hardware.py b/hackrfpy/tests/test_hardware.py index 38f3e68..273d96f 100644 --- a/hackrfpy/tests/test_hardware.py +++ b/hackrfpy/tests/test_hardware.py @@ -12,6 +12,10 @@ # frozen fixtures can't prove: that hackrf_info output matches the parser, # that a real capture yields the right sample count, that hackrf_sweep -N # actually terminates, that the reap paths stop a real child cleanly. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import time diff --git a/hackrfpy/tests/test_lifecycle_xplat.py b/hackrfpy/tests/test_lifecycle_xplat.py index eed62c0..f0bdd52 100644 --- a/hackrfpy/tests/test_lifecycle_xplat.py +++ b/hackrfpy/tests/test_lifecycle_xplat.py @@ -11,6 +11,10 @@ # NOTE: these intentionally do NOT skip on Windows. If they fail on # Windows, that is a real finding about CTRL_BREAK_EVENT reaping, which # is the previously-uncovered (pragma: no cover) interrupt path. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import os diff --git a/hackrfpy/tests/test_metadata.py b/hackrfpy/tests/test_metadata.py index eea0bf7..3ed3634 100644 --- a/hackrfpy/tests/test_metadata.py +++ b/hackrfpy/tests/test_metadata.py @@ -3,6 +3,10 @@ ##--------------------------------------------------------------------\ # hackrfpy 'tests/test_metadata.py' # SigMF sidecar round-trips, and preset resolution/override. Device-free. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import json diff --git a/hackrfpy/tests/test_monitor.py b/hackrfpy/tests/test_monitor.py index 23acd5b..e146301 100644 --- a/hackrfpy/tests/test_monitor.py +++ b/hackrfpy/tests/test_monitor.py @@ -5,6 +5,10 @@ # monitor_frequencies: sweep-backed power-over-time monitoring. Distinct # from scan_frequencies (which returns IQ). Verifies frequency->segment # mapping, multi-pass yielding, final-pass flush, and callback mode. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ from hackrfpy import HackRF diff --git a/hackrfpy/tests/test_parsing.py b/hackrfpy/tests/test_parsing.py index edba584..c84f300 100644 --- a/hackrfpy/tests/test_parsing.py +++ b/hackrfpy/tests/test_parsing.py @@ -3,6 +3,10 @@ ##--------------------------------------------------------------------\ # hackrfpy 'tests/test_parsing.py' # Parsing logic against frozen real-ish captures. Runs without a device. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import os diff --git a/hackrfpy/tests/test_real_output.py b/hackrfpy/tests/test_real_output.py index cbc5e46..a712cf0 100644 --- a/hackrfpy/tests/test_real_output.py +++ b/hackrfpy/tests/test_real_output.py @@ -8,6 +8,10 @@ # year, a value on an indented continuation line, and trailing free-text # USB warnings -- none of which the original synthetic fixtures had. # Serial / part ID anonymized. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import os diff --git a/hackrfpy/tests/test_realtime.py b/hackrfpy/tests/test_realtime.py index 008cc55..9399fae 100644 --- a/hackrfpy/tests/test_realtime.py +++ b/hackrfpy/tests/test_realtime.py @@ -6,6 +6,10 @@ # receive (capture_callback), sequential multi-frequency scanning # (scan_frequencies), and proof the optimized decode is bit-identical to # the old path. Cross-platform stubs; no device. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import numpy as np diff --git a/hackrfpy/tests/test_receiver.py b/hackrfpy/tests/test_receiver.py index f731496..9fd4590 100644 --- a/hackrfpy/tests/test_receiver.py +++ b/hackrfpy/tests/test_receiver.py @@ -6,6 +6,10 @@ # amortizing the per-capture startup cost. Verifies exact-count reads from # a SINGLE process launch, the blocks()/callback() access patterns, the # read-size tunability, and clean close. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import numpy as np diff --git a/hackrfpy/tests/test_resolve.py b/hackrfpy/tests/test_resolve.py index 37b4b58..e80c4ed 100644 --- a/hackrfpy/tests/test_resolve.py +++ b/hackrfpy/tests/test_resolve.py @@ -4,6 +4,10 @@ # hackrfpy 'tests/test_resolve.py' # Binary resolution, especially the Windows-extension path that the # documented tools_dir workflow depends on. Pure / no device. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import os diff --git a/hackrfpy/tests/test_safety.py b/hackrfpy/tests/test_safety.py index 4d361b3..6cdcb69 100644 --- a/hackrfpy/tests/test_safety.py +++ b/hackrfpy/tests/test_safety.py @@ -7,8 +7,11 @@ # and the TX gain ceiling. These guard the worst outcomes (bricked board, # silent out-of-spec operation, illegal transmit), so they are pinned # explicitly rather than left to integration coverage. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ - import sys import pytest diff --git a/hackrfpy/tests/test_validation.py b/hackrfpy/tests/test_validation.py index 33ca779..76a7633 100644 --- a/hackrfpy/tests/test_validation.py +++ b/hackrfpy/tests/test_validation.py @@ -3,6 +3,10 @@ ##--------------------------------------------------------------------\ # hackrfpy 'tests/test_validation.py' # The validation envelope + mode machine. No device needed. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 ##--------------------------------------------------------------------\ import pytest From 311e19fe9a8431e2b43e823dd37d1da493c0d0d0 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 10:58:52 -0400 Subject: [PATCH 06/17] toml update for current release --- hackrfpy/pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hackrfpy/pyproject.toml b/hackrfpy/pyproject.toml index 0870149..d585668 100644 --- a/hackrfpy/pyproject.toml +++ b/hackrfpy/pyproject.toml @@ -9,11 +9,10 @@ license-files = ["LICENSE*"] authors = [{ name = "LC-Linkous" }] keywords = ["hackrf", "sdr", "rf", "spectrum", "sigmf"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", "Operating System :: Microsoft :: Windows", - "Operating System :: OS Independent", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", From 31a6925011e0ac34389a90f642b7eeaf0a64fbb5 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 10:59:35 -0400 Subject: [PATCH 07/17] added change log --- CHANGELOG.md | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6bc582d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,72 @@ +# Changelog + +All notable changes to hackrfpy are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +Work on the current development branch. Entries move to a versioned section on +release. + +### Added +- Continuous integration: GitHub Actions workflow running the test suite across + Windows, Linux, and macOS on Python 3.11-3.13, plus a ruff + mypy lint job. +- CLI test suite covering argument parsing, the mode state file, preset + resolution, `info`/`detect`/`doctor`, `rx`/`tx`/`sweep` dispatch, and the + module-level exit-code mapping. Overall coverage raised from 73% to 86% + (`cli.py` from 12% to ~98%). +- Community health files: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, + `SECURITY.md`, issue templates, and a pull request template. + +### Changed +- Packaging classifiers: removed the contradictory `Operating System :: + OS Independent` (the library shells out to the Windows `hackrf-tools` + binaries and Linux/macOS operation is unverified), leaving `Operating System + :: Microsoft :: Windows`. Development status raised from `3 - Alpha` to + `5 - Production/Stable` to match the 1.0.0 release. + +### Fixed +- `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. + + + +## [1.0.0] - 2026-06-16 + +Initial public release. + +### Added +- Python wrapper and non-GUI command-line tool (`hrf`) for the HackRF One, + driving the `hackrf-tools` binaries directly (no libhackrf bindings). +- Receive: bounded and streaming capture, with decode to `complex64` and + self-describing SigMF (`.sigmf-meta`) sidecars. +- Spectrum sweep with streaming CSV parsing. +- Transmit: file playback and constant-wave source, guarded by an explicit + operating-mode gate (transmit refuses unless the instance is switched to TX + mode, which emits a one-time safety banner) and a TX gain ceiling. +- Device management passthroughs (clock, Opera Cake, SPI flash, debug) and + preflight `info` / `detect` / `doctor` helpers. +- Cross-platform process lifecycle handling with best-effort clean interrupt + (SIGINT on POSIX, CTRL_BREAK on Windows) and an atexit backstop for live + RX/TX handles. +- Validation layer with hard-range checks, gain snapping to real device steps, + and a parameter readback (`last_params`) reflecting the values actually used. + +[Unreleased]: https://github.com/LC-Linkous/hackRF_python/compare/V1.0.0...HEAD +[1.0.0]: https://github.com/LC-Linkous/hackRF_python/releases/tag/V1.0.0 From d9faf5d7b5f860d122415f704623922d448552c4 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 11:36:22 -0400 Subject: [PATCH 08/17] updated that largely untested on linux --- hackrfpy/README.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/hackrfpy/README.md b/hackrfpy/README.md index 7a890e3..06b903d 100644 --- a/hackrfpy/README.md +++ b/hackrfpy/README.md @@ -2,6 +2,7 @@ **An Unofficial Python CLI + Scripting Wrapper for the HackRF One that works on Windows** +[![Tests](https://github.com/LC-Linkous/hackRF_python/actions/workflows/tests.yml/badge.svg)](https://github.com/LC-Linkous/hackRF_python/actions/workflows/tests.yml) [![PyPI version](https://badge.fury.io/py/hackrfpy.svg)](https://badge.fury.io/py/hackrfpy) [![Python versions](https://img.shields.io/pypi/pyversions/hackrfpy.svg)](https://pypi.org/project/hackrfpy/) [![PyPI - Wheel](https://img.shields.io/pypi/wheel/hackrfpy.svg)](https://pypi.org/project/hackrfpy/) @@ -25,6 +26,14 @@ This repository uses official resources and documentation but is **NOT** endorse - **Error Handling** — a typed exception hierarchy and verbose output options - **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. + +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. + +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. + ## Installation ```bash @@ -39,13 +48,13 @@ pip install "hackrfpy[plotting]" Python 3.11+ is required. -**You also need the `hackrf-tools` binaries**, which are *not* a pip dependency — they are installed at the OS level: +**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`). -- **Linux:** `sudo apt install hackrf` (or your distribution's equivalent) -- **macOS:** `brew install hackrf` -- **Windows:** 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. +- **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). +- **macOS** — *experimental, see [Platform support](#platform-support).* `brew install hackrf`. -Verify the install with `hackrf_info`. +Verify the tools are installed with `hackrf_info`. ## Quick Start @@ -138,6 +147,7 @@ For comprehensive documentation, the full method reference, the CLI reference, a This is an unofficial community project. Contributions welcome! - Report bugs and request features on [GitHub](https://github.com/LC-Linkous/hackRF_python) +- If you run the library on Linux or macOS, reports from real hardware are especially welcome (see [Platform support](#platform-support)) - For device information and OFFICIAL resources, see [https://hackrf.readthedocs.io/](https://hackrf.readthedocs.io/) - Please do **NOT** request features or report bugs to Great Scott Gadgets or the HackRF project! This is an unofficial project and they do not maintain it. From bbeb62525c76bc4b1696500d59f42d3daf139212 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 20:09:16 -0400 Subject: [PATCH 09/17] transmit fix --- hackrfpy/src/hackrfpy/_commands/transmit.py | 9 +++++++++ hackrfpy/src/hackrfpy/core.py | 15 ++++++++++++++- hackrfpy/src/hackrfpy/sigmf.py | 8 ++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/hackrfpy/src/hackrfpy/_commands/transmit.py b/hackrfpy/src/hackrfpy/_commands/transmit.py index c097ba7..20101d2 100644 --- a/hackrfpy/src/hackrfpy/_commands/transmit.py +++ b/hackrfpy/src/hackrfpy/_commands/transmit.py @@ -14,7 +14,10 @@ # Author(s): ##--------------------------------------------------------------------\ +import os + from .. import constants as C +from ..exceptions import HackRFEnvironmentError class TransmitMixin: @@ -28,6 +31,12 @@ def transmit(self, freq, sample_rate, source, *, txvga=20, amp=False, # regulatory + hardware risk if the controlling script dies; this # gives every transmit an optional dead-man bound. self.require_mode(C.MODE_TX) # the gate + # Fail fast on a bad path BEFORE spawning hackrf_transfer, so a typo'd + # source raises a clean, catchable error instead of a generic non-zero + # exit from the tool. Skipped for print_cmd (a dry run is a pure command + # preview and may reference a file you haven't generated yet). + if not print_cmd and not os.path.isfile(source): + raise HackRFEnvironmentError(f"transmit source not found: {source!r}") freq, sample_rate, txvga, amp = self.validate_tx( freq, sample_rate, txvga, amp) bw = self._auto_baseband(sample_rate, baseband_bw) diff --git a/hackrfpy/src/hackrfpy/core.py b/hackrfpy/src/hackrfpy/core.py index e06d483..33a6e8c 100644 --- a/hackrfpy/src/hackrfpy/core.py +++ b/hackrfpy/src/hackrfpy/core.py @@ -93,6 +93,14 @@ def _stop_all_live(): from ._commands.device import DeviceMixin +# Upper bound on retained drained output per stream (stdout / stderr). The +# handle-mode drain and the streaming-mode stderr tail both cap here so a +# long-lived RX/TX handle can't grow memory without bound (hackrf_transfer +# prints a stats line every second for the life of the process). We keep the +# most-recent bytes -- enough for an error tail -- and drop older ones. +_DRAIN_CAP = 64 * 1024 + + class _Process: # Thin controller returned by _run(mode="handle") for "run until I stop it" # workflows (the wait-for-user-stop consumption mode). Wraps a Popen and @@ -124,6 +132,11 @@ def _drain(stream, sink): try: for chunk in iter(lambda: stream.read(65536), b""): sink.append(chunk) + # Bound retained output: drop oldest chunks once over the cap, + # always keeping at least the latest one. Prevents unbounded + # growth on open-ended handles (see _DRAIN_CAP). + while sum(len(c) for c in sink) > _DRAIN_CAP and len(sink) > 1: + sink.pop(0) except (OSError, ValueError): pass # pipe closed under us during shutdown @@ -539,7 +552,7 @@ def _stream(self, resolved, text=False, read_samples=65536): stderr=subprocess.PIPE, text=text, creationflags=_CREATION_FLAGS) err_tail = [] - err_cap = 64 * 1024 + err_cap = _DRAIN_CAP def _eat(stream, sink): try: diff --git a/hackrfpy/src/hackrfpy/sigmf.py b/hackrfpy/src/hackrfpy/sigmf.py index f9f3f8d..b140a1f 100644 --- a/hackrfpy/src/hackrfpy/sigmf.py +++ b/hackrfpy/src/hackrfpy/sigmf.py @@ -52,6 +52,14 @@ def write_sigmf_meta(data_path, freq, sample_rate, *, lna=None, vga=None, ], "annotations": [], } + # SigMF requires any non-core namespace used in the file to be declared in + # core:extensions, or strict validators reject it. We only emit hackrf:* + # keys when gains are supplied, so declare the extension exactly then. + # optional=True: a reader can decode the IQ without understanding hackrf:*. + if annotations_gains: + meta["global"]["core:extensions"] = [ + {"name": "hackrf", "version": "1.0.0", "optional": True} + ] if extra: meta["global"].update(extra) From 7460a5f8b6aa386cb2f61cdbcff49e77a4fe0f27 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 20:09:25 -0400 Subject: [PATCH 10/17] test update --- hackrfpy/tests/test_cli.py | 27 ++++++++++--- hackrfpy/tests/test_features.py | 6 ++- hackrfpy/tests/test_lifecycle_xplat.py | 20 ++++++++- hackrfpy/tests/test_safety.py | 36 +++++++++++++++++ hackrfpy/tests/test_sigmf.py | 56 ++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 hackrfpy/tests/test_sigmf.py diff --git a/hackrfpy/tests/test_cli.py b/hackrfpy/tests/test_cli.py index bc86775..868cdbe 100644 --- a/hackrfpy/tests/test_cli.py +++ b/hackrfpy/tests/test_cli.py @@ -209,12 +209,27 @@ def test_doctor_ok_with_core_tools(cli_env): _run_cli(["doctor"]) -def test_doctor_exits_when_core_tool_missing(cli_env): - # remove a CORE tool so preflight reports a problem -> exit 1 - for fn in ("hackrf_sweep", "hackrf_sweep.bat", "hackrf_sweep.py"): - p = os.path.join(cli_env.tools, fn) - if os.path.exists(p): - os.remove(p) +def test_doctor_exits_when_core_tool_missing(cli_env, monkeypatch): + # Force resolve() to fail for the sweep tool so preflight reports a missing + # CORE binary -> exit 1. + # + # NOTE: deleting the stub from tools_dir is NOT enough. resolve() falls back + # to shutil.which(), so on a machine with real hackrf-tools on PATH (i.e. + # any actual dev box) it would find the real hackrf_sweep and report no + # problems. Patching resolve makes this deterministic everywhere. + from hackrfpy.core import HackRF as _HackRF + from hackrfpy import constants as C + from hackrfpy.exceptions import HackRFDeviceError + + real_resolve = _HackRF.resolve + + def _resolve(self, key): + if key == "sweep": + raise HackRFDeviceError(f"missing binary: {C.TOOLS['sweep']}") + return real_resolve(self, key) + + monkeypatch.setattr(_HackRF, "resolve", _resolve) + with pytest.raises(SystemExit) as exc: _run_cli(["doctor"]) assert exc.value.code == 1 diff --git a/hackrfpy/tests/test_features.py b/hackrfpy/tests/test_features.py index 405fa97..c642f92 100644 --- a/hackrfpy/tests/test_features.py +++ b/hackrfpy/tests/test_features.py @@ -115,12 +115,14 @@ def empty_stream(*a, **k): # ---- tx: max_duration converts an open-ended repeat into a timed run ------- -def test_tx_max_duration_forces_timed(monkeypatch): +def test_tx_max_duration_forces_timed(monkeypatch, tmp_path): h = HackRF() h.set_mode(C.MODE_TX) + src = tmp_path / "sig.iq" + src.write_bytes(b"\x00\x01" * 8) # real file for the source guard seen = {} monkeypatch.setattr(h, "_run", lambda argv, **k: seen.update(k) or ("", "", 0)) - h.transmit(433.92e6, 8e6, "sig.iq", repeat=True, max_duration=5.0) + h.transmit(433.92e6, 8e6, str(src), repeat=True, max_duration=5.0) assert seen.get("mode") == "timed" assert seen.get("duration") == 5.0 diff --git a/hackrfpy/tests/test_lifecycle_xplat.py b/hackrfpy/tests/test_lifecycle_xplat.py index f0bdd52..18a1faf 100644 --- a/hackrfpy/tests/test_lifecycle_xplat.py +++ b/hackrfpy/tests/test_lifecycle_xplat.py @@ -76,7 +76,10 @@ def test_timed_stops_chatty_child_on_schedule(stub_device): # ---- handle mode ----------------------------------------------------------- def test_handle_drains_pipes_and_stops_cleanly(stub_device): # flood stderr past the pipe buffer immediately; without drain threads - # this blocks the child (the classic deadlock) + # this blocks the child (the classic deadlock). Reaching "started" on + # stdout proves the flood was drained past. The retained tail is now + # bounded by _DRAIN_CAP so an open-ended handle can't grow without bound. + from hackrfpy.core import _DRAIN_CAP h = stub_device(transfer=dict( stderr_flood=262144, stdout_lines=["started"], idle=True)) proc = h._run(["transfer", "-r", "x.iq"], mode="handle") @@ -87,10 +90,23 @@ def test_handle_drains_pipes_and_stops_cleanly(stub_device): time.sleep(0.05) assert proc.is_alive() out, err, rc = proc.stop() - assert len(err) >= 262144 + assert 0 < len(err) <= _DRAIN_CAP # drained, but capped (was: >= flood) assert b"started" in out +def test_handle_drain_buffer_is_capped(stub_device): + # Regression: the handle-mode drain used to retain ALL output, growing + # memory without bound on long-lived RX/TX handles. Flood far past the cap, + # then exit; wait() joins the drain threads so the retained size is + # deterministic and must be bounded by _DRAIN_CAP. + from hackrfpy.core import _DRAIN_CAP + h = stub_device(transfer=dict(stderr_flood=_DRAIN_CAP * 8)) + proc = h._run(["transfer", "-r", "x.iq"], mode="handle") + out, err, rc = proc.wait() + assert rc == 0 + assert 0 < len(err) <= _DRAIN_CAP + + def test_handle_context_manager_reaps(stub_device): h = stub_device(transfer=dict(stdout_lines=["ready"], idle=True)) proc = h._run(["transfer", "-r", "x.iq"], mode="handle") diff --git a/hackrfpy/tests/test_safety.py b/hackrfpy/tests/test_safety.py index 6cdcb69..f1e73e4 100644 --- a/hackrfpy/tests/test_safety.py +++ b/hackrfpy/tests/test_safety.py @@ -149,3 +149,39 @@ def test_transmit_cw_builds_c_flag(monkeypatch): assert seen["argv"][0] == "transfer" assert seen["argv"][1] == "-c" assert seen["argv"][2] == 127 # clamped 0-127 + + +# ---- transmit source-file guard: fail fast before spawning ------------------ +def test_transmit_missing_source_rejected(monkeypatch): + from hackrfpy.exceptions import HackRFEnvironmentError + h = HackRF() + h.set_mode(C.MODE_TX) + monkeypatch.setattr(h, "_run", + lambda *a, **k: pytest.fail("must not spawn on bad source")) + with pytest.raises(HackRFEnvironmentError, match="source not found"): + h.transmit(433.92e6, 8e6, "/no/such/file.iq") + + +def test_transmit_mode_gate_precedes_source_check(monkeypatch): + # With BOTH a wrong mode and a missing file, the TX-mode gate must win -- + # the safety invariant is checked before input validation. + from hackrfpy.exceptions import HackRFModeError + h = HackRF() # defaults to RX + assert h.mode == C.MODE_RX + monkeypatch.setattr(h, "_run", + lambda *a, **k: pytest.fail("must not spawn")) + with pytest.raises(HackRFModeError): + h.transmit(433.92e6, 8e6, "/no/such/file.iq") + + +def test_transmit_print_cmd_skips_source_check(monkeypatch): + # A dry-run preview must not require the file to exist (it may be a file + # you haven't generated yet). + h = HackRF() + h.set_mode(C.MODE_TX) + seen = {} + monkeypatch.setattr(h, "_run", + lambda argv, **k: seen.update(argv=argv)) + h.transmit(433.92e6, 8e6, "/no/such/file.iq", print_cmd=True) + assert seen["argv"][0] == "transfer" + assert "-t" in seen["argv"] diff --git a/hackrfpy/tests/test_sigmf.py b/hackrfpy/tests/test_sigmf.py new file mode 100644 index 0000000..cb82c60 --- /dev/null +++ b/hackrfpy/tests/test_sigmf.py @@ -0,0 +1,56 @@ +#! /usr/bin/python3 + +##--------------------------------------------------------------------\ +# hackrfpy 'tests/test_sigmf.py' +# SigMF sidecar writer/reader. Pins the core namespace shape and the +# core:extensions declaration for hackrf:* keys (strict SigMF validators +# reject a file that uses a non-core namespace without declaring it). No +# device needed. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 +##--------------------------------------------------------------------\ + +import json + +from hackrfpy import write_sigmf_meta, read_sigmf_meta + + +def test_core_fields_written(tmp_path): + meta_path = write_sigmf_meta(str(tmp_path / "cap.iq"), 433.92e6, 8e6) + meta = json.load(open(meta_path)) + g = meta["global"] + assert g["core:datatype"] == "ci8" + assert g["core:sample_rate"] == 8e6 + assert meta["captures"][0]["core:frequency"] == 433.92e6 + + +def test_extensions_declared_when_gains_present(tmp_path): + # hackrf:* keys require a core:extensions declaration to be spec-valid. + meta_path = write_sigmf_meta(str(tmp_path / "cap.iq"), 100e6, 8e6, + lna=24, vga=20, amp=True) + g = json.load(open(meta_path))["global"] + assert g["hackrf:lna_gain_db"] == 24 + exts = g.get("core:extensions") + assert isinstance(exts, list) and len(exts) == 1 + assert exts[0]["name"] == "hackrf" + assert exts[0]["optional"] is True + assert "version" in exts[0] + + +def test_extensions_absent_when_no_gains(tmp_path): + # No hackrf:* keys -> no extension declaration (nothing to declare). + meta_path = write_sigmf_meta(str(tmp_path / "cap.iq"), 100e6, 8e6) + g = json.load(open(meta_path))["global"] + assert "core:extensions" not in g + assert not any(k.startswith("hackrf:") for k in g) + + +def test_roundtrip_via_data_path(tmp_path): + # read_sigmf_meta accepts the data path and maps it to the sidecar. + data_path = str(tmp_path / "cap.iq") + write_sigmf_meta(data_path, 915e6, 10e6, lna=16) + meta = read_sigmf_meta(data_path) + assert meta["global"]["hackrf:lna_gain_db"] == 16 + assert meta["captures"][0]["core:frequency"] == 915e6 From 91ec83eec270229a4ead8c6f0114fa4c70f1df49 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 20:09:38 -0400 Subject: [PATCH 11/17] version patch prep --- hackrfpy/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hackrfpy/pyproject.toml b/hackrfpy/pyproject.toml index d585668..275a4d7 100644 --- a/hackrfpy/pyproject.toml +++ b/hackrfpy/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hackrfpy" -version = "1.0.0" +version = "1.0.1" description = "A self-contained CLI + scriptable wrapper for the HackRF One, built on the hackrf-tools binaries (no libhackrf bindings required)." readme = "README.md" requires-python = ">=3.11" From 30ec23eb3b52a499288cbd88b77231325be3569b Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 20:09:45 -0400 Subject: [PATCH 12/17] version patch prep --- CITATION.cff | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CITATION.cff b/CITATION.cff index 206bd93..82e980d 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -13,7 +13,7 @@ authors: - family-names: "Linkous" given-names: "Lauren" orcid: "https://orcid.org/0000-0001-9945-5125" -version: "1.0.0" +version: "1.0.1" date-released: "2026-06-16" license: GPL-2.0-or-later repository-code: "https://github.com/LC-Linkous/hackRF_python" From 0af5941bac2754466bd60ac07c0d9c2634312667 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 20:47:53 -0400 Subject: [PATCH 13/17] format edting for tests --- .github/workflows/tests.yml | 3 --- hackrfpy/capture.sigmf-meta | 27 +++++++++++++++++++++++++++ hackrfpy/pyproject.toml | 22 ++++++++++++++++++++-- hackrfpy/src/hackrfpy/core.py | 21 ++++++++------------- hackrfpy/tests/test_compat.py | 11 +++++++---- 5 files changed, 62 insertions(+), 22 deletions(-) create mode 100644 hackrfpy/capture.sigmf-meta diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ce21297..3d2dc09 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,9 +71,6 @@ jobs: - name: Ruff lint run: uv run ruff check . - - name: Ruff format check - run: uv run ruff format --check . - - name: Mypy # Non-blocking for now: flip `continue-on-error` to false once the # type surface is clean and you want mypy to gate merges. diff --git a/hackrfpy/capture.sigmf-meta b/hackrfpy/capture.sigmf-meta new file mode 100644 index 0000000..4cef0cb --- /dev/null +++ b/hackrfpy/capture.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": 24, + "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": 433920000.0, + "core:datetime": "2026-07-12T00:05:06.505987+00:00" + } + ], + "annotations": [] +} \ No newline at end of file diff --git a/hackrfpy/pyproject.toml b/hackrfpy/pyproject.toml index 275a4d7..92588c8 100644 --- a/hackrfpy/pyproject.toml +++ b/hackrfpy/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hackrfpy" -version = "1.0.1" +version = "1.0.0" description = "A self-contained CLI + scriptable wrapper for the HackRF One, built on the hackrf-tools binaries (no libhackrf bindings required)." readme = "README.md" requires-python = ">=3.11" @@ -52,4 +52,22 @@ dev = [ markers = [ "hardware: tests that require a connected HackRF (self-skip if absent)", ] -testpaths = ["tests"] \ No newline at end of file +testpaths = ["tests"] + +[tool.ruff] +# Match the codebase's existing style rather than reformatting it. +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +# Default ruleset (pyflakes + a pycodestyle subset). Kept deliberately small: +# this gates CI, so every enabled rule should be one we actually want to block +# a merge on. +select = ["E", "F", "W"] + +[tool.ruff.lint.per-file-ignores] +# E741 ("ambiguous variable name l"): `l` reads naturally as "line" in the +# sweep-line parsing comprehensions. Cosmetic only, and renaming would churn +# working parser tests. +"tests/test_parsing.py" = ["E741"] +"tests/test_real_output.py" = ["E741"] \ No newline at end of file diff --git a/hackrfpy/src/hackrfpy/core.py b/hackrfpy/src/hackrfpy/core.py index 33a6e8c..25aba92 100644 --- a/hackrfpy/src/hackrfpy/core.py +++ b/hackrfpy/src/hackrfpy/core.py @@ -19,12 +19,14 @@ # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ +import atexit import os import shutil import signal import subprocess import sys import threading +import weakref import numpy as np @@ -32,6 +34,11 @@ from .exceptions import ( HackRFValueError, HackRFModeError, HackRFDeviceError, HackRFEnvironmentError, ) +from ._commands.info import InfoMixin +from ._commands.capture import CaptureMixin +from ._commands.transmit import TransmitMixin +from ._commands.sweep import SweepMixin +from ._commands.device import DeviceMixin # ---- platform interrupt plumbing ------------------------------------------- # hackrf_* tools flush + close cleanly on SIGINT. On Windows SIGINT can't be @@ -66,9 +73,6 @@ def _interrupt(proc): # 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). -import atexit -import weakref - _LIVE = weakref.WeakSet() @@ -86,13 +90,6 @@ def _stop_all_live(): pass # best-effort on the way down; never raise from atexit -from ._commands.info import InfoMixin -from ._commands.capture import CaptureMixin -from ._commands.transmit import TransmitMixin -from ._commands.sweep import SweepMixin -from ._commands.device import DeviceMixin - - # Upper bound on retained drained output per stream (stdout / stderr). The # handle-mode drain and the streaming-mode stderr tail both cap here so a # long-lived RX/TX handle can't grow memory without bound (hackrf_transfer @@ -683,8 +680,6 @@ def relative_power_db(self, iq_or_dbfs, *, lna=None, vga=None, amp=None, value -= float(freq_correction(freq_hz)) return value - return load_iq(path, count=count, offset_samples=offset_samples) - def estimate_capture(self, sample_rate, num_samples=None, duration=None, path="."): # Bytes/sec = sample_rate * 2 (int8 I + int8 Q). Returns a dict and @@ -755,4 +750,4 @@ def load_iq(path: str, count: int | None = None, iq.real = a[0::2] iq.imag = a[1::2] iq /= 128.0 - return iq + return iq \ No newline at end of file diff --git a/hackrfpy/tests/test_compat.py b/hackrfpy/tests/test_compat.py index 66e6494..3f7dbe5 100644 --- a/hackrfpy/tests/test_compat.py +++ b/hackrfpy/tests/test_compat.py @@ -17,7 +17,6 @@ import weakref import numpy as np -import pytest from hackrfpy import HackRF, constants as C import hackrfpy.core as core @@ -46,12 +45,16 @@ def test_features_unknown_version_is_optimistic_but_marked(): # ---- parameter readback ---------------------------------------------------- -def test_last_params_records_snapped_values(monkeypatch): +def test_last_params_records_snapped_values(monkeypatch, tmp_path): h = HackRF() monkeypatch.setattr(h, "_run", lambda *a, **k: ("", "", 0)) monkeypatch.setattr(h, "estimate_capture", lambda *a, **k: { "total_bytes": 0, "bytes_per_sec": 1, "seconds": 0, "free_bytes": 1}) - h.capture(433.92e6, 8e6, num_samples=1000, lna=30, vga=20) + # out= into tmp_path: capture() writes a SigMF sidecar next to the data + # file, and the default (out="capture.iq") dropped capture.sigmf-meta into + # the repo working directory on every test run. + h.capture(433.92e6, 8e6, num_samples=1000, lna=30, vga=20, + out=str(tmp_path / "capture.iq")) assert h.last_params["lna"] == 24 assert h.last_params["freq"] == 433_920_000 assert h.last_params["mode"] == "rx" @@ -127,4 +130,4 @@ def test_preflight_and_doctor_alias_agree(stub_device): r2 = h.doctor(capture_path=h._tmp_path) assert r1["features"]["stdout_streaming"] is True assert r1["tools"].keys() == r2["tools"].keys() - assert "features" in r1 + assert "features" in r1 \ No newline at end of file From f1524fab04b8a8fddaeef4824d484574be74da60 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 21:06:50 -0400 Subject: [PATCH 14/17] lint check update --- hackrfpy/examples/benchmark.py | 8 ++++---- hackrfpy/examples/calibrate.py | 2 +- hackrfpy/examples/collect_sample_data.py | 2 +- hackrfpy/examples/persistent_capture.py | 2 +- hackrfpy/examples/power_meter.py | 1 - hackrfpy/examples/waterfall_realtime.py | 2 +- hackrfpy/pyproject.toml | 2 +- hackrfpy/src/hackrfpy/_commands/capture.py | 2 +- hackrfpy/src/hackrfpy/_commands/device.py | 2 +- hackrfpy/src/hackrfpy/_commands/sweep.py | 2 +- hackrfpy/src/hackrfpy/_receiver.py | 1 - hackrfpy/src/hackrfpy/core.py | 2 +- hackrfpy/tests/collect_real_data.py | 2 +- hackrfpy/tests/test_cli.py | 2 +- hackrfpy/tests/test_compat.py | 2 +- hackrfpy/tests/test_detect.py | 2 -- hackrfpy/tests/test_metadata.py | 2 -- hackrfpy/tests/test_monitor.py | 1 - hackrfpy/tests/test_parsing.py | 1 - hackrfpy/tests/test_realtime.py | 3 +-- hackrfpy/tests/test_receiver.py | 1 - hackrfpy/tests/test_safety.py | 1 - 22 files changed, 17 insertions(+), 28 deletions(-) diff --git a/hackrfpy/examples/benchmark.py b/hackrfpy/examples/benchmark.py index d90d4d2..2138c60 100644 --- a/hackrfpy/examples/benchmark.py +++ b/hackrfpy/examples/benchmark.py @@ -92,7 +92,7 @@ def bench_drop_test(h, rate, seconds): if steady_rate >= rate / 1e6 * 0.95: print(f" -> sustains {rate/1e6:g} Msps in steady state") else: - print(f" -> steady rate below target; possible real limit here") + print(" -> steady rate below target; possible real limit here") # Drop detection: authoritative hackrf_debug -S shortfall count + the # hackrf_transfer 'overruns' count from stderr. @@ -109,10 +109,10 @@ def bench_drop_test(h, rate, seconds): pass if got >= n * 0.999: - print(f" note: ~100% of requested samples received -- no bulk loss") + print(" note: ~100% of requested samples received -- no bulk loss") if shortfall is not None: if shortfall == 0: - print(f" hackrf_debug -S: 0 shortfalls -- clean") + print(" hackrf_debug -S: 0 shortfalls -- clean") elif shortfall <= 3: print(f" hackrf_debug -S: {shortfall} shortfall(s) -- likely a " f"startup transient, not sustained loss") @@ -187,4 +187,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/hackrfpy/examples/calibrate.py b/hackrfpy/examples/calibrate.py index a7501fb..c0d8ec8 100644 --- a/hackrfpy/examples/calibrate.py +++ b/hackrfpy/examples/calibrate.py @@ -72,7 +72,7 @@ def freq_response_curve(h, args): # reference (a broadband noise source with known-flat output). Lacking # that, this still captures the *relative* shape vs the reference freq, # which removes most of the "why is 2.4 GHz lower than 100 MHz" confound. - print(f"\n== frequency-response characterization ==") + print("\n== frequency-response characterization ==") print(" NOTE: only meaningful with a FLAT reference source connected.") if not args.assume_ready: input(" press Enter with the flat reference on (or Ctrl-C to skip)... ") diff --git a/hackrfpy/examples/collect_sample_data.py b/hackrfpy/examples/collect_sample_data.py index b456391..4024efd 100644 --- a/hackrfpy/examples/collect_sample_data.py +++ b/hackrfpy/examples/collect_sample_data.py @@ -203,4 +203,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/hackrfpy/examples/persistent_capture.py b/hackrfpy/examples/persistent_capture.py index c2085a3..86fd57d 100644 --- a/hackrfpy/examples/persistent_capture.py +++ b/hackrfpy/examples/persistent_capture.py @@ -154,4 +154,4 @@ def spectrum(iq): if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/hackrfpy/examples/power_meter.py b/hackrfpy/examples/power_meter.py index 511ea0b..ffcaa10 100644 --- a/hackrfpy/examples/power_meter.py +++ b/hackrfpy/examples/power_meter.py @@ -16,7 +16,6 @@ ##--------------------------------------------------------------------\ import argparse import sys -import numpy as np from hackrfpy import HackRF diff --git a/hackrfpy/examples/waterfall_realtime.py b/hackrfpy/examples/waterfall_realtime.py index a094e12..24dd111 100644 --- a/hackrfpy/examples/waterfall_realtime.py +++ b/hackrfpy/examples/waterfall_realtime.py @@ -154,4 +154,4 @@ def flush_line(): except KeyboardInterrupt: pass # context manager has already reaped the child on the way out -print("[*] stopped") \ No newline at end of file +print("[*] stopped") diff --git a/hackrfpy/pyproject.toml b/hackrfpy/pyproject.toml index 92588c8..d608414 100644 --- a/hackrfpy/pyproject.toml +++ b/hackrfpy/pyproject.toml @@ -70,4 +70,4 @@ select = ["E", "F", "W"] # sweep-line parsing comprehensions. Cosmetic only, and renaming would churn # working parser tests. "tests/test_parsing.py" = ["E741"] -"tests/test_real_output.py" = ["E741"] \ No newline at end of file +"tests/test_real_output.py" = ["E741"] diff --git a/hackrfpy/src/hackrfpy/_commands/capture.py b/hackrfpy/src/hackrfpy/_commands/capture.py index 1818600..4630041 100644 --- a/hackrfpy/src/hackrfpy/_commands/capture.py +++ b/hackrfpy/src/hackrfpy/_commands/capture.py @@ -268,7 +268,7 @@ def _capture_segmented(self, freq, sample_rate, out, segment_secs, lna, seg = f"{base}_{idx:03d}{ext}" argv = self._rx_argv(freq, sample_rate, seg, lna, vga, amp, bias_tee, bw, n_per) - res = self._run(argv, mode="blocking", print_cmd=print_cmd) + self._run(argv, mode="blocking", print_cmd=print_cmd) if sigmf and not print_cmd: write_sigmf_meta(seg, freq, sample_rate, lna=lna, vga=vga, amp=amp, datatype="ci8") diff --git a/hackrfpy/src/hackrfpy/_commands/device.py b/hackrfpy/src/hackrfpy/_commands/device.py index 23a1faa..38dbe24 100644 --- a/hackrfpy/src/hackrfpy/_commands/device.py +++ b/hackrfpy/src/hackrfpy/_commands/device.py @@ -16,7 +16,7 @@ import shutil from .. import constants as C -from ..exceptions import HackRFDeviceError, HackRFEnvironmentError, HackRFValueError +from ..exceptions import HackRFDeviceError, HackRFValueError class DeviceMixin: diff --git a/hackrfpy/src/hackrfpy/_commands/sweep.py b/hackrfpy/src/hackrfpy/_commands/sweep.py index e29645e..9d6ad5c 100644 --- a/hackrfpy/src/hackrfpy/_commands/sweep.py +++ b/hackrfpy/src/hackrfpy/_commands/sweep.py @@ -180,7 +180,7 @@ def sweep_to_file(self, f_min_hz, f_max_hz, out, *, binary=False, argv += ["-I"] # binary inverse FFT (binary output mode) elif binary: argv += ["-B"] # raw binary output - res = self._run(argv, mode="blocking", print_cmd=print_cmd) + self._run(argv, mode="blocking", print_cmd=print_cmd) return None if print_cmd else out def sweep_stream(self, f_min_hz, f_max_hz, **k): diff --git a/hackrfpy/src/hackrfpy/_receiver.py b/hackrfpy/src/hackrfpy/_receiver.py index 88b05d5..6d996b2 100644 --- a/hackrfpy/src/hackrfpy/_receiver.py +++ b/hackrfpy/src/hackrfpy/_receiver.py @@ -19,7 +19,6 @@ # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ -import numpy as np class PersistentReceiver: diff --git a/hackrfpy/src/hackrfpy/core.py b/hackrfpy/src/hackrfpy/core.py index 25aba92..06ca947 100644 --- a/hackrfpy/src/hackrfpy/core.py +++ b/hackrfpy/src/hackrfpy/core.py @@ -750,4 +750,4 @@ def load_iq(path: str, count: int | None = None, iq.real = a[0::2] iq.imag = a[1::2] iq /= 128.0 - return iq \ No newline at end of file + return iq diff --git a/hackrfpy/tests/collect_real_data.py b/hackrfpy/tests/collect_real_data.py index c02724a..d70f046 100644 --- a/hackrfpy/tests/collect_real_data.py +++ b/hackrfpy/tests/collect_real_data.py @@ -46,7 +46,7 @@ from hackrfpy.exceptions import HackRFError # noqa: E402 try: - import numpy as np # noqa: E402 + import numpy as np # noqa: E402, F401 except ModuleNotFoundError: sys.stderr.write( "ERROR: numpy is not available in this environment.\n" diff --git a/hackrfpy/tests/test_cli.py b/hackrfpy/tests/test_cli.py index 868cdbe..4c9ba2a 100644 --- a/hackrfpy/tests/test_cli.py +++ b/hackrfpy/tests/test_cli.py @@ -361,4 +361,4 @@ def test_print_detect_rich_report(capsys): assert "UNCONFIRMED" in out # the non-HackRF board assert "stale" in out # firmware_stale branch assert "multiple boards" in out # multiple branch - assert "one board has stale firmware" in out # warnings branch \ No newline at end of file + assert "one board has stale firmware" in out # warnings branch diff --git a/hackrfpy/tests/test_compat.py b/hackrfpy/tests/test_compat.py index 3f7dbe5..dd1b4da 100644 --- a/hackrfpy/tests/test_compat.py +++ b/hackrfpy/tests/test_compat.py @@ -130,4 +130,4 @@ def test_preflight_and_doctor_alias_agree(stub_device): r2 = h.doctor(capture_path=h._tmp_path) assert r1["features"]["stdout_streaming"] is True assert r1["tools"].keys() == r2["tools"].keys() - assert "features" in r1 \ No newline at end of file + assert "features" in r1 diff --git a/hackrfpy/tests/test_detect.py b/hackrfpy/tests/test_detect.py index 4f03568..fb86fc5 100644 --- a/hackrfpy/tests/test_detect.py +++ b/hackrfpy/tests/test_detect.py @@ -11,10 +11,8 @@ # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ -import pytest from hackrfpy import HackRF -from hackrfpy.exceptions import HackRFDeviceError _ONE_BOARD = [ diff --git a/hackrfpy/tests/test_metadata.py b/hackrfpy/tests/test_metadata.py index 3ed3634..4f5d039 100644 --- a/hackrfpy/tests/test_metadata.py +++ b/hackrfpy/tests/test_metadata.py @@ -9,8 +9,6 @@ # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ -import json -import os import pytest diff --git a/hackrfpy/tests/test_monitor.py b/hackrfpy/tests/test_monitor.py index e146301..202844e 100644 --- a/hackrfpy/tests/test_monitor.py +++ b/hackrfpy/tests/test_monitor.py @@ -11,7 +11,6 @@ # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ -from hackrfpy import HackRF # two sweep passes (two timestamps), segments covering 100 and 433 MHz diff --git a/hackrfpy/tests/test_parsing.py b/hackrfpy/tests/test_parsing.py index c84f300..d8d3f3e 100644 --- a/hackrfpy/tests/test_parsing.py +++ b/hackrfpy/tests/test_parsing.py @@ -13,7 +13,6 @@ import numpy as np from hackrfpy import HackRF -from hackrfpy.core import HackRF as _H from hackrfpy._commands.sweep import SweepMixin from hackrfpy._commands.info import InfoMixin diff --git a/hackrfpy/tests/test_realtime.py b/hackrfpy/tests/test_realtime.py index 9399fae..dc9da10 100644 --- a/hackrfpy/tests/test_realtime.py +++ b/hackrfpy/tests/test_realtime.py @@ -13,7 +13,6 @@ ##--------------------------------------------------------------------\ import numpy as np -import pytest from hackrfpy import HackRF @@ -87,4 +86,4 @@ def test_optimized_decode_matches_reference(): ).astype(np.complex64) / 128.0 got = h.decode_iq(raw) assert np.array_equal(got, ref), f"decode mismatch on {list(raw)}" - assert got.dtype == np.complex64 \ No newline at end of file + assert got.dtype == np.complex64 diff --git a/hackrfpy/tests/test_receiver.py b/hackrfpy/tests/test_receiver.py index 9fd4590..e1a2361 100644 --- a/hackrfpy/tests/test_receiver.py +++ b/hackrfpy/tests/test_receiver.py @@ -14,7 +14,6 @@ import numpy as np -from hackrfpy import HackRF def _streaming_device(stub_device, nbytes=200_000): diff --git a/hackrfpy/tests/test_safety.py b/hackrfpy/tests/test_safety.py index f1e73e4..a71fc70 100644 --- a/hackrfpy/tests/test_safety.py +++ b/hackrfpy/tests/test_safety.py @@ -12,7 +12,6 @@ # Author(s): Lauren Linkous # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ -import sys import pytest From fed1ae169c93023ceded6a5a3e794a705c42d499 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 21:17:34 -0400 Subject: [PATCH 15/17] logging fixes --- CHANGELOG.md | 41 +++++++++++++---- hackrfpy/src/hackrfpy/core.py | 54 ++++++++++++++++++++--- hackrfpy/tests/test_features.py | 19 ++++---- hackrfpy/tests/test_safety.py | 78 ++++++++++++++++++++++++++++----- 4 files changed, 157 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc582d..04bede1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,28 +21,51 @@ release. `SECURITY.md`, issue templates, and a pull request template. ### Changed +- 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, + reformat, or silence hackrfpy's output like any other library. + - **Diagnostics no longer touch stdout.** `print_message` previously wrote to + stdout, so `hrf sweep -v > out.csv` prepended `[*] mode: rx` into the CSV. + stdout is now reserved for data (sweep CSV, IQ on `-r -`) and explicitly + requested output (`--print-cmd`). Diagnostics go to stderr. + - Console behavior is unchanged for scripts and the CLI: warnings still appear + with no setup at all, and `verbose=True` / `-v` still prints progress. If the + host application has configured logging, hackrfpy stays out of the way and + simply propagates records to it. - Packaging classifiers: removed the contradictory `Operating System :: OS Independent` (the library shells out to the Windows `hackrf-tools` binaries and Linux/macOS operation is unverified), leaving `Operating System :: Microsoft :: Windows`. Development status raised from `3 - Alpha` to `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. ### Fixed +- Unbounded memory growth on long-lived RX/TX handles: `_Process` drained child + stdout/stderr into lists that were never trimmed, so an open-ended capture or + repeat transmit retained every per-second stats line for the life of the + process. Both drain paths now share a 64 KB cap (`_DRAIN_CAP`). +- `transmit()` now verifies the source file exists *before* arming TX and + spawning `hackrf_transfer`, raising `HackRFEnvironmentError` instead of + failing with a generic non-zero exit from the tool. The TX-mode gate is still + checked first, and `print_cmd` dry runs skip the check. +- SigMF sidecars now declare the `hackrf` namespace in `core:extensions`. + Previously the `hackrf:*` gain keys were written without declaring the + extension, which strict SigMF validators reject. +- Removed unreachable dead code in `core.py` (an orphaned `return load_iq(...)` + after a `return`, referencing three undefined names). +- The test suite no longer writes a stray `capture.sigmf-meta` into the working + 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. @@ -69,4 +92,4 @@ Initial public release. and a parameter readback (`last_params`) reflecting the values actually used. [Unreleased]: https://github.com/LC-Linkous/hackRF_python/compare/V1.0.0...HEAD -[1.0.0]: https://github.com/LC-Linkous/hackRF_python/releases/tag/V1.0.0 +[1.0.0]: https://github.com/LC-Linkous/hackRF_python/releases/tag/V1.0.0 \ No newline at end of file diff --git a/hackrfpy/src/hackrfpy/core.py b/hackrfpy/src/hackrfpy/core.py index 06ca947..e2d6f48 100644 --- a/hackrfpy/src/hackrfpy/core.py +++ b/hackrfpy/src/hackrfpy/core.py @@ -20,6 +20,7 @@ ##--------------------------------------------------------------------\ import atexit +import logging import os import shutil import signal @@ -40,6 +41,35 @@ from ._commands.sweep import SweepMixin from ._commands.device import DeviceMixin + +# ---- diagnostics channel ---------------------------------------------------- +# Library diagnostics go through logging, not print(), so a consumer can route, +# filter, or silence them. Two rules hold, and the tests pin both: +# +# 1. Diagnostics NEVER touch stdout. stdout is for DATA (sweep CSV, an +# IQ stream on `-r -`) and for explicitly-requested output (a --print-cmd +# preview). `hrf sweep -v > out.csv` must not put "[*] mode: rx" in the CSV. +# 2. Warnings fire regardless of verbose. A safety notice that only appears in +# verbose mode is, in practice, a silent warning. +# +# No NullHandler is installed on purpose: with no handler anywhere, logging's +# last-resort handler emits WARNING+ to stderr on its own, which preserves the +# "warnings always show, zero setup required" contract for plain scripts. +log = logging.getLogger("hackrfpy") + + +def _ensure_console_logging(): + # The last-resort handler only emits WARNING and above, so verbose=True in a + # plain script would otherwise print nothing at INFO. Attach a stderr handler + # -- but ONLY if nobody has configured logging (neither our logger nor the + # root). If the host application owns logging, stay out of its way and just + # let records propagate. + if log.handlers or logging.getLogger().handlers: + return + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(message)s")) # bare: no level prefix + log.addHandler(handler) + # ---- 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 @@ -178,7 +208,10 @@ def __exit__(self, exc_type, exc, tb): class HackRF(InfoMixin, CaptureMixin, TransmitMixin, SweepMixin, DeviceMixin): def __init__(self, tools_dir=None, verbose=False, serial=None): # ---- feedback ---- - self.verboseEnabled = verbose + # via set_verbose so that HackRF(verbose=True) wires up the console + # handler exactly like an explicit set_verbose(True) call would. + self.verboseEnabled = False + self.set_verbose(verbose) # ---- device selection (multi-board setups) ---- # When set, `-d ` is injected into every tool that supports @@ -225,20 +258,29 @@ def _record_params(self, **params): # ================================================================= def set_verbose(self, verbose=True): self.verboseEnabled = verbose + if verbose: + # Make INFO actually visible in a plain script (see + # _ensure_console_logging). No-op if the host app configured logging. + _ensure_console_logging() + if log.getEffectiveLevel() > logging.INFO: + log.setLevel(logging.INFO) def get_verbose(self): return self.verboseEnabled def print_message(self, msg): + # 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: - print(msg) + log.info(msg) def warn(self, msg): # 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.) Routed to stderr so it never - # pollutes stdout data streams (sweep CSV, rx -r -). - print(f"[!] {msg}", file=sys.stderr) + # mode is, in practice, a silent warning.) WARNING level, so it survives + # with no logging setup at all via logging's last-resort stderr handler. + # The "[!] " marker lives in the message so it shows under any formatter. + log.warning(f"[!] {msg}") # ================================================================= # Operating mode machine @@ -750,4 +792,4 @@ def load_iq(path: str, count: int | None = None, iq.real = a[0::2] iq.imag = a[1::2] iq /= 128.0 - return iq + return iq \ No newline at end of file diff --git a/hackrfpy/tests/test_features.py b/hackrfpy/tests/test_features.py index c642f92..b8ea050 100644 --- a/hackrfpy/tests/test_features.py +++ b/hackrfpy/tests/test_features.py @@ -13,6 +13,7 @@ # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ +import logging import time import numpy as np @@ -58,15 +59,16 @@ def test_capture_stream_reaps_on_exception(stub_device, tmp_path): # ---- from_device: fail-fast capability probe ------------------------------- -def test_from_device_probes_and_warns_on_old_firmware(stub_device, capsys): +def test_from_device_probes_and_warns_on_old_firmware(stub_device, caplog): h = stub_device(info=dict(stdout_lines=[ "hackrf_info version: 2019.12.1", "libhackrf version: 2019.12.1 (0.5)", "Found HackRF", "Index: 0", "Serial number: 000000000000000000000000deadbeef"])) - dev = HackRF.from_device(tools_dir=h.tools_dir) + with caplog.at_level(logging.WARNING, logger="hackrfpy"): + dev = HackRF.from_device(tools_dir=h.tools_dir) assert dev._probed["boards"] - assert "predates 2021" in capsys.readouterr().err + assert "predates 2021" in "\n".join(r.message for r in caplog.records) def test_from_device_raises_when_no_board(stub_device): @@ -103,15 +105,16 @@ def test_core_tools_are_subset_of_tools(): # ---- sweep: sub-MHz edges warn --------------------------------------------- -def test_sweep_warns_on_sub_mhz_edges(capsys, monkeypatch): +def test_sweep_warns_on_sub_mhz_edges(caplog, monkeypatch): h = HackRF() def empty_stream(*a, **k): if False: yield monkeypatch.setattr(h, "_run", empty_stream) - gen = h.sweep(433_920_000, 434_500_000) - list(gen) - assert "snapped to MHz" in capsys.readouterr().err + with caplog.at_level(logging.WARNING, logger="hackrfpy"): + gen = h.sweep(433_920_000, 434_500_000) + list(gen) + assert "snapped to MHz" in "\n".join(r.message for r in caplog.records) # ---- tx: max_duration converts an open-ended repeat into a timed run ------- @@ -125,4 +128,4 @@ def test_tx_max_duration_forces_timed(monkeypatch, tmp_path): lambda argv, **k: seen.update(k) or ("", "", 0)) h.transmit(433.92e6, 8e6, str(src), repeat=True, max_duration=5.0) assert seen.get("mode") == "timed" - assert seen.get("duration") == 5.0 + assert seen.get("duration") == 5.0 \ No newline at end of file diff --git a/hackrfpy/tests/test_safety.py b/hackrfpy/tests/test_safety.py index a71fc70..e8ce532 100644 --- a/hackrfpy/tests/test_safety.py +++ b/hackrfpy/tests/test_safety.py @@ -13,6 +13,8 @@ # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ +import logging + import pytest from hackrfpy import HackRF, parse_freq, constants as C @@ -50,27 +52,42 @@ def fake_run(argv, **k): # ---- warn channel: safety warnings must fire regardless of verbose ---------- -def test_snap_gain_warns_on_stderr_when_not_verbose(capsys): +# Warnings now go through logging ("hackrfpy" logger, WARNING level) instead of +# a raw stderr write, so these assert on caplog. The stdout-purity invariant is +# still checked with capsys: diagnostics must NEVER land on stdout. +def test_snap_gain_warns_when_not_verbose(caplog): h = HackRF(verbose=False) - snapped = h._snap_gain("lna", 7, C.LNA_GAIN) # 7 -> 0, a silent 7 dB loss + with caplog.at_level(logging.WARNING, logger="hackrfpy"): + snapped = h._snap_gain("lna", 7, C.LNA_GAIN) # 7 -> 0, a silent 7 dB loss assert snapped == 0 - err = capsys.readouterr().err - assert "lna" in err and "-> 0" in err # user was told, on stderr + msgs = "\n".join(r.message for r in caplog.records) + assert "lna" in msgs and "-> 0" in msgs # user was told, despite verbose=False -def test_sub_recommended_sample_rate_warns_not_silent(capsys): +def test_sub_recommended_sample_rate_warns_not_silent(caplog): h = HackRF(verbose=False) - h._check_hard_range("sample_rate", 4e6, C.SR_MIN, C.SR_MAX, C.SR_WARN_BELOW) - assert "below the recommended" in capsys.readouterr().err + with caplog.at_level(logging.WARNING, logger="hackrfpy"): + h._check_hard_range("sample_rate", 4e6, C.SR_MIN, C.SR_MAX, C.SR_WARN_BELOW) + assert "below the recommended" in "\n".join(r.message for r in caplog.records) -def test_forced_out_of_spec_warns_on_stderr(capsys): +def test_forced_out_of_spec_warns_and_never_touches_stdout(caplog, capsys): h = HackRF() h.allow_out_of_spec = True - h._check_hard_range("frequency", 60e9, C.FREQ_MIN_HZ, C.FREQ_MAX_HZ) - out, err = capsys.readouterr() - assert "forced out-of-spec" in err - assert out == "" # never pollutes stdout + with caplog.at_level(logging.WARNING, logger="hackrfpy"): + h._check_hard_range("frequency", 60e9, C.FREQ_MIN_HZ, C.FREQ_MAX_HZ) + assert "forced out-of-spec" in "\n".join(r.message for r in caplog.records) + assert capsys.readouterr().out == "" # never pollutes stdout + + +def test_warnings_are_warning_level(caplog): + # Level matters: a consumer filtering at WARNING must still receive safety + # notices. Pinned so a future refactor can't quietly demote these to INFO. + h = HackRF(verbose=False) + with caplog.at_level(logging.WARNING, logger="hackrfpy"): + h._snap_gain("lna", 7, C.LNA_GAIN) + assert caplog.records + assert all(r.levelno == logging.WARNING for r in caplog.records) # ---- typed frequency parsing ------------------------------------------------ @@ -184,3 +201,40 @@ def test_transmit_print_cmd_skips_source_check(monkeypatch): h.transmit(433.92e6, 8e6, "/no/such/file.iq", print_cmd=True) assert seen["argv"][0] == "transfer" assert "-t" in seen["argv"] + + +# ---- logging contract: what a consumer is entitled to rely on --------------- +def test_diagnostics_never_reach_stdout(capsys, caplog): + # The reason print_message moved off stdout: `hrf sweep -v > out.csv` used + # to prepend "[*] mode: rx" INTO the CSV. stdout is data; stderr is chatter. + h = HackRF(verbose=True) + with caplog.at_level(logging.INFO, logger="hackrfpy"): + h.print_message("[*] progress chatter") + h.warn("a safety warning") + assert capsys.readouterr().out == "" + assert len(caplog.records) == 2 + + +def test_consumer_can_silence_the_library(caplog): + # Raising the level on the "hackrfpy" logger must suppress our records -- + # impossible back when these were bare print() calls. + h = HackRF(verbose=False) + logger = logging.getLogger("hackrfpy") + with caplog.at_level(logging.CRITICAL, logger="hackrfpy"): + logger.setLevel(logging.CRITICAL) + try: + h.warn("should be suppressed") + finally: + logger.setLevel(logging.NOTSET) # restore; logger is global + assert not [r for r in caplog.records if "suppressed" in r.message] + + +def test_verbose_gating_still_holds(caplog): + # verbose=False -> no INFO; verbose=True -> INFO flows. + with caplog.at_level(logging.INFO, logger="hackrfpy"): + quiet = HackRF(verbose=False) + quiet.print_message("[*] invisible") + assert not caplog.records + quiet.set_verbose(True) + quiet.print_message("[*] now visible") + assert any("now visible" in r.message for r in caplog.records) \ No newline at end of file From 85f9ea8aeae099277dd10a89c62c7c3590f6cf0e Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 21:22:50 -0400 Subject: [PATCH 16/17] ruff check fix new lines trailing in file --- hackrfpy/src/hackrfpy/core.py | 2 +- hackrfpy/tests/test_features.py | 2 +- hackrfpy/tests/test_safety.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hackrfpy/src/hackrfpy/core.py b/hackrfpy/src/hackrfpy/core.py index e2d6f48..91e3697 100644 --- a/hackrfpy/src/hackrfpy/core.py +++ b/hackrfpy/src/hackrfpy/core.py @@ -792,4 +792,4 @@ def load_iq(path: str, count: int | None = None, iq.real = a[0::2] iq.imag = a[1::2] iq /= 128.0 - return iq \ No newline at end of file + return iq diff --git a/hackrfpy/tests/test_features.py b/hackrfpy/tests/test_features.py index b8ea050..ee6e9ed 100644 --- a/hackrfpy/tests/test_features.py +++ b/hackrfpy/tests/test_features.py @@ -128,4 +128,4 @@ def test_tx_max_duration_forces_timed(monkeypatch, tmp_path): lambda argv, **k: seen.update(k) or ("", "", 0)) h.transmit(433.92e6, 8e6, str(src), repeat=True, max_duration=5.0) assert seen.get("mode") == "timed" - assert seen.get("duration") == 5.0 \ No newline at end of file + assert seen.get("duration") == 5.0 diff --git a/hackrfpy/tests/test_safety.py b/hackrfpy/tests/test_safety.py index e8ce532..81e872e 100644 --- a/hackrfpy/tests/test_safety.py +++ b/hackrfpy/tests/test_safety.py @@ -237,4 +237,4 @@ def test_verbose_gating_still_holds(caplog): assert not caplog.records quiet.set_verbose(True) quiet.print_message("[*] now visible") - assert any("now visible" in r.message for r in caplog.records) \ No newline at end of file + assert any("now visible" in r.message for r in caplog.records) From cf569f0b1da237b79dfec41e2b100a51ef219738 Mon Sep 17 00:00:00 2001 From: LC_Linkous Date: Sat, 11 Jul 2026 21:49:59 -0400 Subject: [PATCH 17/17] mypy and tests --- .github/workflows/tests.yml | 8 +- CHANGELOG.md | 20 +++ hackrfpy/pyproject.toml | 14 ++ hackrfpy/src/hackrfpy/_commands/capture.py | 70 ++++++--- hackrfpy/src/hackrfpy/_commands/device.py | 40 +++--- hackrfpy/src/hackrfpy/_commands/info.py | 28 ++-- hackrfpy/src/hackrfpy/_commands/sweep.py | 60 +++++--- hackrfpy/src/hackrfpy/_commands/transmit.py | 30 ++-- hackrfpy/src/hackrfpy/_host.py | 80 +++++++++++ hackrfpy/src/hackrfpy/_receiver.py | 62 +++++--- hackrfpy/src/hackrfpy/_stream_ctx.py | 14 +- hackrfpy/src/hackrfpy/cli.py | 24 ++-- hackrfpy/src/hackrfpy/core.py | 149 ++++++++++++-------- hackrfpy/src/hackrfpy/presets.py | 19 ++- hackrfpy/src/hackrfpy/sigmf.py | 13 +- hackrfpy/tests/test_receiver.py | 29 ++++ 16 files changed, 479 insertions(+), 181 deletions(-) create mode 100644 hackrfpy/src/hackrfpy/_host.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3d2dc09..77e94f1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -72,7 +72,7 @@ jobs: run: uv run ruff check . - name: Mypy - # Non-blocking for now: flip `continue-on-error` to false once the - # type surface is clean and you want mypy to gate merges. - continue-on-error: true - run: uv run mypy src/hackrfpy \ No newline at end of file + # Blocking. The package ships a py.typed marker, which promises + # downstream type-checkers that these annotations are real; this gate is + # what keeps that promise true. + run: uv run mypy \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 04bede1..450a6e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ Work on the current development branch. Entries move to a versioned section on release. ### Added +- 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 + are real; previously only 1 of ~114 definitions was annotated, so that marker + was a false promise. It is now enforced. +- `HostOps` protocol (`_host.py`) making the contract between `HackRF` and the + command mixins explicit and type-checkable. The mixins call ~27 methods on + `self` that live on the host class; that dependency was previously implicit in + a comment. Runtime composition and MRO are unchanged. - Continuous integration: GitHub Actions workflow running the test suite across Windows, Linux, and macOS on Python 3.11-3.13, plus a ruff + mypy lint job. - CLI test suite covering argument parsing, the mode state file, preset @@ -42,6 +51,17 @@ release. the codebase made lint-clean so the CI lint job is meaningful. ### Fixed +- 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 + exist on the class, so the resulting `AttributeError` was swallowed by the + hook's bare `except Exception` and the receiver was silently left running. + Found by the typing pass; `is_alive()` added and pinned with a regression test. +- Reading from a closed `PersistentReceiver` raised a bare + `TypeError: 'NoneType' object is not iterable` instead of a typed error; it now + raises `HackRFDeviceError` with an actionable message. +- `sweep_stream(..., print_cmd=True)` would hand `StreamCtx` a `None` and crash; + it now raises `HackRFValueError` pointing at `sweep(..., print_cmd=True)`. - Unbounded memory growth on long-lived RX/TX handles: `_Process` drained child stdout/stderr into lists that were never trimmed, so an open-ended capture or repeat transmit retained every per-second stats line for the life of the diff --git a/hackrfpy/pyproject.toml b/hackrfpy/pyproject.toml index d608414..bd34ffc 100644 --- a/hackrfpy/pyproject.toml +++ b/hackrfpy/pyproject.toml @@ -54,6 +54,20 @@ markers = [ ] testpaths = ["tests"] +[tool.mypy] +python_version = "3.11" +files = ["src/hackrfpy"] +# The point of this config: the package ships a py.typed marker, which promises +# downstream type-checkers that the inline annotations are real. These settings +# are what make that promise true -- every def in the shipped package must be +# annotated, or CI fails. +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true + [tool.ruff] # Match the codebase's existing style rather than reformatting it. line-length = 100 diff --git a/hackrfpy/src/hackrfpy/_commands/capture.py b/hackrfpy/src/hackrfpy/_commands/capture.py index 4630041..600ac70 100644 --- a/hackrfpy/src/hackrfpy/_commands/capture.py +++ b/hackrfpy/src/hackrfpy/_commands/capture.py @@ -18,18 +18,30 @@ # Author(s): ##--------------------------------------------------------------------\ +from __future__ import annotations + import os +from typing import TYPE_CHECKING, Any, Callable, Generator + +import numpy as np from .. import constants as C +from .._host import HostOps from ..sigmf import write_sigmf_meta +if TYPE_CHECKING: + from .._receiver import PersistentReceiver + from .._stream_ctx import StreamCtx + -class CaptureMixin: - def capture(self, freq, sample_rate, *, out="capture.iq", - num_samples=None, duration=None, - lna=16, vga=20, amp=False, bias_tee=False, - baseband_bw=None, to_stdout=False, sigmf=True, - segment_secs=None, print_cmd=False): +class CaptureMixin(HostOps): + def capture(self, freq: float, sample_rate: float, *, + out: str = "capture.iq", num_samples: int | None = None, + duration: float | None = None, lna: int = 16, vga: int = 20, + amp: bool = False, bias_tee: bool = False, + baseband_bw: float | None = None, to_stdout: bool = False, + sigmf: bool = True, segment_secs: float | None = None, + print_cmd: bool = False) -> Any: 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) @@ -57,7 +69,7 @@ def capture(self, freq, sample_rate, *, out="capture.iq", # The inner stream generator is closed EXPLICITLY on the way out; # leaving it to GC can orphan a receiving hackrf_transfer after # the caller breaks out of the loop. - def _gen(): + def _gen() -> Generator[np.ndarray, None, None]: # decoded, not raw tail = b"" inner = self._run(argv, mode="stream") try: @@ -97,17 +109,21 @@ def _gen(): return res # ---- aliases ---- - def rx(self, *a, **k): + def rx(self, *a: Any, **k: Any) -> Any: return self.capture(*a, **k) - def capture_samples(self, freq, sample_rate, num_samples, **k): + def capture_samples(self, freq: float, sample_rate: float, + num_samples: int, **k: Any) -> Any: return self.capture(freq, sample_rate, num_samples=num_samples, **k) - def capture_seconds(self, freq, sample_rate, duration, **k): + def capture_seconds(self, freq: float, sample_rate: float, + duration: float, **k: Any) -> Any: return self.capture(freq, sample_rate, duration=duration, **k) - def scan_frequencies(self, freqs, sample_rate, num_samples, *, - on_capture=None, **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: # 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 @@ -130,8 +146,8 @@ def scan_frequencies(self, freqs, sample_rate, num_samples, *, return results if on_capture is None else None # ---- in-memory + context-managed entry points ---- - def capture_array(self, freq, sample_rate, num_samples, *, - return_params=False, **k): + def capture_array(self, freq: float, sample_rate: float, num_samples: int, + *, return_params: bool = False, **k: Any) -> Any: # 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 @@ -164,8 +180,10 @@ def capture_array(self, freq, sample_rate, num_samples, *, return iq, self.last_params return iq - def open_receiver(self, freq, sample_rate, *, lna=16, vga=20, amp=False, - baseband_bw=None, read_samples=131072): + 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 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: @@ -184,7 +202,8 @@ def open_receiver(self, freq, sample_rate, *, lna=16, vga=20, amp=False, amp=amp, baseband_bw=baseband_bw, read_samples=read_samples) - def capture_stream(self, freq, sample_rate, **k): + def capture_stream(self, freq: float, sample_rate: float, + **k: Any) -> StreamCtx: # 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: @@ -194,8 +213,10 @@ def capture_stream(self, freq, sample_rate, **k): gen = self.capture(freq, sample_rate, to_stdout=True, **k) return StreamCtx(gen) - def capture_callback(self, freq, sample_rate, on_block, *, - max_samples=None, max_blocks=None, **k): + 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: # 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 @@ -228,8 +249,9 @@ def capture_callback(self, freq, sample_rate, on_block, *, return total # ---- internals ---- - def _rx_argv(self, freq, sample_rate, target, lna, vga, amp, bias_tee, - bw, num_samples): + def _rx_argv(self, freq: float, sample_rate: float, target: str, lna: int, + vga: int, amp: bool, bias_tee: bool, bw: float, + num_samples: int | None) -> list[Any]: argv = ["transfer", "-r", target, "-f", int(freq), "-s", int(sample_rate), "-l", lna, "-g", vga, @@ -241,8 +263,10 @@ def _rx_argv(self, freq, sample_rate, target, lna, vga, amp, bias_tee, argv += ["-n", int(num_samples)] return argv - def _capture_segmented(self, freq, sample_rate, out, segment_secs, lna, - vga, amp, bias_tee, bw, sigmf, print_cmd): + def _capture_segmented(self, freq: float, sample_rate: float, out: str, + segment_secs: float, lna: int, vga: int, amp: bool, + bias_tee: bool, bw: float, sigmf: bool, + print_cmd: bool) -> list[str]: # Rolling files out_000.iq, out_001.iq, ... each `segment_secs` long. # Each segment is a bounded blocking capture, so files are whole. base, ext = os.path.splitext(out) diff --git a/hackrfpy/src/hackrfpy/_commands/device.py b/hackrfpy/src/hackrfpy/_commands/device.py index 38dbe24..3b0b47f 100644 --- a/hackrfpy/src/hackrfpy/_commands/device.py +++ b/hackrfpy/src/hackrfpy/_commands/device.py @@ -12,29 +12,34 @@ # Author(s): ##--------------------------------------------------------------------\ +from __future__ import annotations + import re import shutil +from typing import Any from .. import constants as C +from .._host import HostOps from ..exceptions import HackRFDeviceError, HackRFValueError -class DeviceMixin: +class DeviceMixin(HostOps): # ---- clock ---- - def clock(self, *args, print_cmd=False): + def clock(self, *args: Any, print_cmd: bool = False) -> Any: argv = ["clock"] + list(args) return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd) # ---- operacake antenna switch ---- - def operacake(self, *args, print_cmd=False): + def operacake(self, *args: Any, print_cmd: bool = False) -> Any: argv = ["operacake"] + list(args) return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd) - def operacake_list(self): + def operacake_list(self) -> Any: return self.operacake("-l") # ---- cpld jtag (CPLD firmware) ---- - def cpldjtag(self, firmware, *, confirm=False, print_cmd=False): + def cpldjtag(self, firmware: str, *, confirm: bool = False, + print_cmd: bool = False) -> Any: if not confirm and not print_cmd: raise HackRFValueError( "cpldjtag flashes the CPLD and can brick the board. " @@ -43,7 +48,8 @@ def cpldjtag(self, firmware, *, confirm=False, print_cmd=False): text=True, print_cmd=print_cmd) # ---- spiflash (firmware) ---- - def spiflash_write(self, firmware, *, confirm=False, print_cmd=False): + def spiflash_write(self, firmware: str, *, confirm: bool = False, + print_cmd: bool = False) -> Any: # Writing firmware. The single most dangerous operation in the library. if not confirm and not print_cmd: raise HackRFValueError( @@ -53,18 +59,19 @@ def spiflash_write(self, firmware, *, confirm=False, print_cmd=False): return self._run(["spiflash", "-w", firmware], mode="blocking", text=True, print_cmd=print_cmd) - def spiflash_read(self, out, length=None, print_cmd=False): - argv = ["spiflash", "-r", out] + def spiflash_read(self, out: str, length: int | None = None, + print_cmd: bool = False) -> Any: + 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=False): + def spiflash_reset(self, print_cmd: bool = False) -> Any: return self._run(["spiflash", "-R"], mode="blocking", text=True, print_cmd=print_cmd) # ---- debug register access ---- - def debug(self, *args, print_cmd=False): + def debug(self, *args: Any, print_cmd: bool = False) -> Any: argv = ["debug"] + list(args) return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd) @@ -72,11 +79,11 @@ def debug(self, *args, print_cmd=False): # preflight: environment / readiness check # (was 'doctor' -- kept as an alias below for the familiar CLI verb) # ================================================================= - def preflight(self, capture_path="."): + def preflight(self, capture_path: str = ".") -> dict[str, Any]: # 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. - report = {"tools": {}, "boards": [], "mode": self.mode, + report: dict[str, Any] = {"tools": {}, "boards": [], "mode": self.mode, "tool_version": None, "disk_free_bytes": None, "features": {}, "problems": []} @@ -133,10 +140,11 @@ def preflight(self, capture_path="."): # 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="."): + def doctor(self, capture_path: str = ".") -> dict[str, Any]: return self.preflight(capture_path=capture_path) - def features(self, tool_version=None, firmware_version=None): + def features(self, tool_version: str | None = None, + firmware_version: str | None = None) -> dict[str, Any]: # 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 @@ -157,7 +165,7 @@ def features(self, tool_version=None, firmware_version=None): # Prefer the firmware version's year; fall back to the tools version. # A "git-" build is a from-source build, which is current by # definition -- treat it as modern rather than merely "unknown". - def _year(v): + def _year(v: str | None) -> int | None: m = re.match(r"(\d{4})", v or "") return int(m.group(1)) if m else None @@ -177,7 +185,7 @@ def _year(v): "bias_tee": is_git or year is None or year >= 2018, # -p } - def _print_preflight(self, r): + def _print_preflight(self, r: dict[str, Any]) -> None: print("hackrfpy preflight") print("-" * 40) for name, path in r["tools"].items(): diff --git a/hackrfpy/src/hackrfpy/_commands/info.py b/hackrfpy/src/hackrfpy/_commands/info.py index fea6209..3c08d5a 100644 --- a/hackrfpy/src/hackrfpy/_commands/info.py +++ b/hackrfpy/src/hackrfpy/_commands/info.py @@ -11,11 +11,17 @@ # Author(s): ##--------------------------------------------------------------------\ +from __future__ import annotations + import re +from typing import Any + +from .._host import HostOps -class InfoMixin: - def info(self, raw=False, print_cmd=False): +class InfoMixin(HostOps): + def info(self, raw: bool = False, + print_cmd: bool = False) -> dict[str, Any] | str | None: # Returns a parsed dict by default, or the raw text if raw=True. if print_cmd: self._run(["info"], mode="blocking", print_cmd=True) @@ -26,11 +32,11 @@ def info(self, raw=False, print_cmd=False): return out return self.parse_info(out) - def get_info(self): + def get_info(self) -> dict[str, Any] | str | None: # alias return self.info() - def detect(self): + def detect(self) -> dict[str, Any]: # 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 @@ -52,7 +58,7 @@ def detect(self): # "problem": str|None, # } from ..exceptions import HackRFDeviceError - result = {"found": False, "ready": False, "count": 0, "boards": [], + result: dict[str, Any] = {"found": False, "ready": False, "count": 0, "boards": [], "tools_version": None, "libhackrf_version": None, "multiple": False, "warnings": [], "problem": None} try: @@ -101,7 +107,7 @@ def detect(self): result["problem"] = "no HackRF board detected (check USB / drivers)" return result - def identify(self, serial=None): + def identify(self, serial: str | None = None) -> dict[str, Any] | None: # 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 @@ -117,7 +123,7 @@ def identify(self, serial=None): return None @staticmethod - def parse_info(text): + def parse_info(text: str) -> dict[str, Any]: # 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 @@ -132,10 +138,10 @@ def parse_info(text): # other devices on the same USB bus. You may have problems at high # sample rates.") are collected under result["warnings"] instead of # being silently dropped. - result = {"library": {}, "boards": [], "warnings": []} - current = None - last_target = None # dict the last key was written to - last_key = None # the last key, for continuation lines + result: dict[str, Any] = {"library": {}, "boards": [], "warnings": []} + current: dict[str, Any] | None = None + last_target: dict[str, Any] | None = None # dict the last key was written to + last_key: str | None = None # last key, for continuation lines for raw in text.splitlines(): line = raw.strip() if not line: diff --git a/hackrfpy/src/hackrfpy/_commands/sweep.py b/hackrfpy/src/hackrfpy/_commands/sweep.py index 9d6ad5c..37c5c9b 100644 --- a/hackrfpy/src/hackrfpy/_commands/sweep.py +++ b/hackrfpy/src/hackrfpy/_commands/sweep.py @@ -13,12 +13,22 @@ # Author(s): ##--------------------------------------------------------------------\ +from __future__ import annotations + +from typing import Any, Callable, Generator + from .. import constants as C +from .._host import HostOps +from ..exceptions import HackRFValueError -class SweepMixin: - def sweep(self, f_min_hz, f_max_hz, *, bin_width=None, lna=16, vga=20, - amp=False, one_shot=False, num_sweeps=None, print_cmd=False): +class SweepMixin(HostOps): + def sweep(self, f_min_hz: float, f_max_hz: float, *, + bin_width: float | None = None, lna: int = 16, vga: int = 20, + amp: bool = False, one_shot: bool = False, + num_sweeps: int | None = None, + print_cmd: bool = False + ) -> Generator[dict[str, Any], None, None] | None: # 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. @@ -55,9 +65,9 @@ def sweep(self, f_min_hz, f_max_hz, *, bin_width=None, lna=16, vga=20, if print_cmd: self._run(argv, mode="blocking", print_cmd=True) - return + return None - def _gen(): + def _gen() -> Generator[dict[str, Any], None, None]: # Explicitly close the inner stream generator on the way out. # Relying on GC to do it is not deterministic, and an unclosed # inner generator leaves hackrf_sweep running ORPHANED after the @@ -79,13 +89,21 @@ def _gen(): inner.close() return _gen() - def sweep_collect(self, f_min_hz, f_max_hz, num_sweeps=1, **k): + def sweep_collect(self, f_min_hz: float, f_max_hz: float, + num_sweeps: int = 1, **k: Any) -> list[dict[str, Any]]: # Convenience: collect a bounded number of sweeps into a list. k.pop("num_sweeps", None) - return list(self.sweep(f_min_hz, f_max_hz, num_sweeps=num_sweeps, **k)) + rows = self.sweep(f_min_hz, f_max_hz, num_sweeps=num_sweeps, **k) + if rows is None: # print_cmd=True -> dry run, nothing collected + return [] + return list(rows) - def monitor_frequencies(self, freqs_hz, *, span_hz=2_000_000, duration=None, - on_update=None, lna=16, vga=20, amp=False): + def monitor_frequencies(self, freqs_hz: list[float], *, + span_hz: float = 2_000_000, + duration: float | None = None, + on_update: Callable[..., Any] | None = None, + lna: int = 16, vga: int = 20, + amp: bool = False) -> Any: # 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(): @@ -105,7 +123,7 @@ def monitor_frequencies(self, freqs_hz, *, span_hz=2_000_000, duration=None, hi = max(freqs_hz) + span_hz t0 = _time.time() - def _nearest_power(rows_by_low, f): + 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) for low in sorted(rows_by_low): @@ -118,7 +136,9 @@ def _nearest_power(rows_by_low, f): from .._stream_ctx import StreamCtx results = [] stopped = False - with StreamCtx(self.sweep(lo, hi, lna=lna, vga=vga, amp=amp)) as gen: + _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 for row in gen: @@ -145,10 +165,12 @@ def _nearest_power(rows_by_low, f): results.append(update) return None if on_update is not None else results - def sweep_to_file(self, f_min_hz, f_max_hz, out, *, binary=False, - inverse_fft=False, bin_width=None, lna=16, vga=20, - amp=False, one_shot=False, num_sweeps=None, - print_cmd=False): + def sweep_to_file(self, f_min_hz: float, f_max_hz: float, out: str, *, + binary: bool = False, inverse_fft: bool = False, + bin_width: float | None = None, lna: int = 16, + vga: int = 20, amp: bool = False, one_shot: bool = False, + num_sweeps: int | None = None, + print_cmd: bool = False) -> str | None: # 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: @@ -183,7 +205,7 @@ def sweep_to_file(self, f_min_hz, f_max_hz, out, *, binary=False, self._run(argv, mode="blocking", print_cmd=print_cmd) return None if print_cmd else out - def sweep_stream(self, f_min_hz, f_max_hz, **k): + def sweep_stream(self, f_min_hz: float, f_max_hz: float, **k: Any) -> Any: # 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 @@ -193,10 +215,14 @@ def sweep_stream(self, f_min_hz, f_max_hz, **k): # for row in rows: ... from .._stream_ctx import StreamCtx gen = self.sweep(f_min_hz, f_max_hz, **k) + if gen is None: # print_cmd=True -> nothing to stream + raise HackRFValueError( + "sweep_stream() has no stream to yield with print_cmd=True; " + "call sweep(..., print_cmd=True) for a dry-run preview.") return StreamCtx(gen) @staticmethod - def parse_sweep_line(line): + def parse_sweep_line(line: str) -> dict[str, Any] | None: # 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 20101d2..0eb83a4 100644 --- a/hackrfpy/src/hackrfpy/_commands/transmit.py +++ b/hackrfpy/src/hackrfpy/_commands/transmit.py @@ -14,17 +14,23 @@ # Author(s): ##--------------------------------------------------------------------\ +from __future__ import annotations + import os +from typing import Any from .. import constants as C +from .._host import HostOps from ..exceptions import HackRFEnvironmentError -class TransmitMixin: - def transmit(self, freq, sample_rate, source, *, txvga=20, amp=False, - bias_tee=False, baseband_bw=None, repeat=False, - num_samples=None, duration=None, max_duration=None, - print_cmd=False): +class TransmitMixin(HostOps): + def transmit(self, freq: float, sample_rate: float, source: str, *, + txvga: int = 20, amp: bool = False, bias_tee: bool = False, + baseband_bw: float | None = None, repeat: bool = False, + num_samples: int | None = None, duration: float | None = None, + max_duration: float | None = None, + print_cmd: bool = False) -> Any: # 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 @@ -73,15 +79,19 @@ def transmit(self, freq, sample_rate, source, *, txvga=20, amp=False, return self._run(argv, mode="handle", kind="tx") # ---- aliases ---- - def tx(self, *a, **k): + def tx(self, *a: Any, **k: Any) -> Any: return self.transmit(*a, **k) - def transmit_file(self, freq, sample_rate, source, **k): + def transmit_file(self, freq: float, sample_rate: float, source: str, + **k: Any) -> Any: return self.transmit(freq, sample_rate, source, **k) - def transmit_cw(self, freq, sample_rate, *, amplitude=127, txvga=20, - amp=False, bias_tee=False, baseband_bw=None, - duration=None, max_duration=None, print_cmd=False): + def transmit_cw(self, freq: float, sample_rate: float, *, + amplitude: int = 127, txvga: int = 20, amp: bool = False, + bias_tee: bool = False, baseband_bw: float | None = None, + duration: float | None = None, + max_duration: float | None = None, + print_cmd: bool = False) -> Any: # 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/_host.py b/hackrfpy/src/hackrfpy/_host.py new file mode 100644 index 0000000..78a356f --- /dev/null +++ b/hackrfpy/src/hackrfpy/_host.py @@ -0,0 +1,80 @@ +#! /usr/bin/python3 + +##--------------------------------------------------------------------\ +# hackrfpy +# 'src/hackrfpy/_host.py' +# +# The contract between the HackRF class and the command mixins under +# ./_commands/. Each mixin calls methods on `self` that live on the HOST +# class (core.py), not on the mixin -- _run, validate_rx, warn, and so on. +# Until now that contract existed only as a comment at the top of each +# mixin; HostOps makes it explicit and type-checkable. +# +# Mixins inherit HostOps, so a type checker knows what `self` provides. At +# runtime a Protocol subclass is an ordinary class (no abstract enforcement, +# no metaclass conflict), so the HackRF(InfoMixin, CaptureMixin, ...) +# composition and its MRO are unchanged. +# +# If a mixin starts calling a new host method, add it here -- that is the +# point: the dependency becomes visible instead of implicit. +# +# +# Author(s): Lauren Linkous +# Last Update: July 11, 2026 +##--------------------------------------------------------------------\ + +from __future__ import annotations + +from typing import Any, Protocol + +import numpy as np + + +class HostOps(Protocol): + # ---- shared state ------------------------------------------------------ + mode: str + serial: str | None + tools_dir: str | None + verboseEnabled: bool + allow_out_of_spec: bool + last_params: dict[str, Any] | None + + # ---- the single device-I/O choke point --------------------------------- + def _run(self, argv: list[Any], *, mode: str = ..., duration: float | None = ..., + text: bool = ..., check: bool = ..., print_cmd: bool = ..., + kind: str = ..., read_samples: int = ...) -> Any: ... + + def resolve(self, key: str) -> str: ... + + # ---- diagnostics ------------------------------------------------------- + def warn(self, msg: str) -> None: ... + def print_message(self, msg: str) -> None: ... + + # ---- operating mode ---------------------------------------------------- + def require_mode(self, needed: str) -> None: ... + + # ---- validation / envelope --------------------------------------------- + def validate_rx(self, freq: float, sample_rate: float, lna: int, + vga: int) -> tuple[float, float, int, int]: ... + def validate_tx(self, freq: float, sample_rate: float, txvga: int, + amp: bool) -> tuple[float, float, int, bool]: ... + def _check_hard_range(self, name: str, value: float, lo: float, hi: float, + warn_below: float | None = ...) -> float: ... + def _snap_gain(self, name: str, value: int, + table: tuple[int, int, int]) -> int: ... + def _auto_baseband(self, sample_rate: float, + explicit: float | None = ...) -> float: ... + def _record_params(self, **params: Any) -> dict[str, Any]: ... + + # ---- cross-mixin ------------------------------------------------------- + # DeviceMixin.preflight()/features() parse hackrf_info output using + # InfoMixin.parse_info. Declaring it here makes that sibling dependency + # explicit rather than an implicit assumption about composition order. + @staticmethod + def parse_info(text: str) -> dict[str, Any]: ... + + # ---- data -------------------------------------------------------------- + def decode_iq(self, raw: bytes) -> np.ndarray: ... + def estimate_capture(self, sample_rate: float, num_samples: int | None = ..., + duration: float | None = ..., + path: str = ...) -> dict[str, Any]: ... diff --git a/hackrfpy/src/hackrfpy/_receiver.py b/hackrfpy/src/hackrfpy/_receiver.py index 6d996b2..ce8d2b4 100644 --- a/hackrfpy/src/hackrfpy/_receiver.py +++ b/hackrfpy/src/hackrfpy/_receiver.py @@ -21,6 +21,17 @@ +from __future__ import annotations + +from types import TracebackType +from typing import Any, Callable, Generator, Iterator, Literal + +import numpy as np + +from ._host import HostOps +from .exceptions import HackRFDeviceError + + class PersistentReceiver: # A long-lived RX stream at one frequency. Created via # HackRF.open_receiver(...); use as a context manager so the child is @@ -34,8 +45,10 @@ class PersistentReceiver: # # The underlying hackrf_transfer is launched once on __enter__ (or first # use) and runs continuously until close()/__exit__. - def __init__(self, hackrf, freq, sample_rate, *, lna=16, vga=20, amp=False, - baseband_bw=None, read_samples=131072): + def __init__(self, hackrf: HostOps, freq: float, sample_rate: float, *, + lna: int = 16, vga: int = 20, amp: bool = False, + baseband_bw: float | None = None, + read_samples: int = 131072) -> None: self._h = hackrf self.freq = freq self.sample_rate = sample_rate @@ -44,15 +57,19 @@ def __init__(self, hackrf, freq, sample_rate, *, lna=16, vga=20, amp=False, self.amp = amp self.baseband_bw = baseband_bw self.read_samples = read_samples - self._gen = None # the raw-bytes stream generator + self._gen: Generator[bytes, None, None] | None = None # raw-bytes stream self._carry = b"" # leftover bytes between reads (odd splits) self._closed = False self.total_samples = 0 # cumulative samples served # ---- lifecycle --------------------------------------------------------- - def _ensure_open(self): - if self._gen is not None or self._closed: - return + def _ensure_open(self) -> Generator[bytes, None, None]: + if self._closed: + raise HackRFDeviceError( + "receiver is closed; construct a new PersistentReceiver " + "(via HackRF.open_receiver) to start a new stream.") + if self._gen is not None: + return self._gen h = self._h # validate + snap exactly like capture() does, and record params so # relative_power_db() etc. can read the gains back. validate_rx returns @@ -75,16 +92,19 @@ def _ensure_open(self): _LIVE.add(self) except Exception: pass + return self._gen - def __enter__(self): + def __enter__(self) -> PersistentReceiver: self._ensure_open() return self - def __exit__(self, exc_type, exc, tb): + def __exit__(self, exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None) -> Literal[False]: self.close() return False - def close(self): + def close(self) -> None: if self._closed: return self._closed = True @@ -100,21 +120,28 @@ def close(self): except Exception: pass - def stop(self): + def stop(self) -> None: # alias so PersistentReceiver works with the _LIVE backstop, which # calls .stop() on whatever it holds self.close() + def is_alive(self) -> bool: + # 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 + # receiver was silently never reaped on interpreter shutdown. + return self._gen is not None and not self._closed + # ---- data access ------------------------------------------------------- - def read(self, n_samples): + def read(self, n_samples: int) -> np.ndarray: # 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 # process is launched -- this is the whole point. - self._ensure_open() + gen = self._ensure_open() need_bytes = int(n_samples) * 2 # 2 int8 per complex sample buf = self._carry - for chunk in self._gen: + for chunk in gen: buf += chunk if len(buf) >= need_bytes: break @@ -126,23 +153,24 @@ def read(self, n_samples): self.total_samples += len(iq) return iq - def blocks(self): + def blocks(self) -> Iterator[np.ndarray]: # 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. - self._ensure_open() + gen = self._ensure_open() if self._carry: lead = self._h.decode_iq(self._carry) self._carry = b"" if len(lead): self.total_samples += len(lead) yield lead - for chunk in self._gen: + for chunk in gen: iq = self._h.decode_iq(chunk) self.total_samples += len(iq) yield iq - def callback(self, on_block, *, max_samples=None): + def callback(self, on_block: Callable[[np.ndarray, int], Any], *, + max_samples: int | None = None) -> int: # 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/_stream_ctx.py b/hackrfpy/src/hackrfpy/_stream_ctx.py index 5bc2309..3629667 100644 --- a/hackrfpy/src/hackrfpy/_stream_ctx.py +++ b/hackrfpy/src/hackrfpy/_stream_ctx.py @@ -12,17 +12,25 @@ ##--------------------------------------------------------------------\ +from __future__ import annotations + +from types import TracebackType +from typing import Any, Generator, Literal + + class StreamCtx: # Wrap a live generator so its finally-block (which interrupts + reaps the # hackrf_* child) runs on __exit__, even if the body raised. Iterable, so # `for x in rows:` works directly on the bound name. - def __init__(self, gen): + def __init__(self, gen: Generator[Any, None, None]) -> None: self._gen = gen - def __enter__(self): + def __enter__(self) -> Generator[Any, None, None]: return self._gen - def __exit__(self, exc_type, exc, tb): + def __exit__(self, exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None) -> Literal[False]: if self._gen is not None: self._gen.close() return False # never suppress the caller's exception diff --git a/hackrfpy/src/hackrfpy/cli.py b/hackrfpy/src/hackrfpy/cli.py index 2485596..7040b58 100644 --- a/hackrfpy/src/hackrfpy/cli.py +++ b/hackrfpy/src/hackrfpy/cli.py @@ -18,7 +18,10 @@ # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ +from __future__ import annotations + import argparse +from typing import Any, Sequence import os import sys from importlib.metadata import PackageNotFoundError, version as _pkg_version @@ -39,7 +42,7 @@ # ---- mode state file (mode persists across CLI invocations) ------------------ -def _print_detect(det): +def _print_detect(det: dict[str, Any]) -> None: print("hackrfpy detect") print("-" * 40) if det["problem"] and not det["found"]: @@ -67,13 +70,13 @@ def _print_detect(det): print(f" note : {det['problem']}") -def _state_path(): +def _state_path() -> str: base = os.environ.get("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")) return os.path.join(base, C.STATE_DIR_NAME, C.STATE_FILE_NAME) -def read_mode(): +def read_mode() -> str: path = _state_path() try: with open(path) as f: @@ -85,7 +88,7 @@ def read_mode(): return C.DEFAULT_MODE -def write_mode(mode): +def write_mode(mode: str) -> None: path = _state_path() os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: @@ -93,7 +96,8 @@ def write_mode(mode): # Subcommands whose only job is to relay to a device method with **flags. -def _add_common_rf(p, freq_required=True): +def _add_common_rf(p: argparse.ArgumentParser, + freq_required: bool = True) -> None: p.add_argument("-f", "--frequency", type=parse_freq, required=freq_required, help="center frequency (e.g. 433.92M, 1.09G, or Hz)") p.add_argument("-s", "--sample-rate", dest="sample_rate", type=parse_freq, @@ -108,7 +112,7 @@ def _add_common_rf(p, freq_required=True): class HackRFCLI: - def __init__(self, a=None): + def __init__(self, a: Sequence[str] | None = None) -> None: main_parser = argparse.ArgumentParser(prog="hrf", description="hackrfpy") main_parser.add_argument("--version", action="version", version=f"%(prog)s {_VERSION}") @@ -186,10 +190,10 @@ def __init__(self, a=None): self.args = main_parser.parse_args(a) self._parser = main_parser - def getArgs(self): + def getArgs(self) -> argparse.Namespace: return self.args - def _make_device(self, args): + def _make_device(self, args: argparse.Namespace) -> HackRF: h = HackRF(verbose=getattr(args, "verbose", False), serial=getattr(args, "serial", None)) h.allow_out_of_spec = getattr(args, "force", False) @@ -197,7 +201,7 @@ def _make_device(self, args): h.restore_mode(read_mode()) return h - def main(self, args): + def main(self, args: argparse.Namespace) -> None: name = args.subparser_name if name is None: self._parser.print_help() @@ -278,7 +282,7 @@ def main(self, args): + [f"{d:.2f}" for d in row["db"]])) -def main(): +def main() -> None: app = HackRFCLI() try: app.main(app.getArgs()) diff --git a/hackrfpy/src/hackrfpy/core.py b/hackrfpy/src/hackrfpy/core.py index 91e3697..4787274 100644 --- a/hackrfpy/src/hackrfpy/core.py +++ b/hackrfpy/src/hackrfpy/core.py @@ -19,6 +19,8 @@ # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ +from __future__ import annotations + import atexit import logging import os @@ -28,6 +30,8 @@ import sys import threading import weakref +from types import TracebackType +from typing import Any, Callable, Generator, Literal, Protocol import numpy as np @@ -58,7 +62,7 @@ log = logging.getLogger("hackrfpy") -def _ensure_console_logging(): +def _ensure_console_logging() -> None: # The last-resort handler only emits WARNING and above, so verbose=True in a # plain script would otherwise print nothing at INFO. Attach a stderr handler # -- but ONLY if nobody has configured logging (neither our logger nor the @@ -75,7 +79,7 @@ def _ensure_console_logging(): # 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. -if os.name == "nt": # pragma: no cover +if sys.platform == "win32": # pragma: no cover _CREATION_FLAGS = subprocess.CREATE_NEW_PROCESS_GROUP _INTERRUPT_SIGNAL = signal.CTRL_BREAK_EVENT else: @@ -83,7 +87,7 @@ def _ensure_console_logging(): _INTERRUPT_SIGNAL = signal.SIGINT -def _interrupt(proc): +def _interrupt(proc: subprocess.Popen[Any]) -> None: proc.send_signal(_INTERRUPT_SIGNAL) @@ -103,15 +107,23 @@ def _interrupt(proc): # 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). -_LIVE = weakref.WeakSet() +class _Stoppable(Protocol): + # What the atexit backstop actually needs. _Process and PersistentReceiver + # both satisfy it; a Protocol keeps _LIVE honest about holding both without + # forcing an inheritance relationship between them. + def is_alive(self) -> bool: ... + def stop(self) -> Any: ... + + +_LIVE: weakref.WeakSet[_Stoppable] = weakref.WeakSet() -def _register_live(proc_handle): +def _register_live(proc_handle: _Stoppable) -> None: _LIVE.add(proc_handle) @atexit.register -def _stop_all_live(): +def _stop_all_live() -> None: for h in list(_LIVE): try: if h.is_alive(): @@ -137,13 +149,14 @@ class _Process: # prints a stats line every second; with an undrained PIPE the 64 KB buffer # eventually fills and the child blocks on write, silently stalling a # long-running capture. Draining as we go removes that failure mode. - def __init__(self, proc, owner, kind="rx"): + def __init__(self, proc: subprocess.Popen[Any], owner: HackRF, + kind: str = "rx") -> None: self._proc = proc self._owner = owner self._kind = kind # "rx" | "tx" -- for atexit messaging self._stopped = False - self._out_chunks = [] - self._err_chunks = [] + self._out_chunks: list[bytes] = [] + self._err_chunks: list[bytes] = [] self._threads = [] for stream, sink in ((proc.stdout, self._out_chunks), (proc.stderr, self._err_chunks)): @@ -155,7 +168,7 @@ def __init__(self, proc, owner, kind="rx"): self._threads.append(t) @staticmethod - def _drain(stream, sink): + def _drain(stream: Any, sink: list[bytes]) -> None: try: for chunk in iter(lambda: stream.read(65536), b""): sink.append(chunk) @@ -167,10 +180,10 @@ def _drain(stream, sink): except (OSError, ValueError): pass # pipe closed under us during shutdown - def is_alive(self): + def is_alive(self) -> bool: return self._proc.poll() is None - def stop(self, grace=2.0): + def stop(self, grace: float = 2.0) -> tuple[bytes, bytes, int | None]: # SIGINT lets hackrf_transfer flush + close the file cleanly so the # IQ stream isn't truncated mid-sample-pair. if self._proc.poll() is None: @@ -183,12 +196,12 @@ def stop(self, grace=2.0): _LIVE.discard(self) # no longer needs the atexit backstop return self.result() - def wait(self): + def wait(self) -> tuple[bytes, bytes, int | None]: self._proc.wait() _LIVE.discard(self) return self.result() - def result(self): + def result(self) -> tuple[bytes, bytes, int | None]: for t in self._threads: t.join(timeout=2.0) out = b"".join(self._out_chunks) @@ -196,17 +209,20 @@ def result(self): return out, err, self._proc.returncode # ---- context manager (Tier A): guaranteed reap on block exit ----------- - def __enter__(self): + def __enter__(self) -> _Process: return self - def __exit__(self, exc_type, exc, tb): + def __exit__(self, exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None) -> Literal[False]: if not self._stopped: self.stop() return False # never suppress the caller's exception class HackRF(InfoMixin, CaptureMixin, TransmitMixin, SweepMixin, DeviceMixin): - def __init__(self, tools_dir=None, verbose=False, serial=None): + def __init__(self, tools_dir: str | None = None, verbose: bool = False, + serial: str | None = None) -> None: # ---- feedback ---- # via set_verbose so that HackRF(verbose=True) wires up the console # handler exactly like an explicit set_verbose(True) call would. @@ -245,9 +261,10 @@ def __init__(self, tools_dir=None, verbose=False, serial=None): # after snapping / auto-derivation, so a script can see what really # happened (e.g. a requested lna=30 that snapped to 24). None until the # first rx/tx/sweep call on this instance. - self.last_params = None + self.last_params: dict[str, Any] | None = None + self._probed: dict[str, Any] | None = None # set by from_device() - def _record_params(self, **params): + def _record_params(self, **params: Any) -> dict[str, Any]: # Single place the mixins call to publish post-snap values. Returns the # dict so callers can also use it inline if they want. self.last_params = params @@ -256,7 +273,7 @@ def _record_params(self, **params): # ================================================================= # Feedback # ================================================================= - def set_verbose(self, verbose=True): + def set_verbose(self, verbose: bool = True) -> None: self.verboseEnabled = verbose if verbose: # Make INFO actually visible in a plain script (see @@ -265,16 +282,16 @@ def set_verbose(self, verbose=True): if log.getEffectiveLevel() > logging.INFO: log.setLevel(logging.INFO) - def get_verbose(self): + def get_verbose(self) -> bool: return self.verboseEnabled - def print_message(self, msg): + def print_message(self, msg: str) -> None: # 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): + def warn(self, msg: str) -> None: # 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 @@ -286,17 +303,17 @@ def warn(self, msg): # Operating mode machine # ================================================================= @property - def mode(self): + def mode(self) -> str: return self._mode @mode.setter - def mode(self, value): + def mode(self, value: str) -> None: self.set_mode(value) - def get_mode(self): + def get_mode(self) -> str: return self._mode - def set_mode(self, value): + def set_mode(self, value: str) -> str: value = str(value).lower() if value not in C.MODES: raise HackRFValueError( @@ -308,7 +325,7 @@ def set_mode(self, value): self.print_message(f"[*] mode -> {value}") return self._mode - def restore_mode(self, value): + def restore_mode(self, value: str) -> str: # 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`. @@ -319,14 +336,14 @@ def restore_mode(self, value): self._mode = value return self._mode - def require_mode(self, needed): + def require_mode(self, needed: str) -> None: if self._mode != needed: raise HackRFModeError( f"operation requires '{needed}' mode but device is in " f"'{self._mode}' mode. Switch first (set_mode('{needed}') or " f"`hrf mode {needed}`).") - def _tx_safety_banner(self): + def _tx_safety_banner(self) -> None: # Always printed (not gated on verbose): switching to TX is a moment # that deserves a clear, one-time notice regardless of verbosity. print("=" * 64) @@ -339,7 +356,8 @@ def _tx_safety_banner(self): # ================================================================= # Validation layer # ================================================================= - def _check_hard_range(self, name, value, lo, hi, warn_below=None): + def _check_hard_range(self, name: str, value: float, lo: float, hi: float, + warn_below: float | None = None) -> float: # Reject-by-default outside [lo, hi]; --force downgrades to a warning. # warn_below: a soft floor that only ever warns (e.g. low sample rate). value = float(value) @@ -357,7 +375,8 @@ def _check_hard_range(self, name, value, lo, hi, warn_below=None): f"{warn_below:g}; results may be degraded.") return value - def _snap_gain(self, name, value, table): + def _snap_gain(self, name: str, value: int, + table: tuple[int, int, int]) -> int: # Round DOWN to the device's real step and notify. This is the honest # "what you'll actually get" value, matching the silicon. A request # that snaps to a DIFFERENT value is surfaced via warn() (not just @@ -375,7 +394,7 @@ def _snap_gain(self, name, value, table): f"(snapped to device step <= request, range [{lo},{hi}]/{step}dB)") return snapped - def _snap_baseband(self, value): + def _snap_baseband(self, value: float) -> float: # Snap an explicit baseband BW to the nearest supported value. opts = C.BASEBAND_FILTER_BW_HZ nearest = min(opts, key=lambda o: abs(o - value)) @@ -385,7 +404,8 @@ def _snap_baseband(self, value): f"(nearest supported)") return nearest - def _auto_baseband(self, sample_rate, explicit=None): + def _auto_baseband(self, sample_rate: float, + explicit: float | None = None) -> float: # If unset, derive ~0.75x sample rate snapped to a supported BW. If set # explicitly, snap it and warn when it exceeds the sample rate (a # likely mistake -- you can't filter wider than you sample). @@ -399,7 +419,8 @@ def _auto_baseband(self, sample_rate, explicit=None): f"{sample_rate/1e6:g} Msps.") return bw - def validate_rx(self, freq, sample_rate, lna, vga): + def validate_rx(self, freq: float, sample_rate: float, lna: int, + vga: int) -> tuple[float, float, int, int]: 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, @@ -408,7 +429,8 @@ def validate_rx(self, freq, sample_rate, lna, vga): vga = self._snap_gain("vga_gain", vga, C.VGA_GAIN) return freq, sample_rate, lna, vga - def validate_tx(self, freq, sample_rate, txvga, amp): + def validate_tx(self, freq: float, sample_rate: float, txvga: int, + amp: bool) -> tuple[float, float, int, bool]: # 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. @@ -431,7 +453,7 @@ def validate_tx(self, freq, sample_rate, txvga, amp): # ================================================================= # Binary resolution # ================================================================= - def resolve(self, key): + def resolve(self, key: str) -> str: # key is a TOOLS key ("transfer", "info", ...). Returns the full path # or raises HackRFDeviceError with an actionable message. name = C.TOOLS[key] @@ -461,7 +483,8 @@ def resolve(self, key): # Capability probe # ================================================================= @classmethod - def from_device(cls, *, tools_dir=None, verbose=False, serial=None): + 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 so callers # get a device whose reported firmware is known up front. Unlike the # bare constructor (which touches nothing), this RUNS hackrf_info, so @@ -469,13 +492,14 @@ def from_device(cls, *, tools_dir=None, verbose=False, serial=None): # 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 info.get("boards"): raise HackRFDeviceError("no HackRF board detected") h._probed = info h._warn_if_firmware_stale(info) return h - def _warn_if_firmware_stale(self, info): + def _warn_if_firmware_stale(self, info: dict[str, Any]) -> None: # The subprocess approach is firmware-version sensitive: '-r -' stdout # streaming and 'sweep -N' need reasonably modern tools. Surface a # warning (not a raise) so old boards still work for what they can do. @@ -491,9 +515,10 @@ def _warn_if_firmware_stale(self, info): # ================================================================= # The device-I/O choke point # ================================================================= - def _run(self, argv, *, mode="blocking", duration=None, - text=False, check=True, print_cmd=False, kind="rx", - read_samples=65536): + def _run(self, argv: list[Any], *, mode: str = "blocking", + duration: float | None = None, text: bool = False, + check: bool = True, print_cmd: bool = False, kind: str = "rx", + read_samples: int = 65536) -> Any: # argv: full command list whose [0] is a TOOLS key, e.g. # ["transfer", "-r", "out.iq", ...]; resolved to a real path here. # @@ -563,7 +588,8 @@ def _run(self, argv, *, mode="blocking", duration=None, return out, err, rc @staticmethod - def _sigint_and_reap(proc, grace=5.0): + def _sigint_and_reap(proc: subprocess.Popen[Any], + grace: float = 5.0) -> tuple[Any, Any]: # SIGINT (clean flush/close in hackrf_*), bounded wait, escalate. _interrupt(proc) try: @@ -572,7 +598,8 @@ def _sigint_and_reap(proc, grace=5.0): proc.terminate() return proc.communicate() - def _stream(self, resolved, text=False, read_samples=65536): + def _stream(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. @@ -590,10 +617,14 @@ def _stream(self, resolved, text=False, read_samples=65536): proc = subprocess.Popen(resolved, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, creationflags=_CREATION_FLAGS) - err_tail = [] + # PIPE is always requested above, so these are never None. Typed Any + # because the concrete class depends on `text`: BufferedReader in binary + # mode (which is where .read1 comes from) vs TextIOWrapper in text mode. + stdout: Any = proc.stdout + err_tail: list[Any] = [] err_cap = _DRAIN_CAP - def _eat(stream, sink): + def _eat(stream: Any, sink: list[Any]) -> None: try: for data in iter(lambda: stream.read(4096), b"" if not text else ""): sink.append(data) @@ -608,7 +639,7 @@ def _eat(stream, sink): broke_out = True try: if text: - for line in proc.stdout: + for line in stdout: yield line else: # read1() returns whatever is available after ONE underlying @@ -619,7 +650,7 @@ def _eat(stream, sink): # until a full 128 KB accumulated. Cap the request so we never # split an I/Q pair across yields. while True: - chunk = proc.stdout.read1(C.BYTES_PER_SAMPLE * read_samples) + chunk = stdout.read1(C.BYTES_PER_SAMPLE * read_samples) if not chunk: break yield chunk @@ -643,7 +674,7 @@ def _eat(stream, sink): # ================================================================= # Shared helpers used by mixins # ================================================================= - def decode_iq(self, raw): + def decode_iq(self, raw: bytes) -> np.ndarray: # HackRF native format -> complex64. Interleaved int8 I,Q,I,Q... # Guard against an odd trailing byte (truncated final pair). # @@ -671,7 +702,7 @@ def decode_iq(self, raw): # examples/calibrate.py. @staticmethod - def power_dbfs(iq): + def power_dbfs(iq: np.ndarray) -> float: # 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. @@ -682,15 +713,19 @@ def power_dbfs(iq): return 10.0 * np.log10(power + 1e-20) @staticmethod - def gain_db(lna=0, vga=0, amp=False): + def gain_db(lna: int = 0, vga: int = 0, amp: bool = False) -> float: # 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 # different between min and max gain. return float(lna) + float(vga) + (C.AMP_DB if amp else 0.0) - def relative_power_db(self, iq_or_dbfs, *, lna=None, vga=None, amp=None, - offset_db=0.0, freq_hz=None, freq_correction=None): + def relative_power_db(self, iq_or_dbfs: np.ndarray | float, *, + lna: int | None = None, vga: int | None = None, + amp: bool | None = None, offset_db: float = 0.0, + freq_hz: float | None = None, + freq_correction: Callable[[float], float] | None = None + ) -> float: # 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. @@ -722,8 +757,10 @@ def relative_power_db(self, iq_or_dbfs, *, lna=None, vga=None, amp=None, value -= float(freq_correction(freq_hz)) return value - def estimate_capture(self, sample_rate, num_samples=None, duration=None, - path="."): + def estimate_capture(self, sample_rate: float, + num_samples: int | None = None, + duration: float | None = None, + path: str = ".") -> dict[str, Any]: # 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 @@ -749,7 +786,7 @@ def estimate_capture(self, sample_rate, num_samples=None, duration=None, # ===================================================================== # Module-level helpers: consume recordings WITHOUT a device / HackRF() # ===================================================================== -def parse_freq(txt): +def parse_freq(txt: str | float | int) -> float: """Parse '433.92M', '88M', '1.09G', '2.5k', '100MHz', or plain Hz -> float Hz. Lives at module scope (not just in the CLI) so library callers can accept diff --git a/hackrfpy/src/hackrfpy/presets.py b/hackrfpy/src/hackrfpy/presets.py index c72ace3..9736050 100644 --- a/hackrfpy/src/hackrfpy/presets.py +++ b/hackrfpy/src/hackrfpy/presets.py @@ -17,15 +17,14 @@ # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ -import os +from __future__ import annotations -try: - import tomllib # Python 3.11+ -except ModuleNotFoundError: # pragma: no cover - tomllib = None +import os +import tomllib # stdlib on 3.11+, which is this package's minimum +from typing import Any # Each preset: center or (f_min, f_max) for sweeps, plus a default sample rate. -BUILTIN_PRESETS = { +BUILTIN_PRESETS: dict[str, dict[str, Any]] = { "fm": {"f_min": 88_000_000, "f_max": 108_000_000, "sample_rate": 10_000_000, "desc": "FM broadcast band"}, "airband": {"f_min": 118_000_000, "f_max": 137_000_000, "sample_rate": 8_000_000, @@ -43,17 +42,17 @@ } -def _config_path(): +def _config_path() -> str: base = os.environ.get("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")) return os.path.join(base, "hackrfpy", "presets.toml") -def load_presets(): +def load_presets() -> dict[str, dict[str, Any]]: # Built-ins overlaid with the user's TOML (user wins on key collisions). presets = {k: dict(v) for k, v in BUILTIN_PRESETS.items()} path = _config_path() - if tomllib and os.path.isfile(path): + if os.path.isfile(path): with open(path, "rb") as f: user = tomllib.load(f) for name, cfg in user.get("presets", {}).items(): @@ -61,7 +60,7 @@ def load_presets(): return presets -def get_preset(name): +def get_preset(name: str) -> dict[str, Any]: presets = load_presets() if name not in presets: from .exceptions import HackRFValueError diff --git a/hackrfpy/src/hackrfpy/sigmf.py b/hackrfpy/src/hackrfpy/sigmf.py index b140a1f..4b694c0 100644 --- a/hackrfpy/src/hackrfpy/sigmf.py +++ b/hackrfpy/src/hackrfpy/sigmf.py @@ -14,13 +14,18 @@ # Last Update: July 11, 2026 ##--------------------------------------------------------------------\ +from __future__ import annotations + import json import os from datetime import datetime, timezone +from typing import Any -def write_sigmf_meta(data_path, freq, sample_rate, *, lna=None, vga=None, - amp=None, datatype="ci8", extra=None): +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: # Sidecar path: foo.iq -> foo.sigmf-meta base, _ = os.path.splitext(data_path) meta_path = base + ".sigmf-meta" @@ -34,7 +39,7 @@ def write_sigmf_meta(data_path, freq, sample_rate, *, lna=None, vga=None, if amp is not None: annotations_gains["hackrf:amp_enabled"] = bool(amp) - meta = { + meta: dict[str, Any] = { "global": { "core:datatype": datatype, "core:sample_rate": float(sample_rate), @@ -68,7 +73,7 @@ def write_sigmf_meta(data_path, freq, sample_rate, *, lna=None, vga=None, return meta_path -def read_sigmf_meta(path: str) -> dict: +def read_sigmf_meta(path: str) -> dict[str, Any]: """Read a .sigmf-meta sidecar back into a dict. Accepts either the meta path itself or the data path (foo.iq is mapped diff --git a/hackrfpy/tests/test_receiver.py b/hackrfpy/tests/test_receiver.py index e1a2361..ccf974c 100644 --- a/hackrfpy/tests/test_receiver.py +++ b/hackrfpy/tests/test_receiver.py @@ -105,3 +105,32 @@ def test_receiver_records_params_for_calibration(stub_device): assert h.last_params["vga"] == 20 # and the calibration helper can read them back assert h.relative_power_db(-6.0) == -6.0 - 36.0 + + +# ---- atexit backstop contract ---------------------------------------------- +def test_receiver_satisfies_the_live_backstop(stub_device): + # REGRESSION: PersistentReceiver registers itself in core._LIVE, whose atexit + # hook does `if h.is_alive(): h.stop()`. is_alive() was missing entirely, so + # the AttributeError was swallowed by the hook's bare `except Exception` and + # an orphaned receiver was NEVER reaped on interpreter shutdown. + from hackrfpy.core import _LIVE, _stop_all_live + h = _streaming_device(stub_device) + recv = h.open_receiver(433.92e6, 8e6) + recv.read(16) # opens the stream + assert recv.is_alive() + assert recv in set(_LIVE) # registered on the backstop + _stop_all_live() # what atexit runs + assert not recv.is_alive(), "backstop did not reap the live receiver" + + +def test_read_after_close_raises_typed_error(stub_device): + # Previously fell through _ensure_open with self._gen None and blew up on + # `for chunk in None` -> raw TypeError. Now a typed, actionable error. + from hackrfpy.exceptions import HackRFDeviceError + import pytest + h = _streaming_device(stub_device) + recv = h.open_receiver(433.92e6, 8e6) + recv.read(16) + recv.close() + with pytest.raises(HackRFDeviceError, match="closed"): + recv.read(16)