diff --git a/build.sh b/build.sh index 8fa9908..319e433 100755 --- a/build.sh +++ b/build.sh @@ -21,6 +21,7 @@ FILES=( system.prop system/ webroot/ + scripts/ BUILDING_MODULES.md ) diff --git a/driver-manager-v1.0.0.zip b/driver-manager-v1.0.0.zip index 719dd1d..debe592 100644 Binary files a/driver-manager-v1.0.0.zip and b/driver-manager-v1.0.0.zip differ diff --git a/scripts/dvbt_rx.py b/scripts/dvbt_rx.py new file mode 100755 index 0000000..c35e4c6 --- /dev/null +++ b/scripts/dvbt_rx.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +""" +DVB-T Digital TV Receiver using RTL-SDR and GNU Radio + +Receives DVB-T signals and outputs MPEG-TS stream to stdout. +Usage: + python3 dvbt_rx.py --freq 506000000 | mpv - + python3 dvbt_rx.py --freq 506e6 --input-file recording.raw | vlc - +""" + +import argparse +import signal +import sys +import os + +from gnuradio import gr, blocks, fft, dtv +import osmosdr + + +# DVB-T parameter tables +BANDWIDTH_MAP = { + 8: (64.0 / 7e6, dtv.BANDWIDTH_8_0_MHZ), # 9142857.14 Hz + 7: (1.0 / 8e6, dtv.BANDWIDTH_7_0_MHZ), # 8000000 Hz + 6: (7.0 / 48e6, dtv.BANDWIDTH_6_0_MHZ), # 6857142.86 Hz +} + +TRANSMISSION_MODE_MAP = { + '2k': (dtv.T2k, 2048, 1705), + '8k': (dtv.T8k, 8192, 6817), +} + +CONSTELLATION_MAP = { + 'qpsk': dtv.MOD_QPSK, + '16qam': dtv.MOD_16QAM, + '64qam': dtv.MOD_64QAM, +} + +CODE_RATE_MAP = { + '1/2': dtv.C1_2, + '2/3': dtv.C2_3, + '3/4': dtv.C3_4, + '5/6': dtv.C5_6, + '7/8': dtv.C7_8, +} + +GUARD_INTERVAL_MAP = { + '1/4': (dtv.GI_1_4, 4), + '1/8': (dtv.GI_1_8, 8), + '1/16': (dtv.GI_1_16, 16), + '1/32': (dtv.GI_1_32, 32), +} + + +def parse_freq(s): + """Parse frequency string, supporting scientific notation like 506e6.""" + return int(float(s)) + + +class DVBTReceiver(gr.top_block): + def __init__(self, args): + gr.top_block.__init__(self, "DVB-T Receiver") + + # Resolve parameters + tx_mode_enum, fft_length, active_carriers = TRANSMISSION_MODE_MAP[args.transmission_mode] + constellation = CONSTELLATION_MAP[args.constellation] + code_rate = CODE_RATE_MAP[args.code_rate] + gi_enum, gi_divisor = GUARD_INTERVAL_MAP[args.guard_interval] + cp_length = fft_length // gi_divisor + + # Sample rate from bandwidth + # DVB-T sample rate = bandwidth * 8/7 (for 8 MHz: 64/7 MHz) + bw_mhz = args.bandwidth + if bw_mhz == 8: + samp_rate = 64e6 / 7.0 # 9142857.14 Hz + elif bw_mhz == 7: + samp_rate = 8e6 + else: + samp_rate = 48e6 / 7.0 # 6857142.86 Hz + + print(f"--- DVB-T Receiver ---", file=sys.stderr) + print(f"Frequency: {args.freq / 1e6:.3f} MHz", file=sys.stderr) + print(f"Bandwidth: {bw_mhz} MHz", file=sys.stderr) + print(f"Sample rate: {samp_rate:.0f} Hz", file=sys.stderr) + print(f"Mode: {args.transmission_mode} (FFT={fft_length})", file=sys.stderr) + print(f"Constellation: {args.constellation}", file=sys.stderr) + print(f"Code rate: {args.code_rate}", file=sys.stderr) + print(f"Guard interval:{args.guard_interval} (CP={cp_length})", file=sys.stderr) + print(f"", file=sys.stderr) + + # --- Source --- + if args.input_file: + print(f"Source: file '{args.input_file}'", file=sys.stderr) + self.source = blocks.file_source( + gr.sizeof_gr_complex, args.input_file, repeat=False + ) + else: + print(f"Source: RTL-SDR (live)", file=sys.stderr) + self.source = osmosdr.source(args="numchan=1") + self.source.set_sample_rate(samp_rate) + self.source.set_center_freq(args.freq) + self.source.set_freq_corr(args.ppm) + self.source.set_gain_mode(True) + self.source.set_gain(args.gain) + self.source.set_if_gain(args.if_gain) + self.source.set_bb_gain(0) + + actual_rate = self.source.get_sample_rate() + if abs(actual_rate - samp_rate) > 1000: + print(f"WARNING: Requested sample rate {samp_rate:.0f} Hz but got {actual_rate:.0f} Hz", + file=sys.stderr) + print(f"WARNING: RTL-SDR max is ~2.4 Msps. DVB-T {bw_mhz}MHz needs {samp_rate:.0f} Hz.", + file=sys.stderr) + print(f"WARNING: Signal decoding will likely fail. Use --input-file for testing.", + file=sys.stderr) + + # --- OFDM Demodulation Chain --- + + # Stream -> Vector for OFDM processing + self.s2v_ofdm = blocks.stream_to_vector(gr.sizeof_gr_complex, fft_length) + + # OFDM symbol acquisition (timing sync, CP removal) + self.ofdm_acq = dtv.dvbt_ofdm_sym_acquisition( + blocks=1, + fft_length=fft_length, + occupied_tones=active_carriers, + cp_length=cp_length, + snr=100.0 + ) + + # FFT: time domain -> frequency domain + self.fft = fft.fft_vcc( + fft_size=fft_length, + forward=True, + window=[], + shift=False + ) + + # Channel estimation and equalization using reference signals + self.demod_ref = dtv.dvbt_demod_reference_signals( + itemsize=gr.sizeof_gr_complex, + ninput=fft_length, + noutput=active_carriers, + constellation=constellation, + hierarchy=dtv.NH, + code_rate_HP=code_rate, + code_rate_LP=dtv.C1_2, + guard_interval=gi_enum, + transmission_mode=tx_mode_enum, + include_cell_id=0, + cell_id=0 + ) + + # --- Demapping and Decoding Chain --- + + # Vector -> Stream for demapper + self.v2s_demod = blocks.vector_to_stream(gr.sizeof_gr_complex, active_carriers) + + # Constellation demapping (soft decision) + self.demap = dtv.dvbt_demap( + nsize=active_carriers, + constellation=constellation, + hierarchy=dtv.NH, + transmission=tx_mode_enum, + gain=1.0 + ) + + # Bit-level inner deinterleaver + self.bit_deintlv = dtv.dvbt_bit_inner_deinterleaver( + nsize=active_carriers, + constellation=constellation, + hierarchy=dtv.NH, + transmission=tx_mode_enum + ) + + # Viterbi decoder (inner FEC) + self.viterbi = dtv.dvbt_viterbi_decoder( + constellation=constellation, + hierarchy=dtv.NH, + coderate=code_rate, + bsize=active_carriers + ) + + # Byte-level convolutional deinterleaver + self.conv_deintlv = dtv.dvbt_convolutional_deinterleaver( + nsize=136, + I=12, + M=17 + ) + + # Reed-Solomon decoder (outer FEC) - RS(204,188,t=8) shortened from RS(255,239) + self.rs_dec = dtv.dvbt_reed_solomon_dec( + p=2, + m=8, + gfpoly=0x11d, + n=255, + k=239, + t=8, + s=51, + blocks=8 + ) + + # Energy descrambler - restores MPEG-TS sync bytes + self.descramble = dtv.dvbt_energy_descramble(nblocks=8) + + # --- Output: MPEG-TS to stdout --- + self.sink = blocks.file_descriptor_sink(gr.sizeof_char, 1) # fd=1 = stdout + + # --- Wire the flowgraph --- + self.connect( + self.source, + self.s2v_ofdm, + self.ofdm_acq, + self.fft, + self.demod_ref, + self.v2s_demod, + self.demap, + self.bit_deintlv, + self.viterbi, + self.conv_deintlv, + self.rs_dec, + self.descramble, + self.sink + ) + + print("Flowgraph built. Starting...", file=sys.stderr) + + +def main(): + parser = argparse.ArgumentParser( + description='DVB-T Digital TV Receiver', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + Live reception (requires SDR with sufficient sample rate): + python3 dvbt_rx.py --freq 506000000 | mpv - + + From recorded IQ file: + python3 dvbt_rx.py --freq 506e6 --input-file recording.cf32 | vlc - + + Record IQ samples first (if you have a capable SDR): + rtl_sdr -f 506000000 -s 9142857 -g 40 recording.raw +""" + ) + + parser.add_argument('--freq', type=parse_freq, required=True, + help='Center frequency in Hz (e.g., 506000000 or 506e6)') + parser.add_argument('--bandwidth', type=int, default=8, choices=[6, 7, 8], + help='Channel bandwidth in MHz (default: 8)') + parser.add_argument('--transmission-mode', default='8k', choices=['2k', '8k'], + help='Transmission mode (default: 8k)') + parser.add_argument('--constellation', default='64qam', + choices=['qpsk', '16qam', '64qam'], + help='Constellation (default: 64qam)') + parser.add_argument('--code-rate', default='2/3', + choices=['1/2', '2/3', '3/4', '5/6', '7/8'], + help='Code rate (default: 2/3)') + parser.add_argument('--guard-interval', default='1/32', + choices=['1/4', '1/8', '1/16', '1/32'], + help='Guard interval (default: 1/32)') + parser.add_argument('--gain', type=float, default=40.0, + help='RF gain in dB (default: 40)') + parser.add_argument('--if-gain', type=float, default=40.0, + help='IF gain in dB (default: 40)') + parser.add_argument('--ppm', type=float, default=0.0, + help='Frequency correction in PPM (default: 0)') + parser.add_argument('--input-file', type=str, default=None, + help='Read IQ from file instead of RTL-SDR (complex float32)') + + args = parser.parse_args() + + # Make stdout binary for MPEG-TS output + if hasattr(sys.stdout, 'buffer'): + sys.stdout = sys.stdout.buffer + + tb = DVBTReceiver(args) + + def signal_handler(sig, frame): + print("\nStopping...", file=sys.stderr) + tb.stop() + tb.wait() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGPIPE, signal.SIG_DFL) + + tb.start() + try: + tb.wait() + except KeyboardInterrupt: + tb.stop() + tb.wait() + + +if __name__ == '__main__': + main() diff --git a/scripts/kodi_dvbt_setup.sh b/scripts/kodi_dvbt_setup.sh new file mode 100755 index 0000000..fdf6264 --- /dev/null +++ b/scripts/kodi_dvbt_setup.sh @@ -0,0 +1,168 @@ +#!/system/bin/sh +# Kodi DVB-T Live TV Setup +# +# Sets up the pipeline: RTL-SDR -> DVB-T demod -> MPEG-TS -> HTTP -> Kodi +# +# Kodi PVR connection: +# 1. Install "PVR IPTV Simple Client" addon in Kodi +# 2. Configure M3U playlist URL: http://127.0.0.1:8554/dvbt.m3u +# 3. Or add single channel: http://127.0.0.1:8554 +# +# This script generates the M3U playlist from configured channels +# and starts the DVB-T HTTP stream server. + +MODDIR="/data/adb/modules/driver-manager" +CONFDIR="$MODDIR/config" +STREAMDIR="$MODDIR/streams" +LOGFILE="$MODDIR/driver-manager.log" +KODI_PORT=$(cat "$CONFDIR/kodi_stream_port" 2>/dev/null || echo "8554") +CHANNELS_FILE="$CONFDIR/dvbt_channels.conf" + +mkdir -p "$STREAMDIR" + +mlog() { + echo "$(date '+%Y-%m-%d %H:%M:%S') [kodi] $1" >> "$LOGFILE" +} + +# Generate M3U playlist from channel config +# Format of dvbt_channels.conf: +# # Channel Name | Frequency (Hz) | Bandwidth | TX Mode | Constellation | Code Rate | Guard +# BBC One|506000000|8|8k|64qam|2/3|1/32 +# ITV|514000000|8|8k|64qam|2/3|1/32 +generate_m3u() { + M3U="$STREAMDIR/dvbt.m3u" + echo '#EXTM3U' > "$M3U" + + if [ ! -f "$CHANNELS_FILE" ]; then + # Create default channel config + cat > "$CHANNELS_FILE" << 'CHANNELS' +# DVB-T Channel List +# Format: Name|Frequency(Hz)|Bandwidth(MHz)|TxMode|Constellation|CodeRate|Guard +# Edit this file to add your local channels +# Find channels with: rtl_mode_switch.sh spectrum (then check results) +# +# Example channels (UK Freeview): +Channel 1|506000000|8|8k|64qam|2/3|1/32 +Channel 2|514000000|8|8k|64qam|2/3|1/32 +Channel 3|522000000|8|8k|64qam|2/3|1/32 +CHANNELS + mlog "Created default channel config at $CHANNELS_FILE" + fi + + CH_NUM=1 + while IFS='|' read -r NAME FREQ BW TXMODE CONST CR GUARD; do + # Skip comments and empty lines + case "$NAME" in + \#*|"") continue ;; + esac + + # Each channel gets its own stream port + CH_PORT=$((KODI_PORT + CH_NUM)) + echo "#EXTINF:-1,$NAME" >> "$M3U" + echo "http://127.0.0.1:$CH_PORT" >> "$M3U" + CH_NUM=$((CH_NUM + 1)) + done < "$CHANNELS_FILE" + + mlog "Generated M3U playlist: $M3U ($((CH_NUM - 1)) channels)" +} + +# Start a stream server for a specific channel +# Called when Kodi connects to a channel URL +start_channel_stream() { + CH_PORT=$1 + FREQ=$2 + BW=$3 + TXMODE=$4 + CONST=$5 + CR=$6 + GUARD=$7 + CH_NAME=$8 + + TERMUX="/data/data/com.termux/files/usr/bin" + SDR_TV="$MODDIR/scripts/sdr_tv.py" + + FIFO="$STREAMDIR/ch_${CH_PORT}.ts" + rm -f "$FIFO" + mkfifo "$FIFO" 2>/dev/null + + if [ -x "$TERMUX/python3" ] && [ -f "$SDR_TV" ]; then + # Start DVB-T demodulator + "$TERMUX/python3" "$SDR_TV" dvbt \ + --freq "$FREQ" \ + --bandwidth "$BW" \ + --transmission-mode "$TXMODE" \ + --constellation "$CONST" \ + --code-rate "$CR" \ + --guard-interval "$GUARD" \ + > "$FIFO" 2>>"$LOGFILE" & + DVB_PID=$! + + # HTTP stream server for this channel + (while true; do + (echo -e "HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r"; cat "$FIFO") | \ + nc -l -p "$CH_PORT" 2>/dev/null + # If Kodi disconnects, kill the demodulator + kill "$DVB_PID" 2>/dev/null + break + done) & + + mlog "Channel stream started: $CH_NAME on port $CH_PORT (freq=$FREQ)" + else + mlog "ERROR: python3 or sdr_tv.py not found for channel stream" + fi +} + +# Serve M3U playlist on the base port +serve_m3u() { + M3U="$STREAMDIR/dvbt.m3u" + (while true; do + M3U_CONTENT=$(cat "$M3U" 2>/dev/null) + RESPONSE="HTTP/1.1 200 OK\r\nContent-Type: audio/x-mpegurl\r\nContent-Length: ${#M3U_CONTENT}\r\nConnection: close\r\n\r\n${M3U_CONTENT}" + echo -e "$RESPONSE" | nc -l -p "$KODI_PORT" 2>/dev/null + done) & + mlog "M3U server on port $KODI_PORT" +} + +case "$1" in + setup) + generate_m3u + serve_m3u + mlog "Kodi DVB-T setup complete" + mlog "Add to Kodi PVR IPTV Simple Client:" + mlog " M3U URL: http://127.0.0.1:$KODI_PORT/dvbt.m3u" + echo "Kodi setup complete." + echo "In Kodi: Settings > PVR & Live TV > PVR IPTV Simple Client" + echo "M3U URL: http://127.0.0.1:$KODI_PORT/dvbt.m3u" + ;; + scan) + # Scan for DVB-T channels using spectrum analysis + echo "Scanning for DVB-T channels..." + echo "Common DVB-T frequency ranges:" + echo " VHF Band III: 174-230 MHz" + echo " UHF Band IV: 470-582 MHz" + echo " UHF Band V: 582-862 MHz" + echo "" + echo "Starting spectrum scan on UHF band..." + "$MODDIR/scripts/rtl_mode_switch.sh" spectrum + echo "Check $MODDIR/streams/spectrum_data.csv for results" + ;; + channels) + # List configured channels + if [ -f "$CHANNELS_FILE" ]; then + echo "Configured DVB-T channels:" + grep -v "^#" "$CHANNELS_FILE" | grep -v "^$" | while IFS='|' read -r NAME FREQ BW TXMODE CONST CR GUARD; do + FREQ_MHZ=$(echo "scale=1; $FREQ / 1000000" | bc 2>/dev/null || echo "$FREQ") + echo " $NAME — ${FREQ_MHZ} MHz (${BW}MHz BW, $TXMODE, $CONST, $CR, $GUARD)" + done + else + echo "No channels configured. Edit: $CHANNELS_FILE" + fi + ;; + *) + echo "Usage: kodi_dvbt_setup.sh {setup|scan|channels}" + echo "" + echo " setup - Generate M3U and start stream servers" + echo " scan - Scan spectrum for DVB-T channels" + echo " channels - List configured channels" + ;; +esac diff --git a/scripts/rtl_mode_switch.sh b/scripts/rtl_mode_switch.sh new file mode 100755 index 0000000..b4f6eea --- /dev/null +++ b/scripts/rtl_mode_switch.sh @@ -0,0 +1,235 @@ +#!/system/bin/sh +# RTL-SDR Mode Switcher +# Switches between DVB-T (digital TV), FM Radio, and SDR scanner modes +# Manages the userspace process that controls the RTL-SDR dongle +# +# Only one mode can use the dongle at a time. This script kills +# the active process before starting a new one. + +MODDIR="/data/adb/modules/driver-manager" +CONFDIR="$MODDIR/config" +LOGFILE="$MODDIR/driver-manager.log" +PIDDIR="$MODDIR/run" +TERMUX="/data/data/com.termux/files/usr/bin" +STREAMDIR="$MODDIR/streams" + +mkdir -p "$PIDDIR" "$STREAMDIR" + +mlog() { + echo "$(date '+%Y-%m-%d %H:%M:%S') [rtl_switch] $1" >> "$LOGFILE" +} + +# Kill any running RTL process that holds the dongle +kill_rtl() { + for pidfile in "$PIDDIR"/rtl_*.pid; do + [ -f "$pidfile" ] || continue + PID=$(cat "$pidfile") + if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then + kill "$PID" 2>/dev/null + sleep 1 + kill -9 "$PID" 2>/dev/null + mlog "Killed PID $PID ($(basename "$pidfile" .pid))" + fi + rm -f "$pidfile" + done + # Also catch any strays + pkill -f rtl_tcp 2>/dev/null + pkill -f rtl_fm 2>/dev/null + pkill -f rtl_adsb 2>/dev/null + pkill -f rtl_power 2>/dev/null + pkill -f dvbt_rx 2>/dev/null + pkill -f sdr_tv 2>/dev/null + sleep 1 +} + +# Start rtl_tcp server — base layer for all modes +# Apps connect to localhost:1234 for I/Q data +start_rtl_tcp() { + PORT=$(cat "$CONFDIR/rtl_tcp_port" 2>/dev/null || echo "1234") + GAIN=$(cat "$CONFDIR/rtl_gain" 2>/dev/null || echo "0") + SRATE=$(cat "$CONFDIR/rtl_samplerate" 2>/dev/null || echo "2048000") + FREQ=$(cat "$CONFDIR/rtl_freq" 2>/dev/null || echo "100000000") + + if [ -x "$TERMUX/rtl_tcp" ]; then + "$TERMUX/rtl_tcp" -a 127.0.0.1 -p "$PORT" -f "$FREQ" -s "$SRATE" -g "$GAIN" & + echo $! > "$PIDDIR/rtl_tcp.pid" + mlog "rtl_tcp started on port $PORT (freq=$FREQ srate=$SRATE gain=$GAIN)" + else + mlog "ERROR: rtl_tcp not found — install in Termux: pkg install rtl-sdr" + return 1 + fi +} + +MODE="$1" +[ -z "$MODE" ] && MODE=$(cat "$CONFDIR/rtl_mode" 2>/dev/null || echo "off") + +case "$MODE" in + + # ================================================================= + # DVB-T Digital TV Mode + # ================================================================= + # Receives DVB-T signal, outputs MPEG-TS stream + # Stream is served via HTTP on port 8554 for Kodi to consume + dvbt) + kill_rtl + FREQ=$(cat "$CONFDIR/dvbt_freq" 2>/dev/null || echo "506000000") + BW=$(cat "$CONFDIR/dvbt_bandwidth" 2>/dev/null || echo "8") + TXMODE=$(cat "$CONFDIR/dvbt_txmode" 2>/dev/null || echo "8k") + CONSTELLATION=$(cat "$CONFDIR/dvbt_constellation" 2>/dev/null || echo "64qam") + CODERATE=$(cat "$CONFDIR/dvbt_coderate" 2>/dev/null || echo "2/3") + GUARD=$(cat "$CONFDIR/dvbt_guard" 2>/dev/null || echo "1/32") + KODI_PORT=$(cat "$CONFDIR/kodi_stream_port" 2>/dev/null || echo "8554") + + # Use the sdr_tv.py DVB-T receiver if available + SDR_TV="$MODDIR/scripts/sdr_tv.py" + DVBT_RX="$MODDIR/scripts/dvbt_rx.py" + + if [ -x "$TERMUX/python3" ] && [ -f "$SDR_TV" ]; then + # Pipe MPEG-TS to a local HTTP server for Kodi + # Using socat or a simple netcat HTTP wrapper + FIFO="$STREAMDIR/dvbt.ts" + rm -f "$FIFO" + mkfifo "$FIFO" 2>/dev/null + + # Start DVB-T receiver, output MPEG-TS to FIFO + "$TERMUX/python3" "$SDR_TV" dvbt \ + --freq "$FREQ" \ + --bandwidth "$BW" \ + --transmission-mode "$TXMODE" \ + --constellation "$CONSTELLATION" \ + --code-rate "$CODERATE" \ + --guard-interval "$GUARD" \ + > "$FIFO" 2>>"$LOGFILE" & + echo $! > "$PIDDIR/rtl_dvbt.pid" + + # HTTP stream server — serves MPEG-TS on port for Kodi + # Kodi connects to http://127.0.0.1:8554/dvbt.ts + (while true; do + cat "$FIFO" | { + read -r _ # wait for connection + echo -e "HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nConnection: close\r\n\r" + cat + } | nc -l -p "$KODI_PORT" 2>/dev/null + done) & + echo $! > "$PIDDIR/rtl_dvbt_http.pid" + + mlog "DVB-T started: freq=$FREQ bw=${BW}MHz mode=$TXMODE" + mlog "Kodi stream: http://127.0.0.1:$KODI_PORT" + + elif [ -x "$TERMUX/rtl_tcp" ]; then + # Fallback: just start rtl_tcp, let an Android DVB app handle it + echo "$FREQ" > "$CONFDIR/rtl_freq" + start_rtl_tcp + mlog "DVB-T fallback: rtl_tcp on port 1234, use SDR Touch or rtl_tcp_andro" + else + mlog "ERROR: no DVB-T tools found" + fi + + echo "dvbt" > "$CONFDIR/rtl_mode" + ;; + + # ================================================================= + # FM Radio Mode + # ================================================================= + fm) + kill_rtl + FREQ=$(cat "$CONFDIR/fm_freq" 2>/dev/null || echo "100000000") + GAIN=$(cat "$CONFDIR/rtl_gain" 2>/dev/null || echo "0") + + if [ -x "$TERMUX/rtl_fm" ]; then + # Demodulate FM and pipe to audio output + "$TERMUX/rtl_fm" -f "$FREQ" -M wbfm -s 200000 -r 48000 -g "$GAIN" - 2>>"$LOGFILE" | \ + "$TERMUX/play" -r 48000 -b 16 -c 1 -e signed-integer -t raw - 2>/dev/null & + echo $! > "$PIDDIR/rtl_fm.pid" + mlog "FM radio started: freq=$FREQ gain=$GAIN" + else + mlog "ERROR: rtl_fm not found — install in Termux: pkg install rtl-sdr" + fi + + echo "fm" > "$CONFDIR/rtl_mode" + ;; + + # ================================================================= + # SDR Scanner Mode + # ================================================================= + # Starts rtl_tcp server for any SDR app to connect + sdr) + kill_rtl + start_rtl_tcp + echo "sdr" > "$CONFDIR/rtl_mode" + ;; + + # ================================================================= + # ADS-B Aircraft Tracking + # ================================================================= + adsb) + kill_rtl + if [ -x "$TERMUX/rtl_adsb" ]; then + "$TERMUX/rtl_adsb" -g 50 > "$STREAMDIR/adsb_output.txt" 2>>"$LOGFILE" & + echo $! > "$PIDDIR/rtl_adsb.pid" + mlog "ADS-B tracking started (1090 MHz)" + else + mlog "ERROR: rtl_adsb not found" + fi + echo "adsb" > "$CONFDIR/rtl_mode" + ;; + + # ================================================================= + # Spectrum Scanner + # ================================================================= + spectrum) + kill_rtl + RANGE=$(cat "$CONFDIR/spectrum_range" 2>/dev/null || echo "24M:1800M") + if [ -x "$TERMUX/rtl_power" ]; then + "$TERMUX/rtl_power" -f "$RANGE" -g 50 -i 1 "$STREAMDIR/spectrum_data.csv" 2>>"$LOGFILE" & + echo $! > "$PIDDIR/rtl_spectrum.pid" + mlog "Spectrum scan started: $RANGE" + else + mlog "ERROR: rtl_power not found" + fi + echo "spectrum" > "$CONFDIR/rtl_mode" + ;; + + # ================================================================= + # HackRF TX/RX Mode + # ================================================================= + hackrf) + kill_rtl + # HackRF uses its own USB interface, doesn't conflict with RTL-SDR + # Just set the mode flag — apps handle the rest + mlog "HackRF mode set — use hackrf_transfer or rtl_tcp_andro" + echo "hackrf" > "$CONFDIR/rtl_mode" + ;; + + # ================================================================= + # Off + # ================================================================= + off) + kill_rtl + mlog "All SDR processes stopped" + echo "off" > "$CONFDIR/rtl_mode" + ;; + + # ================================================================= + # Status + # ================================================================= + status) + CURRENT=$(cat "$CONFDIR/rtl_mode" 2>/dev/null || echo "off") + echo "Mode: $CURRENT" + for pidfile in "$PIDDIR"/rtl_*.pid; do + [ -f "$pidfile" ] || continue + PID=$(cat "$pidfile") + NAME=$(basename "$pidfile" .pid) + if kill -0 "$PID" 2>/dev/null; then + echo " $NAME: running (PID $PID)" + else + echo " $NAME: dead" + fi + done + ;; + + *) + echo "Usage: rtl_mode_switch.sh {dvbt|fm|sdr|adsb|spectrum|hackrf|off|status}" + exit 1 + ;; +esac diff --git a/scripts/sdr_tv.py b/scripts/sdr_tv.py new file mode 100755 index 0000000..b3ac731 --- /dev/null +++ b/scripts/sdr_tv.py @@ -0,0 +1,714 @@ +#!/usr/bin/env python3 +""" +SDR TV & RF Analysis Tool + +Multi-mode SDR application for RTL-SDR V4: + - DVB-T digital TV reception (MPEG-TS output) + - Analog TV reception (PAL/NTSC via FM demod) + - Spectrum analyzer (terminal-based power display) + - Wireless camera scanner (common security camera frequencies) + +Designed for authorized RF security testing and research. + +Usage: + python3 sdr_tv.py dvbt --freq 506e6 | mpv - + python3 sdr_tv.py analog --freq 175.25e6 --standard pal | mpv --demuxer=rawvideo --rawvideo=w=768:h=576:format=gray - + python3 sdr_tv.py spectrum --freq 500e6 --span 20e6 + python3 sdr_tv.py camera --preset 1.2ghz +""" + +import argparse +import signal +import sys +import time +import struct +import threading +import numpy as np + +from gnuradio import gr, blocks, fft, dtv, analog, filter +import osmosdr + + +# ============================================================================= +# Constants & Presets +# ============================================================================= + +# DVB-T parameters +TRANSMISSION_MODE_MAP = { + '2k': (dtv.T2k, 2048, 1705), + '8k': (dtv.T8k, 8192, 6817), +} + +CONSTELLATION_MAP = { + 'qpsk': dtv.MOD_QPSK, + '16qam': dtv.MOD_16QAM, + '64qam': dtv.MOD_64QAM, +} + +CODE_RATE_MAP = { + '1/2': dtv.C1_2, + '2/3': dtv.C2_3, + '3/4': dtv.C3_4, + '5/6': dtv.C5_6, + '7/8': dtv.C7_8, +} + +GUARD_INTERVAL_MAP = { + '1/4': (dtv.GI_1_4, 4), + '1/8': (dtv.GI_1_8, 8), + '1/16': (dtv.GI_1_16, 16), + '1/32': (dtv.GI_1_32, 32), +} + +# Wireless camera frequency presets for authorized security testing +# These are commonly documented frequencies used by analog wireless cameras +CAMERA_PRESETS = { + '900mhz': { + 'name': '900 MHz ISM Band', + 'channels': [ + (910.0e6, '910 MHz Ch1'), + (920.0e6, '920 MHz Ch2'), + (930.0e6, '930 MHz Ch3'), + (940.0e6, '940 MHz Ch4'), + ], + 'bandwidth': 6.0e6, + 'modulation': 'fm', + }, + '1.2ghz': { + 'name': '1.2 GHz Band', + 'channels': [ + (1080.0e6, '1080 MHz Ch1'), + (1120.0e6, '1120 MHz Ch2'), + (1160.0e6, '1160 MHz Ch3'), + (1200.0e6, '1200 MHz Ch4'), + (1240.0e6, '1240 MHz Ch5'), + (1280.0e6, '1280 MHz Ch6'), + (1320.0e6, '1320 MHz Ch7'), + ], + 'bandwidth': 10.0e6, + 'modulation': 'fm', + }, + '2.4ghz': { + 'name': '2.4 GHz ISM Band', + 'channels': [ + (2411.0e6, '2411 MHz Ch1'), + (2431.0e6, '2431 MHz Ch2'), + (2451.0e6, '2451 MHz Ch3'), + (2473.0e6, '2473 MHz Ch4'), + ], + 'bandwidth': 10.0e6, + 'modulation': 'fm', + }, + 'fpv': { + 'name': 'FPV/Drone Video (5.8 GHz — RTL-SDR cannot tune here)', + 'channels': [ + (5740.0e6, '5740 MHz R1'), + (5760.0e6, '5760 MHz R2'), + (5780.0e6, '5780 MHz R3'), + (5800.0e6, '5800 MHz R4'), + (5820.0e6, '5820 MHz R5'), + (5840.0e6, '5840 MHz R6'), + (5860.0e6, '5860 MHz R7'), + (5880.0e6, '5880 MHz R8'), + ], + 'bandwidth': 20.0e6, + 'modulation': 'fm', + }, +} + + +def parse_freq(s): + """Parse frequency string, supporting scientific notation like 506e6.""" + return int(float(s)) + + +def log(msg): + """Print to stderr so stdout stays clean for data output.""" + print(msg, file=sys.stderr) + + +# ============================================================================= +# RTL-SDR Source Helper +# ============================================================================= + +def create_rtl_source(freq, samp_rate, gain=40.0, if_gain=40.0, ppm=0.0): + """Create and configure an osmosdr RTL-SDR source.""" + source = osmosdr.source(args="numchan=1") + source.set_sample_rate(samp_rate) + source.set_center_freq(freq) + source.set_freq_corr(ppm) + source.set_gain_mode(False) + source.set_gain(gain) + source.set_if_gain(if_gain) + source.set_bb_gain(0) + return source + + +# ============================================================================= +# Mode 1: DVB-T Digital TV Receiver +# ============================================================================= + +class DVBTReceiver(gr.top_block): + def __init__(self, args): + gr.top_block.__init__(self, "DVB-T Receiver") + + tx_mode_enum, fft_length, active_carriers = TRANSMISSION_MODE_MAP[args.transmission_mode] + constellation = CONSTELLATION_MAP[args.constellation] + code_rate = CODE_RATE_MAP[args.code_rate] + gi_enum, gi_divisor = GUARD_INTERVAL_MAP[args.guard_interval] + cp_length = fft_length // gi_divisor + + if args.bandwidth == 8: + samp_rate = 64e6 / 7.0 + elif args.bandwidth == 7: + samp_rate = 8e6 + else: + samp_rate = 48e6 / 7.0 + + log(f"=== DVB-T Digital Receiver ===") + log(f"Frequency: {args.freq / 1e6:.3f} MHz") + log(f"Bandwidth: {args.bandwidth} MHz") + log(f"Sample rate: {samp_rate:.0f} Hz") + log(f"Mode: {args.transmission_mode} (FFT={fft_length})") + log(f"Constellation: {args.constellation}") + log(f"Code rate: {args.code_rate}") + log(f"Guard interval: {args.guard_interval} (CP={cp_length})") + + # Source + if args.input_file: + log(f"Source: file '{args.input_file}'") + self.source = blocks.file_source(gr.sizeof_gr_complex, args.input_file, repeat=False) + else: + log(f"Source: RTL-SDR (live)") + self.source = create_rtl_source(args.freq, samp_rate, args.gain, args.if_gain, args.ppm) + actual = self.source.get_sample_rate() + if abs(actual - samp_rate) > 1000: + log(f"WARNING: Got {actual:.0f} Hz instead of {samp_rate:.0f} Hz") + log(f"WARNING: RTL-SDR can't sample fast enough for DVB-T.") + log(f"WARNING: Use --input-file with a pre-recorded IQ file.") + + # OFDM demodulation chain + self.s2v = blocks.stream_to_vector(gr.sizeof_gr_complex, fft_length) + + self.ofdm_acq = dtv.dvbt_ofdm_sym_acquisition( + blocks=1, fft_length=fft_length, + occupied_tones=active_carriers, cp_length=cp_length, snr=100.0 + ) + + self.fft_block = fft.fft_vcc(fft_length, True, [], False) + + self.demod_ref = dtv.dvbt_demod_reference_signals( + gr.sizeof_gr_complex, fft_length, active_carriers, + constellation, dtv.NH, code_rate, dtv.C1_2, + gi_enum, tx_mode_enum, 0, 0 + ) + + self.v2s = blocks.vector_to_stream(gr.sizeof_gr_complex, active_carriers) + + self.demap = dtv.dvbt_demap(active_carriers, constellation, dtv.NH, tx_mode_enum, 1.0) + + self.bit_deintlv = dtv.dvbt_bit_inner_deinterleaver( + active_carriers, constellation, dtv.NH, tx_mode_enum + ) + + self.viterbi = dtv.dvbt_viterbi_decoder(constellation, dtv.NH, code_rate, active_carriers) + + self.conv_deintlv = dtv.dvbt_convolutional_deinterleaver(136, 12, 17) + + self.rs_dec = dtv.dvbt_reed_solomon_dec(2, 8, 0x11d, 255, 239, 8, 51, 8) + + self.descramble = dtv.dvbt_energy_descramble(8) + + self.sink = blocks.file_descriptor_sink(gr.sizeof_char, 1) + + # Wire it up + self.connect( + self.source, self.s2v, self.ofdm_acq, self.fft_block, + self.demod_ref, self.v2s, self.demap, self.bit_deintlv, + self.viterbi, self.conv_deintlv, self.rs_dec, + self.descramble, self.sink + ) + + log("Flowgraph ready. MPEG-TS output on stdout.") + + +# ============================================================================= +# Mode 2: Analog TV Receiver (PAL/NTSC via Wideband FM Demod) +# ============================================================================= + +class AnalogTVReceiver(gr.top_block): + """ + Analog TV receiver using wideband FM demodulation. + Outputs raw baseband video as unsigned 8-bit samples to stdout. + + Analog TV transmits composite video via AM on the picture carrier, + with FM audio offset +4.5 MHz (NTSC) or +5.5 MHz (PAL). + + This receiver demodulates the composite video baseband signal. + """ + def __init__(self, args): + gr.top_block.__init__(self, "Analog TV Receiver") + + samp_rate = 2.4e6 # RTL-SDR max stable rate + audio_rate = 48000 + + if args.standard == 'pal': + fm_deviation = 5.5e6 + video_bw = 5.0e6 + else: # ntsc + fm_deviation = 4.5e6 + video_bw = 4.2e6 + + log(f"=== Analog TV Receiver ({args.standard.upper()}) ===") + log(f"Frequency: {args.freq / 1e6:.3f} MHz") + log(f"Sample rate: {samp_rate:.0f} Hz") + log(f"Standard: {args.standard.upper()}") + + # Source + if args.input_file: + log(f"Source: file '{args.input_file}'") + self.source = blocks.file_source(gr.sizeof_gr_complex, args.input_file, repeat=False) + else: + log(f"Source: RTL-SDR (live)") + self.source = create_rtl_source(args.freq, samp_rate, args.gain, args.if_gain, args.ppm) + + # FM demodulator for composite video + self.fm_demod = analog.wfm_rcv( + quad_rate=samp_rate, + audio_decimation=1, + ) + + # Low-pass filter to isolate video baseband + video_taps = filter.firdes.low_pass( + 1.0, samp_rate, min(video_bw, samp_rate / 2 - 100e3), 100e3 + ) + self.video_lpf = filter.fir_filter_fff(1, video_taps) + + # Scale float [-1,1] to unsigned byte [0,255] for raw video output + self.scale = blocks.multiply_const_ff(127.0) + self.offset = blocks.add_const_ff(128.0) + self.f2c = blocks.float_to_uchar() + + # Output to stdout + self.sink = blocks.file_descriptor_sink(gr.sizeof_char, 1) + + self.connect( + self.source, self.fm_demod, self.video_lpf, + self.scale, self.offset, self.f2c, self.sink + ) + + log("Flowgraph ready. Raw video on stdout.") + log(f"Play with: python3 sdr_tv.py analog ... | mpv --demuxer=rawvideo --rawvideo=w=720:h=576:format=gray -") + + +# ============================================================================= +# Mode 3: Spectrum Analyzer (Terminal-based) +# ============================================================================= + +class SpectrumAnalyzer(gr.top_block): + """ + Real-time terminal spectrum analyzer. + Captures IQ, computes FFT, and displays power spectrum in the terminal. + """ + def __init__(self, args): + gr.top_block.__init__(self, "Spectrum Analyzer") + + self.fft_size = args.fft_size + self.samp_rate = min(args.span, 2.4e6) # RTL-SDR limited + self.center_freq = args.freq + self.args = args + + log(f"=== Spectrum Analyzer ===") + log(f"Center: {args.freq / 1e6:.3f} MHz") + log(f"Span: {self.samp_rate / 1e6:.1f} MHz") + log(f"FFT size: {self.fft_size}") + log(f"Bin width: {self.samp_rate / self.fft_size:.0f} Hz") + + # Source + if args.input_file: + self.source = blocks.file_source(gr.sizeof_gr_complex, args.input_file, repeat=True) + else: + self.source = create_rtl_source(args.freq, self.samp_rate, args.gain, args.if_gain, args.ppm) + + # FFT -> magnitude squared -> log + self.s2v = blocks.stream_to_vector(gr.sizeof_gr_complex, self.fft_size) + self.fft_block = fft.fft_vcc(self.fft_size, True, + fft.window.blackmanharris(self.fft_size), True) + self.mag = blocks.complex_to_mag_squared(self.fft_size) + + # Probe to read FFT data from Python + self.probe = blocks.probe_signal_vf(self.fft_size) + + self.connect(self.source, self.s2v, self.fft_block, self.mag, self.probe) + + def display_loop(self): + """Terminal display loop — runs in main thread.""" + cols = 80 + try: + cols = os.get_terminal_size().columns + except Exception: + pass + + log("") + while True: + try: + data = self.probe.level() + if len(data) == 0: + time.sleep(0.1) + continue + + power = np.array(data) + # Convert to dB, avoiding log(0) + power_db = 10.0 * np.log10(np.maximum(power, 1e-12)) + + # Bin down to terminal width + num_bins = min(cols - 12, self.fft_size) + if num_bins < self.fft_size: + binned = np.mean(power_db.reshape(num_bins, -1), axis=1) + else: + binned = power_db[:num_bins] + + db_min = self.args.db_min + db_max = self.args.db_max + height = 20 + + # Clear screen and draw + sys.stderr.write('\033[2J\033[H') + + freq_start = (self.center_freq - self.samp_rate / 2) / 1e6 + freq_end = (self.center_freq + self.samp_rate / 2) / 1e6 + sys.stderr.write(f' Spectrum: {freq_start:.2f} - {freq_end:.2f} MHz ' + f'(center {self.center_freq/1e6:.3f} MHz)\n') + sys.stderr.write(f' Range: {db_min} to {db_max} dB\n\n') + + # Draw waterfall-style rows + for row in range(height - 1, -1, -1): + db_level = db_min + (db_max - db_min) * row / height + label = f'{db_level:6.0f}|' + line = [] + for b in range(num_bins): + if binned[b] >= db_level: + intensity = min(1.0, (binned[b] - db_level) / ((db_max - db_min) / height)) + if intensity > 0.7: + line.append('\033[91m\u2588\033[0m') # red = strong + elif intensity > 0.3: + line.append('\033[93m\u2593\033[0m') # yellow = medium + else: + line.append('\033[92m\u2591\033[0m') # green = weak + else: + line.append(' ') + sys.stderr.write(label + ''.join(line) + '\n') + + # Frequency axis + sys.stderr.write(' ' * 7 + '\u2514' + '\u2500' * num_bins + '\n') + axis = ' ' * 7 + axis += f'{freq_start:.1f}' + mid_pos = num_bins // 2 - 4 + axis += ' ' * (mid_pos - len(f'{freq_start:.1f}')) + axis += f'{self.center_freq/1e6:.1f}' + end_pos = num_bins - len(f'{freq_end:.1f}') - mid_pos + axis += ' ' * max(1, end_pos) + axis += f'{freq_end:.1f}' + sys.stderr.write(axis + ' MHz\n') + + # Peak info + peak_bin = np.argmax(binned) + peak_freq = freq_start + (freq_end - freq_start) * peak_bin / num_bins + sys.stderr.write(f'\n Peak: {binned[peak_bin]:.1f} dB @ {peak_freq:.3f} MHz\n') + sys.stderr.write(f' [Ctrl+C to quit]\n') + + time.sleep(0.1) + + except (ValueError, RuntimeError): + time.sleep(0.2) + except KeyboardInterrupt: + break + + +# ============================================================================= +# Mode 4: Wireless Camera Scanner +# ============================================================================= + +class CameraScanner(gr.top_block): + """ + Scans known wireless camera frequencies and reports signal strength. + For authorized security testing only. + """ + def __init__(self, args): + gr.top_block.__init__(self, "Camera Scanner") + + self.samp_rate = 2.4e6 + self.fft_size = 1024 + self.args = args + + # Start with a dummy frequency, we'll retune + self.source = create_rtl_source(100e6, self.samp_rate, args.gain, args.if_gain, args.ppm) + + self.s2v = blocks.stream_to_vector(gr.sizeof_gr_complex, self.fft_size) + self.fft_block = fft.fft_vcc(self.fft_size, True, + fft.window.blackmanharris(self.fft_size), True) + self.mag = blocks.complex_to_mag_squared(self.fft_size) + self.probe = blocks.probe_signal_vf(self.fft_size) + + self.connect(self.source, self.s2v, self.fft_block, self.mag, self.probe) + + def measure_power(self, freq, dwell=0.5): + """Tune to frequency and measure average power.""" + self.source.set_center_freq(freq) + time.sleep(dwell) + try: + data = np.array(self.probe.level()) + if len(data) == 0: + return -100.0 + power = 10.0 * np.log10(np.maximum(np.mean(data), 1e-12)) + return power + except (ValueError, RuntimeError): + return -100.0 + + def scan_loop(self): + """Scan all camera presets and display results.""" + presets = self.args.preset + if presets == 'all': + scan_presets = list(CAMERA_PRESETS.keys()) + else: + scan_presets = [presets] + + log("\n=== Wireless Camera Scanner ===") + log("For authorized security testing only.\n") + + # RTL-SDR V4 tunes roughly 24 MHz - 1766 MHz + max_freq = 1766e6 + + while True: + try: + sys.stderr.write('\033[2J\033[H') + sys.stderr.write('=== Wireless Camera Scanner ===\n') + sys.stderr.write('Authorized security testing only.\n\n') + + for preset_name in scan_presets: + preset = CAMERA_PRESETS[preset_name] + sys.stderr.write(f'--- {preset["name"]} ---\n') + + for freq, label in preset['channels']: + if freq > max_freq: + sys.stderr.write(f' {label:25s} [OUT OF RANGE for RTL-SDR]\n') + continue + + power = self.measure_power(freq, dwell=0.3) + + # Signal strength bar + bar_len = max(0, int((power + 60) / 2)) + bar = '\u2588' * bar_len + + if power > -20: + color = '\033[91m' # red = strong signal + status = 'STRONG' + elif power > -35: + color = '\033[93m' # yellow = moderate + status = 'MODERATE' + elif power > -50: + color = '\033[92m' # green = weak + status = 'WEAK' + else: + color = '\033[90m' # gray = noise floor + status = 'noise' + + sys.stderr.write( + f' {label:25s} {power:7.1f} dB ' + f'{color}{bar:20s} {status}\033[0m\n' + ) + + sys.stderr.write('\n') + + sys.stderr.write('[Scanning... Ctrl+C to quit]\n') + time.sleep(1.0) + + except KeyboardInterrupt: + break + + +# ============================================================================= +# Mode 5: Camera Video Receiver (tune to a specific camera freq and demod) +# ============================================================================= + +class CameraVideoReceiver(gr.top_block): + """ + Receives analog wireless camera video via wideband FM demodulation. + Most analog wireless cameras use FM modulated composite video. + """ + def __init__(self, args): + gr.top_block.__init__(self, "Camera Video Receiver") + + samp_rate = 2.4e6 + + log(f"=== Camera Video Receiver ===") + log(f"Frequency: {args.freq / 1e6:.3f} MHz") + log(f"Demodulation: Wideband FM") + + self.source = create_rtl_source(args.freq, samp_rate, args.gain, args.if_gain, args.ppm) + + # Wideband FM demod (analog cameras typically use ~5 MHz deviation) + self.fm_demod = analog.wfm_rcv(quad_rate=samp_rate, audio_decimation=1) + + # Low-pass to get composite video + video_taps = filter.firdes.low_pass(1.0, samp_rate, 1.0e6, 200e3) + self.video_lpf = filter.fir_filter_fff(1, video_taps) + + # Float to byte for output + self.scale = blocks.multiply_const_ff(127.0) + self.offset = blocks.add_const_ff(128.0) + self.f2c = blocks.float_to_uchar() + + self.sink = blocks.file_descriptor_sink(gr.sizeof_char, 1) + + self.connect(self.source, self.fm_demod, self.video_lpf, + self.scale, self.offset, self.f2c, self.sink) + + log("Raw composite video on stdout.") + log("View with: python3 sdr_tv.py camera-rx ... | ffplay -f rawvideo -pix_fmt gray -video_size 720x576 -") + + +# ============================================================================= +# CLI +# ============================================================================= + +import os + +def main(): + parser = argparse.ArgumentParser( + description='SDR TV & RF Analysis Tool for RTL-SDR V4', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Modes: + dvbt DVB-T digital TV receiver (outputs MPEG-TS to stdout) + analog Analog TV receiver PAL/NTSC (outputs raw video to stdout) + spectrum Real-time terminal spectrum analyzer + camera Scan wireless camera frequencies for signals + camera-rx Receive/demodulate a wireless camera signal + +Examples: + python3 sdr_tv.py dvbt --freq 506e6 | mpv - + python3 sdr_tv.py analog --freq 175.25e6 | ffplay -f rawvideo -pix_fmt gray -video_size 720x576 - + python3 sdr_tv.py spectrum --freq 500e6 + python3 sdr_tv.py camera --preset all + python3 sdr_tv.py camera --preset 1.2ghz + python3 sdr_tv.py camera-rx --freq 1200e6 | ffplay -f rawvideo -pix_fmt gray -video_size 720x576 - +""" + ) + + subparsers = parser.add_subparsers(dest='mode', required=True) + + # Common arguments for all modes + def add_common_args(p): + p.add_argument('--gain', type=float, default=40.0, help='RF gain dB (default: 40)') + p.add_argument('--if-gain', type=float, default=40.0, help='IF gain dB (default: 40)') + p.add_argument('--ppm', type=float, default=0.0, help='Freq correction PPM (default: 0)') + p.add_argument('--input-file', type=str, default=None, help='IQ file instead of live SDR') + + # --- DVB-T --- + p_dvbt = subparsers.add_parser('dvbt', help='DVB-T digital TV receiver') + p_dvbt.add_argument('--freq', type=parse_freq, required=True, help='Center freq Hz') + p_dvbt.add_argument('--bandwidth', type=int, default=8, choices=[6, 7, 8], help='BW MHz') + p_dvbt.add_argument('--transmission-mode', default='8k', choices=['2k', '8k']) + p_dvbt.add_argument('--constellation', default='64qam', choices=['qpsk', '16qam', '64qam']) + p_dvbt.add_argument('--code-rate', default='2/3', choices=['1/2', '2/3', '3/4', '5/6', '7/8']) + p_dvbt.add_argument('--guard-interval', default='1/32', choices=['1/4', '1/8', '1/16', '1/32']) + add_common_args(p_dvbt) + + # --- Analog TV --- + p_analog = subparsers.add_parser('analog', help='Analog TV receiver (PAL/NTSC)') + p_analog.add_argument('--freq', type=parse_freq, required=True, help='Picture carrier freq Hz') + p_analog.add_argument('--standard', default='pal', choices=['pal', 'ntsc'], help='TV standard') + add_common_args(p_analog) + + # --- Spectrum Analyzer --- + p_spec = subparsers.add_parser('spectrum', help='Terminal spectrum analyzer') + p_spec.add_argument('--freq', type=parse_freq, required=True, help='Center freq Hz') + p_spec.add_argument('--span', type=float, default=2.4e6, help='Span Hz (max 2.4M for RTL-SDR)') + p_spec.add_argument('--fft-size', type=int, default=1024, help='FFT size (default: 1024)') + p_spec.add_argument('--db-min', type=float, default=-60.0, help='Min dB for display') + p_spec.add_argument('--db-max', type=float, default=0.0, help='Max dB for display') + add_common_args(p_spec) + + # --- Camera Scanner --- + p_cam = subparsers.add_parser('camera', help='Scan wireless camera frequencies') + p_cam.add_argument('--preset', default='all', + choices=list(CAMERA_PRESETS.keys()) + ['all'], + help='Camera freq preset to scan') + add_common_args(p_cam) + + # --- Camera Video Receiver --- + p_camrx = subparsers.add_parser('camera-rx', help='Receive wireless camera video') + p_camrx.add_argument('--freq', type=parse_freq, required=True, help='Camera freq Hz') + add_common_args(p_camrx) + + args = parser.parse_args() + + # Signal handling + def signal_handler(sig, frame): + log("\nStopping...") + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + try: + signal.signal(signal.SIGPIPE, signal.SIG_DFL) + except AttributeError: + pass + + # Dispatch + if args.mode == 'dvbt': + if hasattr(sys.stdout, 'buffer'): + sys.stdout = sys.stdout.buffer + tb = DVBTReceiver(args) + tb.start() + try: + tb.wait() + except KeyboardInterrupt: + tb.stop() + tb.wait() + + elif args.mode == 'analog': + if hasattr(sys.stdout, 'buffer'): + sys.stdout = sys.stdout.buffer + tb = AnalogTVReceiver(args) + tb.start() + try: + tb.wait() + except KeyboardInterrupt: + tb.stop() + tb.wait() + + elif args.mode == 'spectrum': + tb = SpectrumAnalyzer(args) + tb.start() + try: + tb.display_loop() + except KeyboardInterrupt: + pass + tb.stop() + tb.wait() + + elif args.mode == 'camera': + tb = CameraScanner(args) + tb.start() + try: + tb.scan_loop() + except KeyboardInterrupt: + pass + tb.stop() + tb.wait() + + elif args.mode == 'camera-rx': + if hasattr(sys.stdout, 'buffer'): + sys.stdout = sys.stdout.buffer + tb = CameraVideoReceiver(args) + tb.start() + try: + tb.wait() + except KeyboardInterrupt: + tb.stop() + tb.wait() + + +if __name__ == '__main__': + main() diff --git a/webroot/index.html b/webroot/index.html index 75883e5..c5dd60e 100644 --- a/webroot/index.html +++ b/webroot/index.html @@ -241,6 +241,100 @@ + +
+
RTL-SDR Mode
+
+
+
Active Mode
+
Only one mode can use the dongle at a time
+
+ +
+
+
Process Status
+
+
+
+ + +
+
DVB-T Live TV / Kodi
+
+
+
DVB-T Frequency
+
Channel center frequency (Hz)
+
+ +
+
+
+
Kodi Stream
+
Add to Kodi PVR IPTV Simple Client
+
+
http://127.0.0.1:8554
+
+
+ + +
+
+ + +
+
FM Radio
+
+
+
Frequency (MHz)
+
FM broadcast frequency
+
+ +
+
+
Game Controllers
@@ -301,6 +395,42 @@ catch (e) { return ''; } } + async function switchRtlMode(mode) { + log('Switching RTL mode to: ' + mode); + await exec('sh ' + MODDIR + '/scripts/rtl_mode_switch.sh ' + mode); + log('RTL mode switched to: ' + mode); + await loadRtlStatus(); + } + + async function setConf(file, value) { + await exec('echo "' + value + '" > ' + MODDIR + '/config/' + file); + log('Set ' + file + ' = ' + value); + } + + async function loadRtlStatus() { + const mode = (await exec('cat ' + MODDIR + '/config/rtl_mode 2>/dev/null')).stdout.trim(); + if (mode) document.getElementById('rtlMode').value = mode; + + const status = (await exec('sh ' + MODDIR + '/scripts/rtl_mode_switch.sh status 2>/dev/null')).stdout.trim(); + document.getElementById('rtlStatus').textContent = status || 'off'; + + const dot = document.getElementById('rtlDot'); + dot.className = 'dot' + (mode && mode !== 'off' ? '' : ' off'); + + const kodiDot = document.getElementById('kodiDot'); + kodiDot.className = 'dot' + (mode === 'dvbt' ? '' : ' off'); + + const fmDot = document.getElementById('fmDot'); + fmDot.className = 'dot' + (mode === 'fm' ? '' : ' off'); + + // Load saved frequencies + const dvbtFreq = (await exec('cat ' + MODDIR + '/config/dvbt_freq 2>/dev/null')).stdout.trim(); + if (dvbtFreq) document.getElementById('dvbtFreq').value = dvbtFreq; + + const fmFreq = (await exec('cat ' + MODDIR + '/config/fm_freq 2>/dev/null')).stdout.trim(); + if (fmFreq) document.getElementById('fmFreq').value = fmFreq; + } + async function setMode(file, value) { log('Setting ' + file + ' = ' + value); await exec('echo "' + value + '" > ' + MODDIR + '/config/' + file); @@ -388,6 +518,7 @@ await loadWifiInfo(); await loadSdrInfo(); await loadControllerInfo(); + await loadRtlStatus(); log('Done'); }