# Agent TX Protocol Operator-facing reference for the TX streaming extensions to the agent WebSocket protocol. Implementation plan: [agent_tx_implementation_plan.md](./agent_tx_implementation_plan.md). Cross-repo design: [agent_tx_plan.md](./agent_tx_plan.md). > **Regulatory note.** Transmission is regulated in every jurisdiction. The > agent-side interlocks documented below let you configure safe defaults > for your deployment. They do not replace licensing or responsibility > for your own emissions. The RIA Hub's consent modal and audit log make > actions attributable — they are not a legal-compliance layer. ## Opt-in TX is **disabled by default**. The hub cannot make the agent transmit unless the operator has explicitly opted in on the agent host. Two equivalent opt-in paths: ```bash # Persist to ~/.ria/agent.json so the agent always allows TX. ria-agent register --hub http://HUB:3005 --api-key KEY \ --allow-tx \ --tx-max-gain-db -10 \ --tx-max-duration-s 60 \ --tx-freq-range 2.4e9 2.5e9 \ --tx-freq-range 5.7e9 5.8e9 ``` ```bash # Runtime-only override (does not touch disk). ria-agent stream --allow-tx ``` Caps: | Flag | Config key | Effect | |---|---|---| | `--tx-max-gain-db VALUE` | `tx_max_gain_db` | Reject any `tx_start` whose `tx_gain > VALUE` | | `--tx-max-duration-s VALUE` | `tx_max_duration_s` | Auto-stop any TX session after `VALUE` seconds (watchdog in the TX loop) | | `--tx-freq-range LO HI` (repeatable) | `tx_allowed_freq_ranges` | Reject any `tx_start` whose `tx_center_frequency` falls outside all configured ranges | The agent enforces each cap **before** opening the SDR. A violating `tx_start` produces a `tx_status: error` frame and never touches hardware. ## Heartbeat advertisement Every heartbeat now includes: ```jsonc { "type": "heartbeat", "hardware": ["mock", "pluto"], "status": "streaming", "capabilities": ["rx", "tx"], // "tx" present only when tx_enabled=True "tx_enabled": true, "sessions": { // omitted when no session is live "rx": { "app_id": "app-1", "state": "streaming" }, "tx": { "app_id": "app-1", "state": "transmitting" } } } ``` Hubs should read `capabilities` to decide whether to surface TX operators against this agent in the Screens app composer. ## Control messages ### Hub → agent (JSON) ```jsonc // Arm the TX side. Agent validates interlocks, opens/resolves the SDR, // and transitions into "armed". The next binary frames are consumed as // TX IQ buffers. { "type": "tx_start", "app_id": "app-1", "radio_config": { "device": "pluto", "identifier": "ip:192.168.3.1", "tx_sample_rate": 1000000, "tx_center_frequency": 2450000000, "tx_gain": -20, // dB; Pluto uses negative attenuation "tx_bandwidth": 1000000, // optional "buffer_size": 1024, "underrun_policy": "pause" // "pause" (default) | "zero" | "repeat" } } // Update parameters at the next buffer boundary. No re-arm needed. { "type": "tx_configure", "app_id": "app-1", "radio_config": { "tx_gain": -25 } } // Stop TX, drain the inbound queue, pause_tx, release the SDR (if no RX // session is still using it). A new tx_start can follow immediately. { "type": "tx_stop", "app_id": "app-1" } ``` ### Hub → agent (binary) - Raw interleaved float32 IQ, normalised to `[-1, 1]`. - One WebSocket frame = one buffer = `buffer_size` complex samples = `buffer_size * 2 * 4` bytes. - Accepted only while a TX session is live. Frames outside that window are logged and dropped. - Malformed frames (odd float count, wrong size) trigger one underrun cycle but do not crash the stream. ### Agent → hub (JSON) ```jsonc { "type": "tx_status", "app_id": "app-1", "state": "armed" } { "type": "tx_status", "app_id": "app-1", "state": "transmitting" } { "type": "tx_status", "app_id": "app-1", "state": "underrun" } { "type": "tx_status", "app_id": "app-1", "state": "done" } { "type": "tx_status", "app_id": "app-1", "state": "error", "message": "tx_gain -5 exceeds cap -15.0" } ``` Transitions: ``` tx_start tx_stop —————————————————▶ armed ▶ transmitting ——————————▶ done │ │ │ │ queue empties + policy="pause" │ ▼ │ underrun ▶ done (auto-teardown) │ └─ interlock / init failure ▶ error (no session) ``` ## Underrun policies When the inbound TX queue is empty at a buffer boundary: | Policy | Behavior | |---|---| | `pause` *(default)* | Callback returns silence, calls `pause_tx()`, flips the session into `underrun`. Watchdog emits `tx_status: underrun` + `tx_status: done` and tears down. Hub must re-issue `tx_start` to resume. | | `zero` | Callback returns a zero-filled buffer. Session stays alive; no status change. Carrier continues with dead air. | | `repeat` | Callback returns the most recently transmitted buffer. If no buffer has arrived yet, falls back to zero for that cycle. | Choose `pause` for correctness-sensitive workloads (any data modulation where zero-fill or repeat corrupts the stream). Choose `zero` or `repeat` for continuous-carrier use cases where brief stalls are acceptable. ## Concurrent RX + TX A single `app_id` may hold both an RX session (`start`/`stop`) and a TX session (`tx_start`/`tx_stop`) on the same agent at the same time. When both reference the same `(device, identifier)`, the agent shares a single driver instance between the two sessions (ref-counted release on stop). Multi-app sharing of one SDR is not supported in v1. A second `tx_start` with a different `app_id` while another TX session is live produces `tx_status: error "tx already active on this agent"`. ## Buffer format recap - **Direction** is the only framing: hub → agent binary means TX, agent → hub binary means RX. - **Layout**: `[I0, Q0, I1, Q1, …]` as little-endian float32. - **Size**: `buffer_size * 2 * 4` bytes. Mismatched sizes are treated as a single-cycle underrun (malformed frame). - **Range**: samples must lie in `[-1, 1]`. Out-of-range values are transmitted as-is; the SDR driver may clip. ## Configuration reference `~/.ria/agent.json` is written by `ria-agent register` and read by `ria-agent stream`. Minimum schema with TX: ```json { "hub_url": "https://hub.example.com", "agent_id": "agent-abc123", "token": "rha_...", "tx_enabled": true, "tx_max_gain_db": -10.0, "tx_max_duration_s": 60, "tx_allowed_freq_ranges": [[2.4e9, 2.5e9], [5.7e9, 5.8e9]] } ``` File permissions are enforced to `0600` by `save()`.