15. PTT Safety Architecture (ART 0535)

Principle: Release is more safety-critical than keying. A lost setPTT:false can leave a radio transmitting indefinitely. Every layer must have an independent path to force RX.

15.1 Safety Model β€” Defense in Depth

Layer 1: Touch UX        touch-end / touch-move-out / touch-cancel β†’ TXControl('stop')
Layer 2: State Machine    isProcessing lock (3s leak detection) + pendingStop queue
Layer 3: Watchdog         30s hardware timer β†’ force release regardless of state
Layer 4: Command ACK      setPTT:false β†’ wait for getPTT:false echo β†’ retry 3Γ—
Layer 5: Backup Channel   TX audio WS sends "s:" β†’ independent socket β†’ forced-RX
Layer 6: PING Health      periodic PING/PONG β†’ stale connection detected β†’ reconnect
Layer 7: Page Lifecycle   visibilitychange / blur / pagehide β†’ force release
Layer 8: Server Forced-RX ws_ctrl() s: handler β†’ radio.set_ptt(False) β€” always served

No single point of failure can trap the radio in TX.

15.2 Layer 1 β€” Touch UX (tx_button.js)

Event Action Why
touchstart TXControl('start') Press begins TX
touchend TXControl('stop') Normal release
touchmove out of button TXControl('stop') Finger slides off β†’ safety release
touchcancel TXControl('stop') OS interrupts touch β†’ safety release
mouseleave (desktop) TXControl('stop') Cursor leaves button β†’ safety release

Each event handler uses touchId tracking to prevent cross-touch interference. { passive: false, capture: true } ensures the handlers fire before any other listener on the page and can call preventDefault().

15.3 Layer 2 β€” State Machine Lock (tx_button.js)

TXState.isProcessing  ──  prevents concurrent start/stop races
TXState.pendingStop    ──  queue stop request when start is in-flight
PROCESSING_LOCK_TIMEOUT_MS = 3000  ──  leak detection: force-unlock stuck lock

Lock lifecycle: - start: sets isProcessing=true, records processingStartTime, clears pendingStop. On completion, checks pendingStop β€” if set, immediately calls TXControl('stop'). - stop: if isProcessing, sets pendingStop=true and returns (queued). Otherwise acquires lock, executes release, unlocks. - Leak detection: if isProcessing held >3s, force-cleared. Prevents a single uncaught exception from permanently blocking all stop calls.

15.4 Layer 3 β€” Hardware Watchdog (tx_button.js)

30-second timeout started on every TX start.
  β†’ clears if TXControl('stop') runs normally
  β†’ fires: force-release PTT, clear audio, reset UI

The watchdog is the ultimate frontend failsafe. Even if every other layer fails (JS error, dead WebSocket, frozen event loop), within 30 seconds the watchdog timer fires and calls sendTRXptt(false) + full state cleanup.

15.5 Layer 4 β€” Command ACK Retry (ptt_manager.js)

setPTT:false sent
  β†’ start 1s ACK timer
    β†’ getPTT:false received (device confirmed RX) β†’ timer cancelled βœ“
    β†’ 1s elapsed, no confirmation:
      β†’ retry 1: re-send setPTT:false + backup s: on TX WS
      β†’ 1s elapsed, no confirmation:
        β†’ retry 2: re-send setPTT:false + backup s: on TX WS
      β†’ 1s elapsed, no confirmation:
        β†’ retry 3: re-send setPTT:false + backup s: on TX WS
      β†’ 1s elapsed, no confirmation:
        β†’ onControlConnectionDead() β€” force-reconnect control channel

Design rationale: setPTT:true (key) has no ACK β€” a lost key command means no TX, which is safe. setPTT:false (release) is the dangerous one: a lost release means stuck TX. The ACK retry loop ensures confirmation, using the TX audio socket as an independent backup channel on every retry.

15.6 Layer 5 β€” Backup Channel (s: command)

wsAudioTX.send("s:")  β†’  server ws_audio_tx() handler
  β†’  dsp_proc.modulator.reset_mic()
  β†’  radio.set_ptt(False)
  β†’  dsp_proc.demodulator.set_ptt(False)
  β†’  _send_ctrl("getPTT:false")  ←  broadcasts to ALL control clients

The s: command on the TX audio WebSocket is an independent path to force RX. It does not depend on the control WebSocket (/WSCTRX) being alive. The server-side handler in ws_audio_tx() directly calls radio.set_ptt(False) and broadcasts the state change.

Why this works: The TX audio socket and control socket are separate TCP connections. WiFi/router glitches that half-close one rarely affect the other simultaneously. This is the critical redundancy that breaks the single-point-of-failure.

15.7 Layer 6 β€” PING/PONG Health Check (controls.js)

Every 5s: wsControlTRX.send("PING")
  β†’ start PONG_TIMEOUT_MS timer
  β†’ PONG received β†’ round-trip latency display βœ“
  β†’ timer expired β†’ onControlConnectionDead()
    β†’ send s: on TX audio socket (if TX is pressed)
    β†’ force-close and reconnect control socket

TCP half-open connections (where the server thinks the socket is alive but the client’s WiFi has dropped) are the most insidious failure mode. PING/PONG detection catches these within one timeout cycle, triggers backup release before reconnect.

15.8 Layer 7 β€” Page Lifecycle (tx_button.js)

document.addEventListener('visibilitychange', ...)
window.addEventListener('blur', ...)
window.addEventListener('pagehide', ...)
  β†’ if TXState.isPressed β†’ TXControl('stop')

When the browser tab is backgrounded, the OS may suspend timers and JavaScript execution. If the user switches away while TX is active, the touch-end event is never delivered. These lifecycle hooks catch that case and force release.

15.9 Layer 8 β€” Server-Side Forced RX (server.py)

# ws_ctrl() handler β€” primary control path
elif cmd == "setPTT":
    tx = val.lower() == "true"
    await radio.set_ptt(tx)
    if dsp_proc: dsp_proc.demodulator.set_ptt(tx)
    await ws.send_text(f"getPTT:{str(tx).lower()}")

# ws_ctrl() handler β€” backup forced-RX
elif cmd == "s":
    await radio.set_ptt(False)
    if dsp_proc: dsp_proc.demodulator.set_ptt(False)
    await _send_ctrl("getPTT:false")

The server echoes every PTT state change as getPTT:<bool> over the control channel. The client uses this echo as delivery confirmation (Layer 4). The s: command is handled identically to setPTT:false β€” no difference in server behavior, just an alternative entry point.

On the device side, radio.set_ptt(True) re-sends the DRIVE byte (0x0017) before keying, and radio.set_ptt(False) sends the PTT command with trailing=0 to release.

15.10 TX Pacer Thread Lifecycle

PTT asserted  β†’  TX pacer thread starts
  β†’ TX_SETTLE_PACKETS (17) zero-IQ packets (PA/relay settling)
  β†’ TX_RAMP_SAMPLES (200) linear ramp 0β†’1
  β†’ Real mic IQ or tune IQ at 5.12ms/pkt
PTT released  β†’  TX pacer thread stops

The pacer thread is tied to radio._ptt_active. When PTT releases (from any path), the while-loop exits, the thread joins, and no further IQ packets are sent. This is the hardware-level guarantee that TX stops when PTT is false.

The TX modulation path is pure Python (Hilbert SSB in dsp.py TXModulator); the WDSP C-chain was removed and has no bearing on PTT safety. All safety layers operate above the DSP level.

15.11 PTT Status Display

State Color Meaning
PTT: ON Green Device confirmed TX active
PTT: ON Yellow Predicted (command sent, not yet confirmed)
PTT: OFF Red Device confirmed RX
PTT: OFF Orange Predicted release (not yet confirmed)
PTT: ? Gray blinking State unknown (prediction expired)

The display always prefers device-confirmed state over prediction. A background sync loop (1 Hz) queries getPTT if device state disagrees with user intent.

15.12 Failure Mode Coverage

Failure Layer Mitigation
Finger slips off button 1 touchmove out-of-bounds β†’ stop
OS steals touch (notification) 1 touchcancel β†’ stop
JS exception in start() blocks stop() 2 pendingStop queue + lock leak detection
Event loop frozen / timer starvation 3 30s hardware watchdog (setTimeout-based)
setPTT:false lost (WiFi packet loss) 4 3Γ— ACK retry with getPTT:false confirmation
Control WS half-open (TCP stale) 5,6 Backup s: on TX WS + PING/PONG detection
Control WS fully dead 5 TX WS s: command β†’ server forced-RX
User switches app (iOS suspend) 7 visibilitychange + blur β†’ force release
All frontend paths fail 8 Server s: handler β€” always available

15.13 Component Mapping

Component File Role
TXButton tx_button.js Touch UX, state machine, lock, watchdog, lifecycle hooks
PTTManager ptt_manager.js ACK retry, release confirmation, status display, debounce
ControlWS server.py ws_ctrl() Primary setPTT dispatch, getPTT echo, s: forced-RX
TXAudioWS server.py ws_audio_tx() Backup s: path β€” independent TCP connection
RadioClient sunsdr_direct.py set_ptt() DRIVE re-send (TX assert), PTT command, _ptt_active flag
TXPacer server.py _tx_pacer_thread() Thread lifecycle tied to _ptt_active β†’ stops on release
HealthCheck controls.js checklatency() PING/PONG dead-connection detection + reconnect