Skip to content

adds hackrf#34

Merged
Josephrp merged 19 commits intodevfrom
rx
Mar 14, 2026
Merged

adds hackrf#34
Josephrp merged 19 commits intodevfrom
rx

Conversation

@Josephrp
Copy link
Copy Markdown
Owner

@Josephrp Josephrp commented Mar 13, 2026

Greptile Summary

This PR integrates HackRF SDR hardware support end-to-end: a new HackRFDeviceManager / HackRFBroker pair in the remote receiver service, /tx/tone and /tx/iq HTTP broker endpoints, a HackRFServiceClient for remote TX delegation, full NFM/AM/SSB/CW modulation and demodulation pipelines (fm.py, analog_mod.py, dsp/analog.py, dsp/nfm.py), and a new normalized mode model (modes.py). A Twilio SMS/WhatsApp webhook handler and several config schema improvements are included.

Key changes:

  • HackRFDeviceManager owns the USB device handle with an asyncio.Lock; HackRFBroker coordinates RX/TX handoff via a should_stop_rx event so active streams yield to TX requests
  • ReceiverService.stream_frequency has been restored with audio demod support (mode, audio_rate_hz, bfo_hz), closing the broken-WebSocket regression from a previous review iteration
  • Compliance checks (is_restricted, is_tx_spectrum_allowed) are applied server-side before every /tx/tone and /tx/iq call — the broker can no longer be used to bypass band-plan enforcement
  • ensure_test_state is now opt-in (called from test fixtures) instead of running unconditionally at module import time, eliminating the silent auth bypass in tests
  • The dotenv_settings drop in settings_customise_sources has been fixed; .env file loading is restored
  • _to_mono_float in both fm.py and analog_mod.py captures is_integer before the stereo mean() call, fixing the dead int16 normalization bug
  • SsbDemodulator.demod now correctly conjugates IQ for LSB mode

New issues found in this iteration:

  • HackRFServiceClient.transmit_iq normalizes large complex64 IQ arrays (np.clip, tobytes, base64.b64encode) synchronously on the event loop before POSTing — for a 10-second NFM clip this is ~500 MB of numpy/encode work that can block for several seconds
  • sd.play(blocking=True) in the new test_audio_device endpoint stalls the event loop for 100 ms per call
  • tmp.write(content) in the new /send-audio endpoint performs synchronous disk I/O of up to 100 MB on the event loop
  • The broker server reads TX_AUDIT_LOG_PATH from the environment, which does not match the pydantic config convention of RADIOSHAQ_RADIO__TX_AUDIT_LOG_PATH, leaving the file audit trail silently unconfigured for operators who set the path via YAML/config

Confidence Score: 3/5

  • This PR addresses many previously flagged critical issues and adds meaningful hardware integration, but a few blocking event-loop concerns remain in new code paths before this is production-ready.
  • The majority of critical issues from the previous review round have been resolved: server-side compliance enforcement is present, the Twilio auth bypass is fixed, dotenv is restored, the LSB demod bug is fixed, and test state is no longer set unconditionally at module import. The remaining issues are performance/correctness concerns: synchronous CPU/IO work on the event loop in HackRFServiceClient.transmit_iq and two new endpoints (send-audio, test-audio-device), plus an audit log path naming mismatch that would silently disable file-based audit trails in broker mode. None of these are security regressions, but the event-loop blocking in the remote TX path is a meaningful reliability concern for production.
  • radioshaq/radioshaq/radio/sdr_tx.py (HackRFServiceClient.transmit_iq IQ normalization), radioshaq/radioshaq/api/routes/audio.py (sd.play blocking), radioshaq/radioshaq/api/routes/radio.py (tmp.write blocking), radioshaq/radioshaq/remote_receiver/server.py (TX_AUDIT_LOG_PATH naming)

Important Files Changed

Filename Overview
radioshaq/radioshaq/remote_receiver/server.py Large addition: HackRFDeviceManager, HackRFBroker, /tx/tone, /tx/iq, /tx/status endpoints, and a refactored ReceiverService.stream_frequency. Server-side compliance checks are now present. Most previous critical issues (missing stream_frequency, unconditional test shim, broken audit trail) are addressed. New issues: TX_AUDIT_LOG_PATH env var doesn't align with pydantic config naming, leaving broker audit path silently unconfigured when the main config is used.
radioshaq/radioshaq/radio/sdr_tx.py Significant refactor: extracted _ComplianceCheckedTransmitter base class, added HackRFServiceClient for remote broker TX, moved IQ generation into executor. The global httpx monkey-patch was removed (now a module-local alias). New issue: HackRFServiceClient.transmit_iq normalizes large IQ arrays (np.clip, tobytes, base64.b64encode) synchronously on the event loop before posting — potentially blocking for several seconds on long clips.
radioshaq/radioshaq/remote_receiver/backends/hackrf_backend.py Added shared HackRFDeviceManager/broker support, NFM/AM/SSB/CW demod dispatch, and rx_active flag management. Demod is correctly offloaded to run_in_executor. Close/configure methods properly updated.
radioshaq/radioshaq/remote_receiver/backends/rtlsdr_backend.py Added NFM/AM/SSB/CW demod dispatch with run_in_executor offloading, configure() method, and raw_data/mode fields on SignalSample. Consistent with hackrf_backend pattern.
radioshaq/radioshaq/remote_receiver/dsp/analog.py New file: AM, SSB, and CW audio demodulators. SsbDemodulator now correctly applies np.conj(x) for LSB (previously-flagged bug is fixed). AmDemodulator includes DC removal. CwAudioDemodulator uses proper phase-continuous BFO mixing.
radioshaq/radioshaq/radio/fm.py New NFM modulator. _to_mono_float now correctly captures is_integer before mean() — the stereo int16 normalization bug from the previous review has been fixed. Pre-emphasis Python loop is still present but is called from run_in_executor in radio_tx.py.
radioshaq/radioshaq/radio/analog_mod.py New AM/SSB/CW modulation helpers. _to_mono_float applies the same corrected is_integer-before-mean pattern as fm.py. ssb_modulate correctly conjugates for LSB. No major issues found.
radioshaq/radioshaq/radio/hackrf_tx_compat.py New TX compatibility layer supporting three paths: direct libhackrf ctypes, start_tx()/buffer, and legacy callback. inspect.signature is now wrapped in try/except (ValueError, TypeError) to handle C extensions. assert statements are absent (replaced with explicit raises in the calling code).
radioshaq/radioshaq/radio/modes.py New normalized mode model (RadioModeName, ModeSpec, MODE_SPECS). normalize_mode now logs a warning on unrecognized input and falls back to NFM (previous issue addressed). Clean, well-structured.
radioshaq/radioshaq/config/schema.py Added sdr_tx_mode/sdr_tx_service_base_url/sdr_tx_service_token fields, TwilioConfig.allow_unsigned_webhooks, and corrected postgres URL host-only substitution using rsplit('@',1). dotenv_settings is now properly included in settings_customise_sources return tuple (previous bug fixed). YamlConfigSettingsSource is guarded with try/except for environments without it.
radioshaq/radioshaq/api/routes/twilio.py New Twilio SMS/WhatsApp webhook handlers. Missing auth_token now raises HTTP 503 unless allow_unsigned_webhooks=True (previous issue addressed). Missing Twilio SDK with configured auth_token raises HTTP 503. Signature validation is mandatory when auth_token is set. Opt-out keyword handling is implemented.
radioshaq/radioshaq/specialized/radio_tx.py SDR voice TX now supports NFM/AM/SSB/CW modulation from audio files. scipy modulation and soundfile I/O are both offloaded to run_in_executor. CW tone is also now in executor (previous issue addressed). Spectrum-based compliance check added before transmit_iq.
radioshaq/radioshaq/api/routes/radio.py New /send-audio endpoint for demo upload-and-transmit. File size is bounded at 100 MB. New issue: tmp.write(content) is synchronous disk I/O of up to 100 MB on the async event loop handler.
radioshaq/radioshaq/api/routes/audio.py test_audio_device endpoint now opens a real sounddevice stream. sd.play(blocking=True) blocks the async event loop for 100 ms on every test call — should be wrapped in run_in_executor.
radioshaq/radioshaq/orchestrator/factory.py Added HackRFServiceClient construction path for sdr_tx_mode='remote'. sdr_tx_service_token is properly threaded through. Loguru format strings updated throughout.

Sequence Diagram

sequenceDiagram
    participant Client
    participant HQServer as HQ Server (main API)
    participant RadioTxAgent as RadioTransmissionAgent
    participant ServiceClient as HackRFServiceClient
    participant ReceiverSvc as Remote Receiver (server.py)
    participant Broker as HackRFBroker
    participant DevMgr as HackRFDeviceManager
    participant HW as HackRF USB

    Client->>HQServer: POST /radio/send-audio (WAV file)
    HQServer->>RadioTxAgent: execute(task)
    RadioTxAgent->>RadioTxAgent: sf.read() [executor]
    RadioTxAgent->>RadioTxAgent: nfm_modulate() [executor]
    RadioTxAgent->>ServiceClient: transmit_iq(freq, iq_complex64, sample_rate)
    Note over ServiceClient: np.clip/tobytes/base64 [event loop ⚠️]
    ServiceClient->>ReceiverSvc: POST /tx/iq (JWT + base64 IQ)
    ReceiverSvc->>ReceiverSvc: _require_broker_auth()
    ReceiverSvc->>ReceiverSvc: is_restricted() + is_tx_spectrum_allowed()
    ReceiverSvc->>Broker: request_tx() → stop_rx flag set
    Broker->>DevMgr: with_device(_blocking_tx) [executor]
    DevMgr->>HW: stream_hackrf_iq_bytes()
    HW-->>DevMgr: done
    DevMgr-->>Broker: complete
    Broker->>Broker: clear_tx() → stop_rx cleared
    ReceiverSvc->>ReceiverSvc: log_tx(audit)
    ReceiverSvc-->>ServiceClient: {"success": true}
    ServiceClient-->>RadioTxAgent: ok
    RadioTxAgent-->>HQServer: {"success": true}
    HQServer-->>Client: 200 OK
Loading

Comments Outside Diff (2)

  1. radioshaq/radioshaq/radio/hackrf_tx_compat.py, line 135-157 (link)

    _stream_via_callback calls stop_tx() immediately after start_tx(), aborting transmission

    On HackRF drivers that implement start_tx(callback) asynchronously (the standard libhackrf pattern where the callback is fired from a USB transfer thread), calling dev.stop_tx() right after dev.start_tx(_tx_cb) terminates the USB streaming before the callback has a chance to fill any buffers. The result is that effectively zero data is transmitted.

    The test fake makes this look correct because its start_tx runs the callback synchronously to completion before returning. A real or semi-realistic async driver would not.

    This path is reached when libhackrf is None and start_tx takes a parameter, i.e. it is the fallback for environments without the ctypes bindings. It should wait for transmission to complete, similar to _stream_via_direct_libhackrf:

    def _stream_via_callback(dev: Any, payload: bytes, duration_sec: float) -> None:
        sent = [0]
    
        def _tx_cb(transfer: Any) -> int:
            # ... same callback logic ...
            pass
    
        dev.start_tx(_tx_cb)
        try:
            import time
            deadline = time.monotonic() + max(duration_sec + 0.5, 0.5)
            while time.monotonic() < deadline and sent[0] < len(payload):
                time.sleep(0.01)
        finally:
            dev.stop_tx()

    Note also that the current signature _stream_via_callback(dev, payload) is missing the duration_sec parameter that the fix above requires; stream_hackrf_iq_bytes would need to pass it through.

  2. radioshaq/radioshaq/remote_receiver/backends/hackrf_backend.py, line 222-270 (link)

    Lambda closures capture mutable self attributes — stale demodulator after retune

    Each run_in_executor lambda captures self._nfm, self._am, self._ssb, or self._cw at call time by closing over self. If set_frequency or configure is called concurrently (resetting the demodulators to None) while the executor is still running the lambda, the lambda holds a reference to the old demodulator object (that's fine), but the next iteration may construct a new demodulator before the old executor task finishes, causing two demodulators to operate on interleaved sample windows.

    The same late-binding issue: lambda: float_to_pcm16(self._nfm.demod(s)) captures self._nfm lazily — if self._nfm is reset to None between when the lambda is created and when the executor thread runs it, the lambda will raise AttributeError: 'NoneType' object has no attribute 'demod'.

    Capture the demodulator instance in a local variable before submitting to the executor:

    nfm = self._nfm  # stable reference for this iteration
    audio_pcm = await loop.run_in_executor(
        None, lambda: float_to_pcm16(nfm.demod(s))
    )

    The same pattern applies to all four demod branches.

Last reviewed commit: 724df38

Greptile also left 3 inline comments on this PR.

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai , comprehensively update your review based on the changes above :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai , comprehensively update your review based on all the changes above :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai , comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai , comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai , comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai , comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai : comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai : comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai : comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai : : comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai : comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai : comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai : comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai : comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

@Josephrp
Copy link
Copy Markdown
Owner Author

@greptileai , comprehensively update your review based on the changes above. do not overwrite the schema / flowchart , update your review , update the out of diff review :

Comment on lines +390 to +401
s = np.asarray(samples_iq)
if np.iscomplexobj(s):
i = (np.clip(np.real(s) * 127, -128, 127)).astype(np.int8)
q = (np.clip(np.imag(s) * 127, -128, 127)).astype(np.int8)
iq = np.empty(2 * len(s), dtype=np.int8)
iq[0::2] = i
iq[1::2] = q
else:
iq = np.asarray(s, dtype=np.int8)
duration_sec = len(iq) / (2.0 * sample_rate)
iq_bytes = iq.tobytes()
iq_b64 = base64.b64encode(iq_bytes).decode("ascii")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HackRFServiceClient.transmit_iq normalizes IQ synchronously on the event loop

radio_tx.py correctly offloads modulation (e.g. nfm_modulate) to a thread executor and returns a complex64 numpy array. That array is then passed directly to this method, which performs the int8 normalization, iq.tobytes(), and base64.b64encode() all synchronously on the event loop.

For a 10-second NFM clip at 2 MHz, samples_iq contains ~20 million complex64 elements (≈160 MB). The operations on lines 392–401 include:

  • np.real(s) — 80 MB temporary
  • np.clip(…) * 127 — 80 MB op
  • .astype(np.int8) × 2 — 20 MB each
  • iq.tobytes() — 40 MB copy
  • base64.b64encode(iq_bytes) — ~53 MB encode

The total wall-clock time is potentially several seconds, blocking every other coroutine in the server.

Consider wrapping lines 390–401 in a thread executor, consistent with how radio_tx.py handles modulation:

loop = asyncio.get_running_loop()

def _normalize():
    if isinstance(samples_iq, (bytes, bytearray, memoryview)):
        iq = np.frombuffer(samples_iq, dtype=np.int8)
    else:
        s = np.asarray(samples_iq)
        if np.iscomplexobj(s):
            i = (np.clip(np.real(s) * 127, -128, 127)).astype(np.int8)
            q = (np.clip(np.imag(s) * 127, -128, 127)).astype(np.int8)
            iq = np.empty(2 * len(s), dtype=np.int8)
            iq[0::2] = i
            iq[1::2] = q
        else:
            iq = np.asarray(s, dtype=np.int8)
    return iq.tobytes(), base64.b64encode(iq.tobytes()).decode("ascii"), len(iq) / (2.0 * sample_rate)

iq_bytes, iq_b64, duration_sec = await loop.run_in_executor(None, _normalize)

# Generate a short buffer of silence for playback test.
silence = np.zeros((frames, 1), dtype="float32")
# Open a stream for playback and (if supported) capture; immediately close.
sd.play(silence, samplerate=samplerate, device=device_id, blocking=True)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking audio playback on the async event loop

sd.play(..., blocking=True) is a synchronous audio I/O call. Even at only 0.1 seconds of audio it will block the event loop for approximately 100 ms, preventing all other coroutines (incoming requests, health checks, WebSocket streams) from running during that window.

Wrap this in run_in_executor to keep the event loop unblocked:

Suggested change
sd.play(silence, samplerate=samplerate, device=device_id, blocking=True)
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None, lambda: sd.play(silence, samplerate=samplerate, device=device_id, blocking=True)
)

You'll also need import asyncio at the top of this module if not already present.

Comment on lines +156 to +160
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
temp_path = tmp.name
try:
tmp.write(content)
tmp.close()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synchronous file write of up to 100 MB on the async event loop

tmp.write(content) is synchronous disk I/O performed directly on the async request handler. For a large audio file near the 100 MB limit, this write could take a noticeable amount of time (tens to hundreds of milliseconds on a slow disk), stalling all other requests on the event loop.

Consider wrapping the file write in run_in_executor:

import asyncio

loop = asyncio.get_running_loop()
try:
    await loop.run_in_executor(None, lambda: (tmp.write(content), tmp.close()))
except Exception:
    tmp.close()
    Path(temp_path).unlink(missing_ok=True)
    raise

Or alternatively, use aiofiles for async file writes if it is already a dependency.

@Josephrp Josephrp merged commit 4f78956 into dev Mar 14, 2026
3 checks passed
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