Skip to content

Restore sync stream after enable_module() re-enables a direction - #2

Open
wormuz wants to merge 11 commits into
GvozdevLeonid:mainfrom
wormuz:fix/tx-sync-invalidated-after-disable
Open

wormuz wants to merge 11 commits into
GvozdevLeonid:mainfrom
wormuz:fix/tx-sync-invalidated-after-disable

Conversation

@wormuz

@wormuz wormuz commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

libbladeRF tears down the synchronous stream when a direction is disabled: rfic_host.c calls sync_deinit() on !dir_enable, and bladerf1.c does the same. That is documented behaviour — the header says "this will shut down the underlying asynchronous stream when enable = false" — but re-enabling the module does not bring the stream back.

Every later sync_tx() / sync_rx() then fails with

sync tx invalid: not initialized

which gives no hint that sync_config() has to be repeated.

How it looks from the caller side

The radio simply appears dead. Measured on a TX1 → 50 dB pad → RX1 loopback:

symptom value
received level at TX gain 60 dB −44.4 dB
received level at TX gain −30 dB −44.5 dB
transmit calls failing 65487 of 66033

Nothing in the error text points at enable_module() as the cause, so this reads as broken hardware rather than a lifecycle contract.

Fix

Remember the last sync_config() arguments per direction and replay them when the module is enabled again.

Direction is taken from the low bit: TX channels are 1 and 3, TX layouts are 1 and 3, RX are even — verified against the enum values on device.

Verification

On hardware, the previously failing sequence:

after sync_config                             OK
after disable -> enable WITHOUT sync_config   ERR_INVAL   <- before
after disable -> enable + repeated sync_config OK

became:

after sync_config                             OK
after disable -> enable WITHOUT sync_config   OK          <- after
after disable -> enable + repeated sync_config OK

and transmit errors over a full probe run dropped from 65487 to 0.

wormuz added 11 commits August 20, 2026 00:41
libbladeRF tears down the synchronous stream when a direction is
disabled: rfic_host.c calls sync_deinit() on !dir_enable, and bladerf1.c
does the same.  That is documented ("this will shut down the underlying
asynchronous stream when enable = false"), but re-enabling the module
does not bring the stream back.

Every later sync_tx()/sync_rx() then fails with

    sync tx invalid: not initialized

which gives no hint that sync_config() must be repeated.  From the
caller's side the radio simply looks dead: measured on a TX1 -> 50 dB
pad -> RX1 loopback, the received level stopped responding to TX gain
(60 dB and -30 dB both gave -44.4 dB) and 65487 of 66033 transmit calls
failed.

Remember the last sync_config() arguments per direction and replay them
when the module is enabled again.  Direction is taken from the low bit:
TX channels are 1 and 3, TX layouts are 1 and 3, RX are even.

Verified on hardware: the disable -> enable -> sync_tx sequence went from
ERR_INVAL to OK, and transmit errors dropped from 65487 to 0.
A stream configured with a *_META format carries per-buffer timestamps and
flags. Passing metadata=None leaves libbladeRF with nowhere to report them,
so the caller silently loses the timestamp it needs and
bladerf_get_timestamp() keeps returning 0.

Nothing in the error path points at the cause, so this reads as dead
hardware rather than a mismatched call.

Measured on a TX1 -> 50 dB pad -> RX1 loopback at 15.36 MSps: with the
stream in a metadata format but metadata=None, the frame timestamp stayed
at 762229041 across 8 consecutive reads and get_timestamp() returned 0.
Consecutive gain steps then analysed the same buffer, so the receive level
repeated in pairs (-34.9/-34.9, -20.2/-20.2 dBFS) and a gain ladder that is
in fact monotonic came out looking broken.

The stream format is already remembered per direction for the enable_module
restore path, so the check costs nothing extra: sync_rx()/sync_tx() now
raise instead of losing timestamps quietly.

After fixing the call sites the same ladder is monotonic, with deviations of
+0.1 to +0.8 dB over a 40 dB span.
The RFFE control register lives in the FPGA and carries the RF front-end
state that no RFIC register reflects: SPDT switch positions, per-channel
enables, and the direction ENABLE/TXNRX bits.

Needed to tell "an SPDT was left in its shutdown position" from "the RFIC
is fine but the signal is routed nowhere". Both look identical from the
RFIC side: every register reads back correct while the output is dead.

Requires the matching accessor in libbladeRF.
The FPGA configuration GPIO register carries the per-format mode bits
(TIMESTAMP, PACKET, 8BIT_MODE, HIGHLY_PACKED) that both directions share.
perform_format_config() writes the whole word from one direction's format
while perform_format_deconfig() only forgets the format and leaves the
register alone, so this is where a stream configuration on one direction
could clobber the mode the other one needs.

The declaration was already in cbladerf.pxd; only the method was missing.

Used it to rule that out for a transmitter fault: the register reads
0x00010001 with TIMESTAMP set in the working state, in the disabled
state, and in the broken one alike.
Without this the AD9361's internal state is unobservable from Python: the
ENSM state, the digital datapath status and the filter enables live only
in RFIC registers, while every FPGA-side register can read correct at the
same time. libbladeRF already had bladerf_get_rfic_register and
bladerf_set_rfic_register and cbladerf.pxd already declared both; only
the binding was missing.

Used to establish that RFIC state is bit-identical between a working and
a dead transmit cycle (0x017, 0x05E, 0x002, 0x003, 0x001, 0x004, 0x073),
and to drive the RFIC digital loopback via 0x3F5, which is what localized
the fault to samples reaching the RFIC bus and then not advancing.
enable_module(dir, false) makes libbladeRF tear the sync stream down, so
the wrapper repeats the last sync_config on re-enable. That is right for
callers but hides the library's own behaviour, which makes it impossible to
tell a wrapper problem from a library one. The switch turns the repeat off
so both can be measured.
Both __rx_callback_SC16_Q11 and __rx_callback_SC8_Q7 read  after only
assigning it inside 'if callback is not None'. With no callback installed the
read raises UnboundLocalError inside the C callback, which is declared
noexcept nogil, so the exception is swallowed: the stream stops after its very
first buffer, ends up in STREAM_DONE, and bladerf_start_stream() returns with
no error for the caller to see.

Default result to 0 (keep streaming). An async RX stream with no Python
callback is a legitimate configuration - it is the cheapest way to drain a
direction without doing any per-buffer work, which is exactly what I needed
to separate data-plane load from control-plane calls while chasing a TX
wedge.
bladerf_read_flash_bytes was declared but not exposed. Needed it to test
whether a firmware-side PIB/GPIF reset clears a wedged TX feed, since a
flash read is the one safe host call that makes the firmware run
NuandConfigureGpif without closing the device.
The .pxd files carry the PyBladerfDevice layout and the libbladeRF
declarations, and none of the four extensions listed them in depends.
Editing a .pxd therefore left pybladerf_tools compiled against the
previous struct layout, with nothing rebuilt and nothing to warn about
until something crashed.
Counts the FPGA keeps of samples it had to drop (RX) or transmit as a hole
(TX). Distinct from the OVERRUN metadata flag, which is derived on the host
from USB queue state and never reads the fabric.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant