Skip to content

Commit 6cc7ce1

Browse files
committed
trailing byte fix, test additions, uv fix, CI
1 parent 7a449db commit 6cc7ce1

6 files changed

Lines changed: 153 additions & 13 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ jobs:
4545
enable-cache: true
4646
python-version: ${{ matrix.python-version }}
4747

48+
- name: Lockfile is in sync with pyproject
49+
# A stale uv.lock means CI tests a different dependency set than a
50+
# fresh `uv sync` would install (the lock had recorded the project
51+
# itself at 1.0.0 against a 1.1.0 pyproject). Fail early instead.
52+
run: uv lock --check
53+
4854
- name: Sync environment (numpy + dev group, editable install)
4955
run: uv sync
5056

@@ -66,6 +72,19 @@ jobs:
6672
steps:
6773
- uses: actions/checkout@v4
6874

75+
- name: Tracked text files are LF in the index
76+
# .gitattributes declares LF; this holds the line. `git ls-files
77+
# --eol` reports the INDEX encoding, independent of the runner's OS.
78+
working-directory: .
79+
run: |
80+
bad=$(git ls-files --eol | grep -E '^i/(crlf|mixed)' || true)
81+
if [ -n "$bad" ]; then
82+
echo "::error::tracked files with CRLF/mixed line endings in the index:"
83+
echo "$bad"
84+
exit 1
85+
fi
86+
echo "index is LF-clean ($(git ls-files --eol | grep -c '^i/lf') text files)"
87+
6988
- name: Install uv
7089
uses: astral-sh/setup-uv@v5
7190
with:

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
Work on the current development branch. Entries move to a versioned section on
1111
release.
1212

13+
### Fixed
14+
- `PersistentReceiver.blocks()` could swap I and Q for the rest of a stream.
15+
A pipe may return fewer bytes than `read1()` asked for, so a raw chunk can
16+
end mid-pair; `blocks()` handed each chunk straight to `decode_iq`, which
17+
drops an odd trailing byte, and from that point every sample's I and Q
18+
were exchanged -- power statistics stay healthy while the phase is
19+
garbage. `capture()`'s stream path already carried the odd byte forward;
20+
`blocks()` now does the same, through the carry buffer `read()` uses, so
21+
the two can be mixed on one receiver. Two regression tests feed odd-sized
22+
chunks directly and check the decoded ramp byte-for-byte. Anything that
23+
streamed through `open_receiver().blocks()` (the `waterfall_persistent` and
24+
`persistent_capture` examples, downstream `IQSource` adapters) is affected.
25+
1326
## [1.1.0] - 2026-09-19
1427

1528
Hardening-and-verification release: process lifecycle, cross-platform

‎hackrfpy/src/hackrfpy/_receiver.py‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -173,17 +173,22 @@ def blocks(self) -> Iterator[np.ndarray]:
173173
# Yield decoded complex64 blocks as they arrive (raw cadence), until
174174
# the stream ends or the caller stops iterating. Good for a continuous
175175
# consumer that doesn't need a fixed sample count per read.
176+
# An odd-length chunk splits an I/Q pair across two reads. decode_iq
177+
# DROPS an odd trailing byte, and dropping one byte here would swap
178+
# I and Q for every sample that follows (capture()'s stream path
179+
# carries the byte for exactly this reason). The read1() cap in
180+
# _run is even, but a pipe is free to return fewer bytes than
181+
# asked, so the odd case is real. Carry the byte in self._carry,
182+
# the same buffer read() uses, so blocks() and read() can be mixed.
176183
gen = self._ensure_open()
177-
if self._carry:
178-
lead = self._h.decode_iq(self._carry)
179-
self._carry = b""
180-
if len(lead):
181-
self.total_samples += len(lead)
182-
yield lead
183184
for chunk in gen:
184-
iq = self._h.decode_iq(chunk)
185-
self.total_samples += len(iq)
186-
yield iq
185+
buf = self._carry + chunk
186+
cut = len(buf) - (len(buf) % 2)
187+
self._carry = buf[cut:]
188+
if cut:
189+
iq = self._h.decode_iq(buf[:cut])
190+
self.total_samples += len(iq)
191+
yield iq
187192

188193
def callback(self, on_block: Callable[[np.ndarray, int], Any], *,
189194
max_samples: int | None = None) -> int:

‎hackrfpy/tests/conftest.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def fixtures_dir():
6969
IDLE = {idle!r}
7070
EXIT_CODE = {exit_code!r}
7171
EMIT_BYTES = {emit_bytes!r}
72+
EMIT_CHUNK_SIZES = {emit_chunk_sizes!r}
7273
TAIL_ON_INTERRUPT = {tail_on_interrupt!r}
7374
IGNORE_INTERRUPT = {ignore_interrupt!r}
7475
BUSY_FAILS = {busy_fails!r}
@@ -131,8 +132,17 @@ def _on_signal(signum, frame):
131132
sys.stderr.flush()
132133
133134
if EMIT_BYTES:
134-
sys.stdout.buffer.write(bytes(EMIT_BYTES))
135-
sys.stdout.buffer.flush()
135+
if EMIT_CHUNK_SIZES:
136+
# write + flush in the given piece sizes (cycled), so the parent's
137+
# read1() can observe a pipe boundary inside an I/Q pair
138+
_raw, _pos, _k = bytes(EMIT_BYTES), 0, 0
139+
while _pos < len(_raw):
140+
_n = EMIT_CHUNK_SIZES[_k % len(EMIT_CHUNK_SIZES)]; _k += 1
141+
sys.stdout.buffer.write(_raw[_pos:_pos + _n]); _pos += _n
142+
sys.stdout.buffer.flush()
143+
else:
144+
sys.stdout.buffer.write(bytes(EMIT_BYTES))
145+
sys.stdout.buffer.flush()
136146
137147
for line in STDOUT_LINES:
138148
sys.stdout.write(line + "\\n")
@@ -163,13 +173,15 @@ def _on_signal(signum, frame):
163173
def _write_stub(tools_dir, name, *, stdout_lines=(), stderr_lines=(),
164174
stderr_flood=0, idle=False, exit_code=0, marker=None,
165175
emit_bytes=None, tail_on_interrupt=None,
166-
ignore_interrupt=False, busy_fails=0, stdout_flood=0):
176+
ignore_interrupt=False, busy_fails=0, stdout_flood=0,
177+
emit_chunk_sizes=None):
167178
py_path = os.path.join(tools_dir, name + ".py")
168179
body = _STUB_TEMPLATE.format(
169180
marker=marker, stdout_lines=list(stdout_lines),
170181
stderr_lines=list(stderr_lines), stderr_flood=stderr_flood,
171182
idle=idle, exit_code=exit_code,
172183
emit_bytes=list(emit_bytes) if emit_bytes else None,
184+
emit_chunk_sizes=list(emit_chunk_sizes) if emit_chunk_sizes else None,
173185
tail_on_interrupt=tail_on_interrupt,
174186
ignore_interrupt=ignore_interrupt, busy_fails=busy_fails,
175187
stdout_flood=stdout_flood)

‎hackrfpy/tests/test_receiver.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,94 @@ def test_read_after_close_raises_typed_error(stub_device):
134134
recv.close()
135135
with pytest.raises(HackRFDeviceError, match="closed"):
136136
recv.read(16)
137+
138+
139+
# ---------------------------------------------------------------------------
140+
# Odd-length chunks must not swap I and Q.
141+
#
142+
# A pipe is free to return fewer bytes than read1() asked for, so a raw chunk
143+
# can end in the middle of an I/Q pair. decode_iq drops an odd trailing byte;
144+
# if blocks() handed it such a chunk directly, that one dropped byte would
145+
# swap I and Q for every sample afterwards -- a capture whose power stats look
146+
# healthy while the phase is garbage. capture()'s own stream path carries the
147+
# odd byte forward; blocks() did not. These pin the carry on blocks(), and that
148+
# read() and blocks() share one carry so they can be mixed.
149+
# ---------------------------------------------------------------------------
150+
151+
def _odd_chunk_receiver(stub_device, chunks):
152+
"""A PersistentReceiver whose raw stream is exactly `chunks`."""
153+
h = _streaming_device(stub_device, nbytes=16)
154+
rx = h.open_receiver(100e6, 8e6)
155+
rx._gen = iter(chunks) # bypass the child; feed raw bytes directly
156+
rx._ensure_open = lambda: rx._gen
157+
return rx
158+
159+
160+
def test_blocks_carries_odd_byte_instead_of_swapping_iq(stub_device):
161+
# ramp: I = 0,2,4,.. Q = 1,3,5,.. so a correct decode has I even, Q odd
162+
n = 1000
163+
raw = bytes(i % 256 for i in range(2 * n))
164+
# split into deliberately odd-sized chunks (7, 13, 1, 3, ...)
165+
sizes = [7, 13, 1, 3, 101, 9]
166+
chunks, pos = [], 0
167+
while pos < len(raw):
168+
for s in sizes:
169+
chunks.append(raw[pos:pos + s])
170+
pos += s
171+
if pos >= len(raw):
172+
break
173+
chunks = [c for c in chunks if c]
174+
assert any(len(c) % 2 for c in chunks), "test needs odd chunks"
175+
176+
rx = _odd_chunk_receiver(stub_device, chunks)
177+
got = np.concatenate(list(rx.blocks()))
178+
assert len(got) == n
179+
# decode_iq divides by 128 -> undo to compare against the raw ramp
180+
i = np.rint(got.real * 128).astype(int) % 256
181+
q = np.rint(got.imag * 128).astype(int) % 256
182+
expect = np.arange(2 * n) % 256
183+
# int8 wraps at 128, so compare modulo 256 on the signed values
184+
assert np.array_equal(i % 256, expect[0::2])
185+
assert np.array_equal(q % 256, expect[1::2])
186+
assert rx.total_samples == n
187+
188+
189+
def test_blocks_and_read_share_one_carry(stub_device):
190+
n = 64
191+
raw = bytes(i % 256 for i in range(2 * n))
192+
# first chunk odd, so blocks() leaves one byte in the carry; then read()
193+
# must pick it up and stay pair-aligned.
194+
chunks = [raw[:5], raw[5:40], raw[40:]]
195+
rx = _odd_chunk_receiver(stub_device, chunks)
196+
gen = rx.blocks()
197+
first = next(gen) # consumes raw[:5] -> 2 samples, 1 byte carried
198+
assert len(first) == 2
199+
assert rx._carry == raw[4:5]
200+
rest = rx.read(n - 2)
201+
got = np.concatenate([first, rest])
202+
assert len(got) == n
203+
i = np.rint(got.real * 128).astype(int) % 256
204+
assert np.array_equal(i, (np.arange(2 * n) % 256)[0::2])
205+
206+
207+
def test_blocks_through_a_real_pipe_with_odd_writes(stub_device):
208+
"""Same contract, but the odd boundaries come from an actual child process
209+
writing and flushing odd-sized pieces into the stdout pipe -- the path a
210+
downstream IQSource adapter (sdr_dsp's HackRFCapture) streams through.
211+
On the pre-fix code this swapped I and Q from the first odd piece on."""
212+
n = 200_000
213+
raw = bytes(i % 256 for i in range(2 * n))
214+
h = stub_device(transfer=dict(emit_bytes=raw, idle=False,
215+
emit_chunk_sizes=[7, 65537, 3, 131071, 1]))
216+
got = []
217+
with h.open_receiver(100e6, 8e6) as rx:
218+
for blk in rx.blocks():
219+
got.append(blk)
220+
if sum(len(g) for g in got) >= n:
221+
break
222+
x = np.concatenate(got)[:n]
223+
i = np.rint(x.real * 128).astype(int) % 256
224+
q = np.rint(x.imag * 128).astype(int) % 256
225+
expect = np.arange(2 * n) % 256
226+
assert np.array_equal(i, expect[0::2])
227+
assert np.array_equal(q, expect[1::2])

‎hackrfpy/uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)