hackrfpy 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hackrfpy
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: A self-contained CLI + scriptable wrapper for the HackRF One, built on the hackrf-tools binaries (no libhackrf bindings required).
5
5
  Keywords: hackrf,sdr,rf,spectrum,sigmf
6
6
  Author: LC-Linkous
7
7
  License-Expression: GPL-2.0-or-later
8
8
  License-File: LICENSE
9
- Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Development Status :: 5 - Production/Stable
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)
12
12
  Classifier: Operating System :: Microsoft :: Windows
13
- Classifier: Operating System :: OS Independent
14
13
  Classifier: Programming Language :: Python :: 3.11
15
14
  Classifier: Programming Language :: Python :: 3.12
16
15
  Classifier: Programming Language :: Python :: 3.13
@@ -27,6 +26,7 @@ Description-Content-Type: text/markdown
27
26
 
28
27
  **An Unofficial Python CLI + Scripting Wrapper for the HackRF One that works on Windows**
29
28
 
29
+ [![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)
30
30
  [![PyPI version](https://badge.fury.io/py/hackrfpy.svg)](https://badge.fury.io/py/hackrfpy)
31
31
  [![Python versions](https://img.shields.io/pypi/pyversions/hackrfpy.svg)](https://pypi.org/project/hackrfpy/)
32
32
  [![PyPI - Wheel](https://img.shields.io/pypi/wheel/hackrfpy.svg)](https://pypi.org/project/hackrfpy/)
@@ -43,13 +43,27 @@ This repository uses official resources and documentation but is **NOT** endorse
43
43
 
44
44
  - **Device Discovery** — detect and identify connected HackRF boards, report firmware and identity
45
45
  - **IQ Capture** — bounded, timed, streaming, or callback-style receive; decoded to normalized `complex64`
46
- - **Spectrum Sweep** — collect or stream `hackrf_sweep` output across a frequency range
46
+ - **Spectrum Sweep** — collect or stream `hackrf_sweep` output across a frequency range, plus multi-frequency power monitoring over time
47
47
  - **Transmit** — file playback and constant-wave test mode, behind a deliberate TX-mode gate
48
48
  - **Operating Envelope** — per-parameter range checks and gain snapping against the device's real steps
49
49
  - **SigMF Recordings** — self-describing `.iq` captures with metadata sidecars
50
50
  - **Error Handling** — a typed exception hierarchy and verbose output options
51
+ - **Lifecycle Safety** — clean interrupts on both platforms, an `atexit` backstop, and an OS dead-man (pdeathsig / Job Object) so even a hard-killed script cannot orphan a transmitter
51
52
  - **CLI** — the `hrf` command-line shell over the full API
52
53
 
54
+ ## Platform support
55
+
56
+ hackrfpy is developed and tested on **Windows**, and has also been verified on Debian Linux with real hardware. Running the `hackrf-tools` binaries as subprocesses — rather than binding to `libhackrf` through a C extension — is what makes that portability cheap: there is no compiler or build step on any platform, and each OS runs its own native tools. Process control is handled per-platform (`SIGINT` on POSIX, `CTRL_BREAK` on Windows), with the interrupt, flush, and dead-man paths covered by tests on every OS, no skips.
57
+
58
+ A platform is called **tested** here only after the full suite (hardware tests included) passes against a real board:
59
+
60
+ - **Windows** — the primary development platform, verified continuously against real hardware.
61
+ - **Linux** — verified 2026-09-19 on Debian 12 (`hackrf` 2022.09.1, firmware 2024.02.1): full suite, 227/227. Mechanics additionally exercised against tools 2023.01.1 on Ubuntu, so both packaged tools versions are known-good. Setup notes for Debian-family systems are in the [main repository README](https://github.com/LC-Linkous/hackRF_python#linux-setup-debian-family).
62
+
63
+ **macOS remains experimental**: the mechanics are CI-tested there, but board-attached operation is unverified. If you run it with a board, please [open an issue](https://github.com/LC-Linkous/hackRF_python/issues) — a passing hardware suite is what promotes a platform, and the bar is not a formality: the Linux verification run surfaced and fixed two real process-lifecycle bugs.
64
+
65
+ One caveat that applies everywhere: `tools_dir` must point at binaries built for the OS you are on — the Windows `.EXE` bundle will never run on Linux, and vice versa.
66
+
53
67
  ## Installation
54
68
 
55
69
  ```bash
@@ -64,13 +78,13 @@ pip install "hackrfpy[plotting]"
64
78
 
65
79
  Python 3.11+ is required.
66
80
 
67
- **You also need the `hackrf-tools` binaries**, which are *not* a pip dependency — they are installed at the OS level:
81
+ **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`).
68
82
 
69
- - **Linux:** `sudo apt install hackrf` (or your distribution's equivalent)
70
- - **macOS:** `brew install hackrf`
71
- - **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.
83
+ - **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.
84
+ - **Linux** — *tested.* `sudo apt install hackrf` (or your distribution's equivalent).
85
+ - **macOS** — *experimental, see [Platform support](#platform-support).* `brew install hackrf`.
72
86
 
73
- Verify the install with `hackrf_info`.
87
+ Verify the tools are installed with `hackrf_info`.
74
88
 
75
89
  ## Quick Start
76
90
 
@@ -104,9 +118,20 @@ for r in rows:
104
118
  print(r["hz_low"], r["hz_high"], min(r["db"]), max(r["db"]))
105
119
  ```
106
120
 
121
+ The same operations are available from the shell via the `hrf` entry point:
122
+
123
+ ```bash
124
+ hrf detect # is a board attached and ready?
125
+ hrf rx -f 433.92M -s 8M -n 2000000 -o capture.iq
126
+ hrf sweep --f-min 88M --f-max 108M
127
+ hrf monitor 98.1M 103.7M -d 10 # power over time on several frequencies
128
+ ```
129
+
130
+ Frequencies accept unit suffixes (`433.92M`, `1.09G`), and most commands take `--print-cmd` to show the underlying `hackrf_*` invocation without running it. The full CLI reference is in the repository README.
131
+
107
132
  ## Transmitting
108
133
 
109
- Transmit is gated behind an explicit mode switch, because an accidental transmit is the one operation that can damage equipment or break the law:
134
+ Transmit is gated behind an explicit mode switch, because an accidental transmit is the one operation that can damage equipment (or break the law)
110
135
 
111
136
  ```python
112
137
  from hackrfpy import HackRF
@@ -116,8 +141,18 @@ h.set_mode("tx") # prints the TX-mode safety banner
116
141
  h.transmit(433.92e6, 8e6, "signal.iq", txvga=20)
117
142
  ```
118
143
 
144
+ A bounded constant-wave test tone is available without a source file: `h.transmit_cw(433.92e6, 2e6, duration=2)`, or `hrf tx --cw -f 433.92M -s 2M -d 2` from the CLI (the duration is mandatory there — an unbounded carrier is exactly the risk the gates exist to prevent). Behind the mode gate sit an `atexit` backstop and an OS dead-man, so even a hard-killed script cannot leave a transmitter on the air.
145
+
119
146
  **Transmitting is regulated.** You are responsible for operating within the law and within your equipment's limits.
120
147
 
148
+ ## Thread safety
149
+
150
+ A `HackRF` instance is **not** safe to share across threads: methods mutate
151
+ per-instance state (`last_params`, the persisted operating mode, logging
152
+ wiring) without locks. Instances are cheap -- the constructor touches no
153
+ hardware -- so create one per thread, or confine all hackrfpy calls to a
154
+ single worker thread.
155
+
121
156
  ## Examples
122
157
 
123
158
  The [main GitHub repository](https://github.com/LC-Linkous/hackRF_python) provides runnable examples, grouped by what they demonstrate.
@@ -129,9 +164,10 @@ The [main GitHub repository](https://github.com/LC-Linkous/hackRF_python) provid
129
164
 
130
165
  **Acquisition**
131
166
 
132
- - `persistent_capture.py` — collect many segments at one frequency from a single long-lived process
167
+ - `persistent_capture.py` — gapless back-to-back segments at one frequency from a single long-lived receive process (contrast with `capture(segment_secs=...)`, whose files have a short re-open gap between them)
133
168
  - `power_meter.py` — live dBFS power meter at one frequency via the callback API
134
169
  - `scan_then_capture.py` — sweep a band, find the strongest bin, then capture there
170
+ - `channel_monitor.py` — live power meter on several frequencies at once via `monitor_frequencies` (one continuous sweep, no plotting extra needed)
135
171
 
136
172
  **Sweep and plotting**
137
173
 
@@ -146,14 +182,19 @@ The [main GitHub repository](https://github.com/LC-Linkous/hackRF_python) provid
146
182
 
147
183
  **Sample data**
148
184
 
149
- - `collect_sample_data.py` — collect real IQ + sweep datasets (read-only; never transmits)
185
+ - `collect_sample_data.py` — collect real IQ + sweep datasets with per-capture validation (read-only; never transmits)
186
+ - `fm_demod_to_wav.py` — demodulate a captured FM broadcast IQ file to an audible mono WAV (numpy + stdlib only; file processing, never touches the device)
187
+
188
+ **Transmit**
189
+
190
+ - `tx_test_tone.py` — the one transmitting example: a bounded CW test tone behind the TX-mode gate, with `--print-cmd` dry-run
150
191
 
151
192
  > Most plotting examples require the optional plotting dependencies:
152
193
  > `pip install "hackrfpy[plotting]"`
153
194
 
154
195
  ## Documentation
155
196
 
156
- For comprehensive documentation, the full method reference, the CLI reference, and the operating envelope:
197
+ Every public method carries a docstring: `help(hackrfpy.HackRF)` or `python -m pydoc hackrfpy` is the offline method reference, and a test gates the whole surface so it cannot drift. For the narrative documentation, the CLI reference, and the operating envelope:
157
198
 
158
199
  - **Library GitHub repository**: [https://github.com/LC-Linkous/hackRF_python/](https://github.com/LC-Linkous/hackRF_python/)
159
200
  - **Official HackRF documentation**: [https://hackrf.readthedocs.io/](https://hackrf.readthedocs.io/) (not associated with this library)
@@ -163,6 +204,7 @@ For comprehensive documentation, the full method reference, the CLI reference, a
163
204
  This is an unofficial community project. Contributions welcome!
164
205
 
165
206
  - Report bugs and request features on [GitHub](https://github.com/LC-Linkous/hackRF_python)
207
+ - If you run the library on Linux or macOS, reports from real hardware are especially welcome (see [Platform support](#platform-support))
166
208
  - For device information and OFFICIAL resources, see [https://hackrf.readthedocs.io/](https://hackrf.readthedocs.io/)
167
209
  - 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.
168
210
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  **An Unofficial Python CLI + Scripting Wrapper for the HackRF One that works on Windows**
4
4
 
5
+ [![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)
5
6
  [![PyPI version](https://badge.fury.io/py/hackrfpy.svg)](https://badge.fury.io/py/hackrfpy)
6
7
  [![Python versions](https://img.shields.io/pypi/pyversions/hackrfpy.svg)](https://pypi.org/project/hackrfpy/)
7
8
  [![PyPI - Wheel](https://img.shields.io/pypi/wheel/hackrfpy.svg)](https://pypi.org/project/hackrfpy/)
@@ -18,13 +19,27 @@ This repository uses official resources and documentation but is **NOT** endorse
18
19
 
19
20
  - **Device Discovery** — detect and identify connected HackRF boards, report firmware and identity
20
21
  - **IQ Capture** — bounded, timed, streaming, or callback-style receive; decoded to normalized `complex64`
21
- - **Spectrum Sweep** — collect or stream `hackrf_sweep` output across a frequency range
22
+ - **Spectrum Sweep** — collect or stream `hackrf_sweep` output across a frequency range, plus multi-frequency power monitoring over time
22
23
  - **Transmit** — file playback and constant-wave test mode, behind a deliberate TX-mode gate
23
24
  - **Operating Envelope** — per-parameter range checks and gain snapping against the device's real steps
24
25
  - **SigMF Recordings** — self-describing `.iq` captures with metadata sidecars
25
26
  - **Error Handling** — a typed exception hierarchy and verbose output options
27
+ - **Lifecycle Safety** — clean interrupts on both platforms, an `atexit` backstop, and an OS dead-man (pdeathsig / Job Object) so even a hard-killed script cannot orphan a transmitter
26
28
  - **CLI** — the `hrf` command-line shell over the full API
27
29
 
30
+ ## Platform support
31
+
32
+ hackrfpy is developed and tested on **Windows**, and has also been verified on Debian Linux with real hardware. Running the `hackrf-tools` binaries as subprocesses — rather than binding to `libhackrf` through a C extension — is what makes that portability cheap: there is no compiler or build step on any platform, and each OS runs its own native tools. Process control is handled per-platform (`SIGINT` on POSIX, `CTRL_BREAK` on Windows), with the interrupt, flush, and dead-man paths covered by tests on every OS, no skips.
33
+
34
+ A platform is called **tested** here only after the full suite (hardware tests included) passes against a real board:
35
+
36
+ - **Windows** — the primary development platform, verified continuously against real hardware.
37
+ - **Linux** — verified 2026-09-19 on Debian 12 (`hackrf` 2022.09.1, firmware 2024.02.1): full suite, 227/227. Mechanics additionally exercised against tools 2023.01.1 on Ubuntu, so both packaged tools versions are known-good. Setup notes for Debian-family systems are in the [main repository README](https://github.com/LC-Linkous/hackRF_python#linux-setup-debian-family).
38
+
39
+ **macOS remains experimental**: the mechanics are CI-tested there, but board-attached operation is unverified. If you run it with a board, please [open an issue](https://github.com/LC-Linkous/hackRF_python/issues) — a passing hardware suite is what promotes a platform, and the bar is not a formality: the Linux verification run surfaced and fixed two real process-lifecycle bugs.
40
+
41
+ One caveat that applies everywhere: `tools_dir` must point at binaries built for the OS you are on — the Windows `.EXE` bundle will never run on Linux, and vice versa.
42
+
28
43
  ## Installation
29
44
 
30
45
  ```bash
@@ -39,13 +54,13 @@ pip install "hackrfpy[plotting]"
39
54
 
40
55
  Python 3.11+ is required.
41
56
 
42
- **You also need the `hackrf-tools` binaries**, which are *not* a pip dependency — they are installed at the OS level:
57
+ **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`).
43
58
 
44
- - **Linux:** `sudo apt install hackrf` (or your distribution's equivalent)
45
- - **macOS:** `brew install hackrf`
46
- - **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.
59
+ - **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.
60
+ - **Linux** — *tested.* `sudo apt install hackrf` (or your distribution's equivalent).
61
+ - **macOS** — *experimental, see [Platform support](#platform-support).* `brew install hackrf`.
47
62
 
48
- Verify the install with `hackrf_info`.
63
+ Verify the tools are installed with `hackrf_info`.
49
64
 
50
65
  ## Quick Start
51
66
 
@@ -79,9 +94,20 @@ for r in rows:
79
94
  print(r["hz_low"], r["hz_high"], min(r["db"]), max(r["db"]))
80
95
  ```
81
96
 
97
+ The same operations are available from the shell via the `hrf` entry point:
98
+
99
+ ```bash
100
+ hrf detect # is a board attached and ready?
101
+ hrf rx -f 433.92M -s 8M -n 2000000 -o capture.iq
102
+ hrf sweep --f-min 88M --f-max 108M
103
+ hrf monitor 98.1M 103.7M -d 10 # power over time on several frequencies
104
+ ```
105
+
106
+ Frequencies accept unit suffixes (`433.92M`, `1.09G`), and most commands take `--print-cmd` to show the underlying `hackrf_*` invocation without running it. The full CLI reference is in the repository README.
107
+
82
108
  ## Transmitting
83
109
 
84
- Transmit is gated behind an explicit mode switch, because an accidental transmit is the one operation that can damage equipment or break the law:
110
+ Transmit is gated behind an explicit mode switch, because an accidental transmit is the one operation that can damage equipment (or break the law)
85
111
 
86
112
  ```python
87
113
  from hackrfpy import HackRF
@@ -91,8 +117,18 @@ h.set_mode("tx") # prints the TX-mode safety banner
91
117
  h.transmit(433.92e6, 8e6, "signal.iq", txvga=20)
92
118
  ```
93
119
 
120
+ A bounded constant-wave test tone is available without a source file: `h.transmit_cw(433.92e6, 2e6, duration=2)`, or `hrf tx --cw -f 433.92M -s 2M -d 2` from the CLI (the duration is mandatory there — an unbounded carrier is exactly the risk the gates exist to prevent). Behind the mode gate sit an `atexit` backstop and an OS dead-man, so even a hard-killed script cannot leave a transmitter on the air.
121
+
94
122
  **Transmitting is regulated.** You are responsible for operating within the law and within your equipment's limits.
95
123
 
124
+ ## Thread safety
125
+
126
+ A `HackRF` instance is **not** safe to share across threads: methods mutate
127
+ per-instance state (`last_params`, the persisted operating mode, logging
128
+ wiring) without locks. Instances are cheap -- the constructor touches no
129
+ hardware -- so create one per thread, or confine all hackrfpy calls to a
130
+ single worker thread.
131
+
96
132
  ## Examples
97
133
 
98
134
  The [main GitHub repository](https://github.com/LC-Linkous/hackRF_python) provides runnable examples, grouped by what they demonstrate.
@@ -104,9 +140,10 @@ The [main GitHub repository](https://github.com/LC-Linkous/hackRF_python) provid
104
140
 
105
141
  **Acquisition**
106
142
 
107
- - `persistent_capture.py` — collect many segments at one frequency from a single long-lived process
143
+ - `persistent_capture.py` — gapless back-to-back segments at one frequency from a single long-lived receive process (contrast with `capture(segment_secs=...)`, whose files have a short re-open gap between them)
108
144
  - `power_meter.py` — live dBFS power meter at one frequency via the callback API
109
145
  - `scan_then_capture.py` — sweep a band, find the strongest bin, then capture there
146
+ - `channel_monitor.py` — live power meter on several frequencies at once via `monitor_frequencies` (one continuous sweep, no plotting extra needed)
110
147
 
111
148
  **Sweep and plotting**
112
149
 
@@ -121,14 +158,19 @@ The [main GitHub repository](https://github.com/LC-Linkous/hackRF_python) provid
121
158
 
122
159
  **Sample data**
123
160
 
124
- - `collect_sample_data.py` — collect real IQ + sweep datasets (read-only; never transmits)
161
+ - `collect_sample_data.py` — collect real IQ + sweep datasets with per-capture validation (read-only; never transmits)
162
+ - `fm_demod_to_wav.py` — demodulate a captured FM broadcast IQ file to an audible mono WAV (numpy + stdlib only; file processing, never touches the device)
163
+
164
+ **Transmit**
165
+
166
+ - `tx_test_tone.py` — the one transmitting example: a bounded CW test tone behind the TX-mode gate, with `--print-cmd` dry-run
125
167
 
126
168
  > Most plotting examples require the optional plotting dependencies:
127
169
  > `pip install "hackrfpy[plotting]"`
128
170
 
129
171
  ## Documentation
130
172
 
131
- For comprehensive documentation, the full method reference, the CLI reference, and the operating envelope:
173
+ Every public method carries a docstring: `help(hackrfpy.HackRF)` or `python -m pydoc hackrfpy` is the offline method reference, and a test gates the whole surface so it cannot drift. For the narrative documentation, the CLI reference, and the operating envelope:
132
174
 
133
175
  - **Library GitHub repository**: [https://github.com/LC-Linkous/hackRF_python/](https://github.com/LC-Linkous/hackRF_python/)
134
176
  - **Official HackRF documentation**: [https://hackrf.readthedocs.io/](https://hackrf.readthedocs.io/) (not associated with this library)
@@ -138,6 +180,7 @@ For comprehensive documentation, the full method reference, the CLI reference, a
138
180
  This is an unofficial community project. Contributions welcome!
139
181
 
140
182
  - Report bugs and request features on [GitHub](https://github.com/LC-Linkous/hackRF_python)
183
+ - If you run the library on Linux or macOS, reports from real hardware are especially welcome (see [Platform support](#platform-support))
141
184
  - For device information and OFFICIAL resources, see [https://hackrf.readthedocs.io/](https://hackrf.readthedocs.io/)
142
185
  - 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.
143
186
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hackrfpy"
3
- version = "1.0.0"
3
+ version = "1.1.0"
4
4
  description = "A self-contained CLI + scriptable wrapper for the HackRF One, built on the hackrf-tools binaries (no libhackrf bindings required)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -9,11 +9,10 @@ license-files = ["LICENSE*"]
9
9
  authors = [{ name = "LC-Linkous" }]
10
10
  keywords = ["hackrf", "sdr", "rf", "spectrum", "sigmf"]
11
11
  classifiers = [
12
- "Development Status :: 3 - Alpha",
12
+ "Development Status :: 5 - Production/Stable",
13
13
  "Intended Audience :: Developers",
14
14
  "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)",
15
15
  "Operating System :: Microsoft :: Windows",
16
- "Operating System :: OS Independent",
17
16
  "Programming Language :: Python :: 3.11",
18
17
  "Programming Language :: Python :: 3.12",
19
18
  "Programming Language :: Python :: 3.13",
@@ -47,10 +46,43 @@ dev = [
47
46
  "pytest-cov",
48
47
  "ruff",
49
48
  "mypy",
49
+ "sigmf>=1.2", # official validator: spec compliance is tested, not trusted
50
50
  ]
51
51
 
52
52
  [tool.pytest.ini_options]
53
53
  markers = [
54
54
  "hardware: tests that require a connected HackRF (self-skip if absent)",
55
55
  ]
56
- testpaths = ["tests"]
56
+ testpaths = ["tests"]
57
+
58
+ [tool.mypy]
59
+ python_version = "3.11"
60
+ files = ["src/hackrfpy"]
61
+ # The point of this config: the package ships a py.typed marker, which promises
62
+ # downstream type-checkers that the inline annotations are real. These settings
63
+ # are what make that promise true -- every def in the shipped package must be
64
+ # annotated, or CI fails.
65
+ disallow_untyped_defs = true
66
+ disallow_incomplete_defs = true
67
+ check_untyped_defs = true
68
+ no_implicit_optional = true
69
+ warn_redundant_casts = true
70
+ warn_unused_ignores = true
71
+
72
+ [tool.ruff]
73
+ # Match the codebase's existing style rather than reformatting it.
74
+ line-length = 100
75
+ target-version = "py311"
76
+
77
+ [tool.ruff.lint]
78
+ # Default ruleset (pyflakes + a pycodestyle subset). Kept deliberately small:
79
+ # this gates CI, so every enabled rule should be one we actually want to block
80
+ # a merge on.
81
+ select = ["E", "F", "W"]
82
+
83
+ [tool.ruff.lint.per-file-ignores]
84
+ # E741 ("ambiguous variable name l"): `l` reads naturally as "line" in the
85
+ # sweep-line parsing comprehensions. Cosmetic only, and renaming would churn
86
+ # working parser tests.
87
+ "tests/test_parsing.py" = ["E741"]
88
+ "tests/test_real_output.py" = ["E741"]
@@ -11,8 +11,14 @@ from .exceptions import (
11
11
  )
12
12
  from . import constants
13
13
 
14
+ try:
15
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
16
+ __version__ = _pkg_version("hackrfpy")
17
+ except PackageNotFoundError: # running from a checkout
18
+ __version__ = "0.0.0+unknown"
19
+
14
20
  __all__ = [
15
- "HackRF", "load_iq", "parse_freq", "constants",
21
+ "HackRF", "load_iq", "parse_freq", "constants", "__version__",
16
22
  "write_sigmf_meta", "read_sigmf_meta",
17
23
  "HackRFError", "HackRFValueError", "HackRFModeError",
18
24
  "HackRFDeviceError", "HackRFEnvironmentError",
@@ -18,18 +18,38 @@
18
18
  # Author(s): <you>
19
19
  ##--------------------------------------------------------------------\
20
20
 
21
+ from __future__ import annotations
22
+
21
23
  import os
24
+ from typing import TYPE_CHECKING, Any, Callable, Generator
25
+
26
+ import numpy as np
22
27
 
23
28
  from .. import constants as C
29
+ from .._host import HostOps
24
30
  from ..sigmf import write_sigmf_meta
25
31
 
32
+ if TYPE_CHECKING:
33
+ from .._receiver import PersistentReceiver
34
+ from .._stream_ctx import StreamCtx
35
+
26
36
 
27
- class CaptureMixin:
28
- def capture(self, freq, sample_rate, *, out="capture.iq",
29
- num_samples=None, duration=None,
30
- lna=16, vga=20, amp=False, bias_tee=False,
31
- baseband_bw=None, to_stdout=False, sigmf=True,
32
- segment_secs=None, print_cmd=False):
37
+ class CaptureMixin(HostOps):
38
+ def capture(self, freq: float, sample_rate: float, *,
39
+ out: str = "capture.iq", num_samples: int | None = None,
40
+ duration: float | None = None, lna: int = 16, vga: int = 20,
41
+ amp: bool = False, bias_tee: bool = False,
42
+ baseband_bw: float | None = None, to_stdout: bool = False,
43
+ sigmf: bool = True, segment_secs: float | None = None,
44
+ print_cmd: bool = False) -> Any:
45
+ """Receive IQ at freq/sample_rate into a file (with SigMF sidecar).
46
+
47
+ Bound with num_samples or duration, or neither for an open-ended handle
48
+ (call .stop() on the returned process handle). segment_secs rolls output
49
+ across numbered files (whole files, short re-open gap between them; use
50
+ open_receiver for gapless). to_stdout=True streams decoded blocks
51
+ instead of writing a file.
52
+ """
33
53
  self.require_mode(C.MODE_RX)
34
54
  freq, sample_rate, lna, vga = self.validate_rx(freq, sample_rate, lna, vga)
35
55
  bw = self._auto_baseband(sample_rate, baseband_bw)
@@ -57,7 +77,7 @@ class CaptureMixin:
57
77
  # The inner stream generator is closed EXPLICITLY on the way out;
58
78
  # leaving it to GC can orphan a receiving hackrf_transfer after
59
79
  # the caller breaks out of the loop.
60
- def _gen():
80
+ def _gen() -> Generator[np.ndarray, None, None]: # decoded, not raw
61
81
  tail = b""
62
82
  inner = self._run(argv, mode="stream")
63
83
  try:
@@ -97,17 +117,30 @@ class CaptureMixin:
97
117
  return res
98
118
 
99
119
  # ---- aliases ----
100
- def rx(self, *a, **k):
120
+ def rx(self, *a: Any, **k: Any) -> Any:
121
+ """Alias of capture()."""
101
122
  return self.capture(*a, **k)
102
123
 
103
- def capture_samples(self, freq, sample_rate, num_samples, **k):
124
+ def capture_samples(self, freq: float, sample_rate: float,
125
+ num_samples: int, **k: Any) -> Any:
126
+ """Alias of capture() with a required num_samples bound."""
104
127
  return self.capture(freq, sample_rate, num_samples=num_samples, **k)
105
128
 
106
- def capture_seconds(self, freq, sample_rate, duration, **k):
129
+ def capture_seconds(self, freq: float, sample_rate: float,
130
+ duration: float, **k: Any) -> Any:
131
+ """Alias of capture() with a required duration bound."""
107
132
  return self.capture(freq, sample_rate, duration=duration, **k)
108
133
 
109
- def scan_frequencies(self, freqs, sample_rate, num_samples, *,
110
- on_capture=None, **k):
134
+ def scan_frequencies(self, freqs: list[float], sample_rate: float,
135
+ num_samples: int, *,
136
+ on_capture: Callable[..., Any] | None = None,
137
+ **k: Any) -> dict[float, np.ndarray] | None:
138
+ """Capture num_samples of IQ at each frequency in turn.
139
+
140
+ Returns {freq: complex64 ndarray}, or None when on_capture consumes
141
+ blocks instead. Each visit re-opens the device (short gap between
142
+ frequencies).
143
+ """
111
144
  # Sequentially capture a fixed sample count at each frequency in
112
145
  # `freqs`, retuning between them. This does NOT close the gapless-
113
146
  # retune gap (each retune is a fresh hackrf_transfer with a short
@@ -130,8 +163,13 @@ class CaptureMixin:
130
163
  return results if on_capture is None else None
131
164
 
132
165
  # ---- in-memory + context-managed entry points ----
133
- def capture_array(self, freq, sample_rate, num_samples, *,
134
- return_params=False, **k):
166
+ def capture_array(self, freq: float, sample_rate: float, num_samples: int,
167
+ *, return_params: bool = False, **k: Any) -> Any:
168
+ """Return exactly num_samples complex64 samples in RAM, no file.
169
+
170
+ The scripting entry point. return_params=True also returns the
171
+ validated parameters actually used.
172
+ """
135
173
  # Scripting entry point: return EXACTLY num_samples complex64 samples
136
174
  # in RAM, no file. Built on the stdout-stream path so it shares the
137
175
  # odd-byte carry + clean-reap logic. The stream is closed as soon as
@@ -164,8 +202,16 @@ class CaptureMixin:
164
202
  return iq, self.last_params
165
203
  return iq
166
204
 
167
- def open_receiver(self, freq, sample_rate, *, lna=16, vga=20, amp=False,
168
- baseband_bw=None, read_samples=131072):
205
+ def open_receiver(self, freq: float, sample_rate: float, *, lna: int = 16,
206
+ vga: int = 20, amp: bool = False,
207
+ baseband_bw: float | None = None,
208
+ read_samples: int = 131072) -> PersistentReceiver:
209
+ """Open a long-lived receive stream; returns a PersistentReceiver.
210
+
211
+ One hackrf_transfer process serves many .read(n) calls: consecutive
212
+ reads are gapless (contrast capture(segment_secs=...)). Use as a context
213
+ manager to guarantee the child is reaped.
214
+ """
169
215
  # Open a PERSISTENT fixed-frequency receiver: one long-lived
170
216
  # hackrf_transfer you drain in segments over time, so you don't pay the
171
217
  # ~1-2 s process spin-up per capture. Use as a context manager:
@@ -184,7 +230,13 @@ class CaptureMixin:
184
230
  amp=amp, baseband_bw=baseband_bw,
185
231
  read_samples=read_samples)
186
232
 
187
- def capture_stream(self, freq, sample_rate, **k):
233
+ def capture_stream(self, freq: float, sample_rate: float,
234
+ **k: Any) -> StreamCtx:
235
+ """Context manager yielding decoded complex64 blocks from a live stream.
236
+
237
+ The receiving hackrf_transfer is always reaped on block exit, even on
238
+ exception.
239
+ """
188
240
  # Context manager wrapping the live stdout stream so the receiving
189
241
  # hackrf_transfer is ALWAYS reaped on exit, even on exception:
190
242
  # with h.capture_stream(433.92e6, 8e6) as blocks:
@@ -194,8 +246,15 @@ class CaptureMixin:
194
246
  gen = self.capture(freq, sample_rate, to_stdout=True, **k)
195
247
  return StreamCtx(gen)
196
248
 
197
- def capture_callback(self, freq, sample_rate, on_block, *,
198
- max_samples=None, max_blocks=None, **k):
249
+ def capture_callback(self, freq: float, sample_rate: float,
250
+ on_block: Callable[..., Any], *,
251
+ max_samples: int | None = None,
252
+ max_blocks: int | None = None, **k: Any) -> int:
253
+ """Invoke on_block(iq) per decoded block until a bound or False return.
254
+
255
+ Bounds: max_samples, max_blocks, or the callback returning False.
256
+ Returns the number of samples delivered.
257
+ """
199
258
  # Binder-style ergonomics over the subprocess stream: instead of the
200
259
  # caller writing the receive loop, register a callback that fires with
201
260
  # each decoded complex64 block as it arrives. This is the usability
@@ -228,8 +287,9 @@ class CaptureMixin:
228
287
  return total
229
288
 
230
289
  # ---- internals ----
231
- def _rx_argv(self, freq, sample_rate, target, lna, vga, amp, bias_tee,
232
- bw, num_samples):
290
+ def _rx_argv(self, freq: float, sample_rate: float, target: str, lna: int,
291
+ vga: int, amp: bool, bias_tee: bool, bw: float,
292
+ num_samples: int | None) -> list[Any]:
233
293
  argv = ["transfer", "-r", target,
234
294
  "-f", int(freq), "-s", int(sample_rate),
235
295
  "-l", lna, "-g", vga,
@@ -241,8 +301,10 @@ class CaptureMixin:
241
301
  argv += ["-n", int(num_samples)]
242
302
  return argv
243
303
 
244
- def _capture_segmented(self, freq, sample_rate, out, segment_secs, lna,
245
- vga, amp, bias_tee, bw, sigmf, print_cmd):
304
+ def _capture_segmented(self, freq: float, sample_rate: float, out: str,
305
+ segment_secs: float, lna: int, vga: int, amp: bool,
306
+ bias_tee: bool, bw: float, sigmf: bool,
307
+ print_cmd: bool) -> list[str]:
246
308
  # Rolling files out_000.iq, out_001.iq, ... each `segment_secs` long.
247
309
  # Each segment is a bounded blocking capture, so files are whole.
248
310
  base, ext = os.path.splitext(out)
@@ -268,7 +330,7 @@ class CaptureMixin:
268
330
  seg = f"{base}_{idx:03d}{ext}"
269
331
  argv = self._rx_argv(freq, sample_rate, seg, lna, vga, amp,
270
332
  bias_tee, bw, n_per)
271
- res = self._run(argv, mode="blocking", print_cmd=print_cmd)
333
+ self._run(argv, mode="blocking", print_cmd=print_cmd)
272
334
  if sigmf and not print_cmd:
273
335
  write_sigmf_meta(seg, freq, sample_rate, lna=lna, vga=vga,
274
336
  amp=amp, datatype="ci8")