From 6e027b2c1b5ae9e58d7f66cbff92303ad5c37934 Mon Sep 17 00:00:00 2001 From: sssnake Date: Tue, 31 Mar 2026 07:38:02 -0700 Subject: [PATCH] Add RTL-SDR mode switcher, DVB-T to Kodi pipeline, FM radio Mode switcher (rtl_mode_switch.sh) handles exclusive dongle access between DVB-T, FM, SDR scanner, ADS-B, spectrum, and HackRF modes. DVB-T pipeline: RTL-SDR -> GNU Radio demod -> MPEG-TS -> HTTP -> Kodi. Kodi setup script generates M3U playlist for PVR IPTV Simple Client. Includes dvbt_rx.py and sdr_tv.py from dvbt-rx project. WebUI updated with mode switcher, channel selector, and Kodi controls. --- build.sh | 1 + driver-manager-v1.0.0.zip | Bin 15357 -> 32406 bytes scripts/dvbt_rx.py | 295 +++++++++++++++ scripts/kodi_dvbt_setup.sh | 168 +++++++++ scripts/rtl_mode_switch.sh | 235 ++++++++++++ scripts/sdr_tv.py | 714 +++++++++++++++++++++++++++++++++++++ webroot/index.html | 131 +++++++ 7 files changed, 1544 insertions(+) create mode 100755 scripts/dvbt_rx.py create mode 100755 scripts/kodi_dvbt_setup.sh create mode 100755 scripts/rtl_mode_switch.sh create mode 100755 scripts/sdr_tv.py 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 719dd1dba1006edb4758991d767fd87f20518ac2..debe592071368046c3b47c0d2f5eaedf23c0ef30 100644 GIT binary patch delta 24468 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 delta 7410 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 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'); }