Compare commits

..

No commits in common. "iq_meta" and "main" have entirely different histories.

19 changed files with 989 additions and 3586 deletions

Binary file not shown.

Binary file not shown.

View File

@ -1,73 +0,0 @@
Metadata-Version: 2.4
Name: gain_viz
Version: 0.1.0
Summary: Interactive srsRAN_Project gnb gain control and spectrum visualization tool
Author-email: "Qoherent Inc." <info@qoherent.ai>
Maintainer-email: Gael Kamga <gael@qoherent.ai>, Ashkan Beigi <ash@qoherent.ai>
Keywords: radio,rf,sdr,software-defined radio,5G,gnb,gNodeB,srsRAN_Project,SCM,SignalCraft Conditioning Mpdule,USRP
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Requires-Dist: flask
Requires-Dist: matplotlib
Requires-Dist: numpy
Requires-Dist: pyzmq
Requires-Dist: pyserial
Requires-Dist: flask_socketio
# gain_viz
![Python](https://img.shields.io/badge/python-3.8%2B-blue)
![Flask](https://img.shields.io/badge/flask-2.x-orange)
# gain_viz
**gain_viz** is a Python-based web application for adjusting RF gain settings and visualizing their effect in real-time. It integrates with USRP and SCM devices, providing live IQ time-series and spectrum visualization.
---
## Features
- Adjust **USRP Tx/Rx gains** and **SCM Tx/Rx gains** from a web interface.
- Live IQ **time-series plot** in milliseconds.
- Live **spectrum visualization** (waterfall / spectrogram).
- Fast refresh for near real-time feedback.
- Responsive and clean web interface built with HTML/CSS/JS.
## Installation
Make sure you have **Python 3.8+** installed.
1. Clone the repository:
```bash
git clone https://riahub.ai/gael/gain-viz.git
cd gain-viz
```
2. Build and install
```bash
pip install --upgrade build
python3 -m build
pip install dist/gain_viz-0.1.0-py3-none-any.whl
export PATH=$PATH:~/.local/bin
source ~/.bashrc
```
## Usage
Run the application
```bash
gain_viz
```
Open your browser at http://localhost:5000
- Toggle the gain switches to enable input fields.
- Enter new gain values and press Update Gains.
- Observe the effect on the time-domain IQ plot and spectrum.

View File

@ -1,14 +0,0 @@
README.md
pyproject.toml
gain_viz/__init__.py
gain_viz/app.py
gain_viz/iq_metadata_interface.py
gain_viz.egg-info/PKG-INFO
gain_viz.egg-info/SOURCES.txt
gain_viz.egg-info/dependency_links.txt
gain_viz.egg-info/entry_points.txt
gain_viz.egg-info/requires.txt
gain_viz.egg-info/top_level.txt
gain_viz/static/plot.png
gain_viz/templates/ind.html
gain_viz/templates/index.html

View File

@ -1 +0,0 @@

View File

@ -1,2 +0,0 @@
[console_scripts]
gain_viz = gain_viz.app:main

View File

@ -1,6 +0,0 @@
flask
matplotlib
numpy
pyzmq
pyserial
flask_socketio

View File

@ -1 +0,0 @@
gain_viz

View File

@ -1,12 +0,0 @@
{
"usrp_tx_gain": 60.0,
"usrp_rx_gain": 20.0,
"scm_tx_gain": 30.0,
"scm_rx_gain": 20.0,
"sample_rate": 23040000.0,
"window_ms": 20.0,
"center_freq": 3430000000.0,
"NFFT": 1024,
"tcp_port": 5556,
"streaming": true
}

View File

@ -1,8 +1,6 @@
from flask import Flask, render_template, send_file, request, jsonify
from flask_socketio import SocketIO
import zmq
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
import os
@ -10,14 +8,8 @@ import threading
import time
import serial
import json
import base64
import io
import traceback
import socket
from collections import deque
app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*")
PLOT_PATH = os.path.join(os.getcwd(), "plot.png")
# ----------------- Shared Config -----------------
@ -30,55 +22,42 @@ config = {
"window_ms": 20,
"center_freq": 3.415e9,
"NFFT": 1024,
"iq_port": 5588,
"streaming": False,
"packets_received": 0,
"iq_bandwidth_mbps": 0.0,
"tcp_port": 5556,
"streaming": False, # Added streaming state
}
config_lock = threading.Lock()
# Global variables
usrp_tx_gain = config["usrp_tx_gain"]
usrp_rx_gain = config["usrp_rx_gain"]
scm_tx_gain = config["scm_tx_gain"]
scm_rx_gain = config["scm_rx_gain"]
# Plotting thread control
plot_thread = None
rx_thread = None
stop_event = threading.Event()
pause_event = threading.Event()
latest_iq_data = None
latest_data_lock = threading.Lock()
iq_buffer = deque(maxlen=1)
iq_buffer_lock = threading.Lock()
udp_sock = None
udp_sock_lock = threading.Lock()
MAX_TIME_PLOT_POINTS = 5000
PLOT_REFRESH_SEC = 0.25
# ----------------- Serial / SCM -----------------
def connect_serial(port, baudrate=115200, timeout=1):
"""Connect to a serial port with even parity."""
try:
return serial.Serial(
ser = serial.Serial(
port=port,
baudrate=baudrate,
timeout=timeout,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_EVEN,
stopbits=serial.STOPBITS_ONE,
stopbits=serial.STOPBITS_ONE
)
return ser
except serial.SerialException as e:
print(f"Error connecting to {port}: {e}")
return None
def send_command(ser, command):
if ser and ser.is_open:
ser.write(command.encode("utf-8"))
ser.write(command.encode('utf-8'))
def receive_feedback(ser):
if ser and ser.is_open:
@ -95,12 +74,13 @@ def receive_feedback(ser):
return ""
return ""
def scm_conf(port, baudrate, rx_cmd, tx_cmd):
ser = connect_serial(port, baudrate)
commands = [rx_cmd, tx_cmd]
if ser:
for cmd in [rx_cmd, tx_cmd]:
feedback, attempt = None, 0
for cmd in commands:
feedback = None
attempt = 0
while feedback != "OK" and attempt < 5:
send_command(ser, cmd + "\r")
feedback = receive_feedback(ser)
@ -109,29 +89,34 @@ def scm_conf(port, baudrate, rx_cmd, tx_cmd):
return True
return False
# ----------------- Gain Updates -----------------
def gain_update(usrp_tx, usrp_rx, scm_tx, scm_rx):
global usrp_tx_gain, usrp_rx_gain, scm_tx_gain, scm_rx_gain
scm_change = False
scm_change = False
if usrp_tx != usrp_tx_gain:
usrp_tx_gain = usrp_tx
os.system(f"tmux send-keys -t ran 'tx_gain 0 {usrp_tx_gain} ' C-m")
if usrp_rx != usrp_rx_gain:
usrp_rx_gain = usrp_rx
os.system(f"tmux send-keys -t ran 'rx_gain 0 {usrp_rx_gain} ' C-m")
if scm_tx != scm_tx_gain:
scm_tx_gain = scm_tx
scm_change = True
if scm_rx != scm_rx_gain:
scm_rx_gain = scm_rx
scm_change = True
t_cmd = f"HW:GAIN 0 TX 0 {scm_tx_gain}"
r_cmd = f"HW:GAIN 1 RX 0 {scm_rx_gain}"
if scm_change:
t_cmd = f"HW:GAIN 0 TX 0 {scm_tx_gain}"
r_cmd = f"HW:GAIN 1 RX 0 {scm_rx_gain}"
scm_conf("/dev/ttyUSB0", 115200, r_cmd, t_cmd)
scm_conf("/dev/ttyUSB1", 115200, r_cmd, t_cmd)
with config_lock:
config["scm_tx_gain"] = scm_tx_gain
config["scm_rx_gain"] = scm_rx_gain
@ -142,493 +127,331 @@ def gain_update(usrp_tx, usrp_rx, scm_tx, scm_rx):
return True
def parse_iq_payload(payload):
if len(payload) <= 2:
return None
iq_bytes = payload[2:]
usable_len = (len(iq_bytes) // 8) * 8
if usable_len == 0:
return None
return np.frombuffer(iq_bytes[:usable_len], dtype=np.complex64)
def compute_power_db(iq):
if iq is None or len(iq) == 0:
return None
power = np.mean(np.abs(iq) ** 2)
if power <= 0:
return -120.0
return 10 * np.log10(power + 1e-12)
def data_receiver_thread():
global latest_iq_data, udp_sock
with config_lock:
iq_port = config["iq_port"]
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024)
sock.bind(("0.0.0.0", iq_port))
sock.settimeout(0.1)
with udp_sock_lock:
udp_sock = sock
print(f"Listening for IQ samples on UDP port {iq_port}")
total_packets = 0
total_bytes = 0
last_stat_time = time.time()
while not stop_event.is_set():
try:
payload, addr = sock.recvfrom(65535)
total_packets += 1
total_bytes += len(payload)
iq = parse_iq_payload(payload)
if iq is None or len(iq) == 0:
continue
with latest_data_lock:
latest_iq_data = iq
with iq_buffer_lock:
iq_buffer.extend(iq)
now = time.time()
dt = now - last_stat_time
if dt >= 1.0:
bandwidth_mbps = (total_bytes * 8) / dt / 1e6
with config_lock:
config["packets_received"] = total_packets
config["iq_bandwidth_mbps"] = bandwidth_mbps
total_bytes = 0
last_stat_time = now
except socket.timeout:
continue
except OSError:
break
except Exception as e:
print(f"UDP receive error: {e}")
traceback.print_exc()
time.sleep(0.1)
try:
sock.close()
except Exception:
pass
with udp_sock_lock:
udp_sock = None
print("Data receiver thread stopped")
# ----------------- Plot Generation -----------------
def generate_spectrum_plot():
socket = None
iq_sample = np.zeros(1, dtype=np.complex64)
last_port = None
while not stop_event.is_set():
# Check if we're paused
if pause_event.is_set():
time.sleep(0.1)
continue
with config_lock:
sample_rate = config["sample_rate"]
window_ms = config["window_ms"]
center_freq = config["center_freq"]
NFFT = config["NFFT"]
tcp_port = config["tcp_port"]
streaming = config["streaming"]
# Only process if streaming is active
if not streaming:
time.sleep(0.1)
continue
with iq_buffer_lock:
current_iq = np.array(iq_buffer, dtype=np.complex64)
# Reconnect if port changed or socket is None
if socket is None or tcp_port != last_port:
if socket:
socket.close()
try:
context = zmq.Context()
socket = context.socket(zmq.SUB)
socket.setsockopt(zmq.CONFLATE, 1)
socket.setsockopt_string(zmq.SUBSCRIBE, "")
socket.setsockopt(zmq.RCVTIMEO, 1000)
socket.connect(f"tcp://localhost:{tcp_port}")
last_port = tcp_port
print(f"Connected to ZMQ on port {tcp_port}")
except Exception as e:
print(f"ZMQ connection error: {e}")
socket = None
time.sleep(1)
continue
if len(current_iq) == 0:
fig, axes = plt.subplots(2, 1, figsize=(14, 7))
fig.patch.set_facecolor('#1a1a2e')
for ax in axes:
ax.set_facecolor('#1a1a2e')
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_visible(False)
axes[0].text(0.5, 0.5, "Waiting for IQ samples on UDP port 5588 ...",
ha="center", va="center", transform=axes[0].transAxes,
fontsize=18, color="#00d4ff")
_save_and_emit(fig)
time.sleep(PLOT_REFRESH_SEC)
continue
window_samples = int(sample_rate * window_ms / 1000)
if iq_sample.size != window_samples:
iq_sample = np.zeros(window_samples, dtype=np.complex64)
try:
window_samples = int(sample_rate * window_ms / 1000)
if len(current_iq) > window_samples:
current_iq = current_iq[-window_samples:]
elif len(current_iq) < window_samples:
current_iq = np.pad(current_iq, (window_samples - len(current_iq), 0), mode="constant")
msg = socket.recv(zmq.NOBLOCK)
float_data = np.frombuffer(msg, dtype=np.float32)
if float_data.size >= 2:
complex_data = float_data.reshape(-1, 2)
iq_all = complex_data[:, 0] + 1j * complex_data[:, 1]
if len(iq_all) >= window_samples:
iq_sample = iq_all[-window_samples:]
else:
iq_sample = np.pad(iq_all, (window_samples - len(iq_all), 0))
total_duration_s = len(current_iq) / sample_rate
total_duration_ms = total_duration_s * 1000.0
freq_low = center_freq - sample_rate / 2.0
freq_high = center_freq + sample_rate / 2.0
power_db = compute_power_db(current_iq)
# Create plot
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 6))
fig.subplots_adjust(hspace=0.4)
if len(current_iq) > MAX_TIME_PLOT_POINTS:
step = max(1, len(current_iq) // MAX_TIME_PLOT_POINTS)
plot_iq = current_iq[::step]
else:
plot_iq = current_iq
# Time-domain plot
times_ms = np.arange(len(iq_sample)) * 1000 / sample_rate
ax1.plot(times_ms, np.real(iq_sample), label="Real", color='b')
ax1.plot(times_ms, np.imag(iq_sample), label="Imag", color='r')
ax1.set_xlim(0, window_ms)
ax1.set_xlabel("Time (ms)")
ax1.set_ylabel("IQ Amplitude")
ax1.grid(True, which='both', linestyle='--', linewidth=0.5)
ax1.legend()
times_ms = np.linspace(0, total_duration_ms, len(plot_iq), endpoint=False)
fig, axes = plt.subplots(
2, 1,
figsize=(14, 7),
gridspec_kw={"height_ratios": [1, 1]},
)
fig.patch.set_facecolor('#1a1a2e')
fig.subplots_adjust(left=0.07, right=0.98, top=0.94, bottom=0.08, hspace=0.32)
ax_time = axes[0]
ax_spec = axes[1]
for ax in axes:
ax.set_facecolor('#0f0f23')
ax.tick_params(colors='#aaa', labelsize=8)
for spine in ax.spines.values():
spine.set_color('#333')
ax_time.plot(times_ms, np.real(plot_iq), label="I", color="#00d4ff", linewidth=0.4, alpha=0.9)
ax_time.plot(times_ms, np.imag(plot_iq), label="Q", color="#ff6b6b", linewidth=0.4, alpha=0.9)
ax_time.set_xlim(0, total_duration_ms)
real_part = np.real(plot_iq)
imag_part = np.imag(plot_iq)
y_min = min(np.min(real_part), np.min(imag_part))
y_max = max(np.max(real_part), np.max(imag_part))
y_pad = max((y_max - y_min) * 0.03, 0.001)
ax_time.set_ylim(y_min - y_pad, y_max + y_pad)
ax_time.margins(x=0, y=0)
ax_time.set_xlabel("Time (ms)", color='#aaa', fontsize=9)
ax_time.set_ylabel("Amplitude", color='#aaa', fontsize=9)
ax_time.set_title(
f"IQ Time Series | Power: {power_db:.1f} dB | Samples: {len(current_iq):,}",
fontsize=10, fontweight="bold", color="#00d4ff", pad=8,
)
ax_time.grid(True, linestyle='--', linewidth=0.3, alpha=0.4, color='#444')
ax_time.legend(loc="upper right", fontsize=7, framealpha=0.6,
facecolor='#1a1a2e', edgecolor='#333', labelcolor='#ccc')
noverlap = min(NFFT - 1, int(NFFT * 0.5))
ax_spec.specgram(
current_iq,
# Spectrogram
cmap = plt.get_cmap('twilight')
ax2.specgram(
iq_sample,
Fs=sample_rate,
Fc=center_freq,
NFFT=NFFT,
noverlap=noverlap,
cmap="twilight",
mode="magnitude",
scale="dB",
noverlap=512,
cmap=cmap
)
ax_spec.set_xlim(0, total_duration_s)
ax_spec.set_ylim(freq_low, freq_high)
ax_spec.margins(x=0, y=0)
ax_spec.xaxis.set_major_formatter(
ticker.FuncFormatter(lambda v, _: f"{v * 1e3:.1f}")
)
ax_spec.xaxis.set_minor_locator(ticker.AutoMinorLocator())
ax_spec.yaxis.set_major_formatter(
ticker.FuncFormatter(lambda v, _: f"{v / 1e9:.4f}")
ax2.set_xlabel("Time (ms)")
ax2.set_ylabel("Frequency (Hz)")
ax2.grid(False)
ax2.set_ylim(center_freq - sample_rate / 2,
center_freq + sample_rate / 2)
ax2.xaxis.set_major_formatter(
ticker.FuncFormatter(lambda t, pos: '{0:g}'.format(t*1e3))
)
ax2.xaxis.set_minor_locator(ticker.AutoMinorLocator())
ax_spec.set_xlabel("Time (ms)", color='#aaa', fontsize=9)
ax_spec.set_ylabel("Frequency (GHz)", color='#aaa', fontsize=9)
ax_spec.set_title("Spectrogram", fontsize=10, color="#aaa", pad=6)
ax_spec.grid(False)
_save_and_emit(fig)
plt.savefig(PLOT_PATH, bbox_inches='tight')
plt.close(fig)
except zmq.Again:
# No new data
fig, ax = plt.subplots(figsize=(12, 6))
ax.text(0.5, 0.5, "Waiting for data...",
ha='center', va='center', transform=ax.transAxes, fontsize=16)
ax.set_title("Spectrum Analyzer - No Data (Streaming Active)")
plt.savefig(PLOT_PATH, bbox_inches='tight')
plt.close(fig)
except Exception as e:
print(f"Plot generation error: {e}")
traceback.print_exc()
fig, ax = plt.subplots(figsize=(12, 6))
ax.text(0.5, 0.5, f"Error: {str(e)}",
ha='center', va='center', transform=ax.transAxes, fontsize=12)
ax.set_title("Spectrum Analyzer - Error")
plt.savefig(PLOT_PATH, bbox_inches='tight')
plt.close(fig)
time.sleep(PLOT_REFRESH_SEC)
time.sleep(0.1)
# Cleanup when stopping
if socket:
socket.close()
print("Plotting thread stopped")
def _save_and_emit(fig):
buf = io.BytesIO()
fig.savefig(
buf,
format='png',
dpi=100,
facecolor=fig.get_facecolor(),
edgecolor='none',
pad_inches=0.05
)
buf.seek(0)
png_bytes = buf.read()
buf.close()
plt.close(fig)
with open(PLOT_PATH, "wb") as f:
f.write(png_bytes)
try:
socketio.emit('plot_update', {'image': base64.b64encode(png_bytes).decode('utf-8')})
except Exception:
pass
def start_plotting():
global plot_thread, rx_thread, latest_iq_data, iq_buffer
"""Start the plotting thread"""
global plot_thread, stop_event, pause_event
stop_event.clear()
pause_event.clear()
with latest_data_lock:
latest_iq_data = None
with config_lock:
config["streaming"] = True
config["packets_received"] = 0
config["iq_bandwidth_mbps"] = 0.0
max_samples = max(1, int(config["sample_rate"] * config["window_ms"] / 1000))
with iq_buffer_lock:
iq_buffer = deque(maxlen=max_samples)
if rx_thread is None or not rx_thread.is_alive():
rx_thread = threading.Thread(target=data_receiver_thread, daemon=True)
rx_thread.start()
print("UDP receiver thread started")
if plot_thread is None or not plot_thread.is_alive():
plot_thread = threading.Thread(target=generate_spectrum_plot, daemon=True)
plot_thread.start()
print("Plotting thread started")
return True
def stop_plotting():
global plot_thread, rx_thread, udp_sock
"""Stop the plotting thread"""
global plot_thread, stop_event
with config_lock:
config["streaming"] = False
stop_event.set()
with udp_sock_lock:
if udp_sock is not None:
try:
udp_sock.close()
except Exception:
pass
if rx_thread and rx_thread.is_alive():
rx_thread.join(timeout=2.0)
if plot_thread and plot_thread.is_alive():
plot_thread.join(timeout=3.0)
fig, ax = plt.subplots(figsize=(14, 7))
fig.patch.set_facecolor('#1a1a2e')
ax.set_facecolor('#1a1a2e')
ax.text(0.5, 0.5, "Streaming Stopped\nClick Start to begin",
ha="center", va="center", transform=ax.transAxes,
fontsize=18, color="#00d4ff")
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_visible(False)
_save_and_emit(fig)
plot_thread.join(timeout=2.0)
# Create stopped message plot
fig, ax = plt.subplots(figsize=(12, 6))
ax.text(0.5, 0.5, "Streaming Stopped\nClick Start to begin",
ha='center', va='center', transform=ax.transAxes, fontsize=16)
ax.set_title("Spectrum Analyzer - Stopped")
plt.savefig(PLOT_PATH, bbox_inches='tight')
plt.close(fig)
print("Plotting thread stopped")
return True
def pause_plotting():
"""Pause the plotting updates"""
global pause_event
if pause_event.is_set():
pause_event.clear()
print("Plotting resumed")
return "resumed"
else:
pause_event.set()
print("Plotting paused")
return "paused"
@app.route("/")
# ----------------- Flask Routes -----------------
@app.route('/')
def index():
return render_template("index.html")
return render_template('index.html')
@app.route('/update_gains', methods=['POST'])
def update_gains():
global usrp_tx_gain, usrp_rx_gain, scm_tx_gain, scm_rx_gain
try:
usrp_tx = request.form.get('usrp_tx_gain', type=float)
usrp_rx = request.form.get('usrp_rx_gain', type=float)
scm_tx = request.form.get('scm_tx_gain', type=float)
scm_rx = request.form.get('scm_rx_gain', type=float)
if usrp_tx is None:
usrp_tx = usrp_tx_gain
if usrp_rx is None:
usrp_rx = usrp_rx_gain
if scm_tx is None:
scm_tx = scm_tx_gain
if scm_rx is None:
scm_rx = scm_rx_gain
@app.route("/plot")
success = gain_update(usrp_tx, usrp_rx, scm_tx, scm_rx)
if success:
return jsonify({"status": "success", "message": "Gains updated successfully"})
else:
return jsonify({"status": "error", "message": "Failed to update gains"}), 500
except Exception as e:
return jsonify({"status": "error", "message": f"Error: {str(e)}"}), 500
@app.route('/plot')
def plot():
return send_file(PLOT_PATH, mimetype="image/png")
try:
return send_file(PLOT_PATH, mimetype='image/png')
except Exception as e:
return send_file(PLOT_PATH, mimetype='image/png')
@app.route('/get_gains')
def get_gains():
return jsonify({
"usrp_tx_gain": usrp_tx_gain,
"usrp_rx_gain": usrp_rx_gain,
"scm_tx_gain": scm_tx_gain,
"scm_rx_gain": scm_rx_gain
})
@app.route("/start_stream", methods=["POST"])
@app.route('/update_params', methods=['POST'])
def update_params():
try:
center_freq = request.form.get('center_freq', type=float)
sample_rate = request.form.get('sample_rate', type=float)
NFFT = request.form.get('fft_size', type=int)
window_ms = request.form.get('window_ms', type=float)
tcp_port = request.form.get('tcp_port', type=int)
if not all([center_freq, sample_rate, NFFT, window_ms, tcp_port]):
return jsonify({
'status': 'error',
'message': 'All parameters are required'
}), 400
with config_lock:
config["center_freq"] = center_freq
config["sample_rate"] = sample_rate
config["NFFT"] = NFFT
config["window_ms"] = window_ms
config["tcp_port"] = tcp_port
print(f"Updated params: center_freq={center_freq}, sample_rate={sample_rate}, NFFT={NFFT}, window_ms={window_ms}, tcp_port={tcp_port}")
save_config()
return jsonify({
'status': 'success',
'message': 'Parameters updated successfully'
})
except Exception as e:
print(f"Error updating params: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@app.route('/start_stream', methods=['POST'])
def start_stream():
try:
start_plotting()
return jsonify(status="success", message="Streaming started")
success = start_plotting()
if success:
return jsonify({"status": "success", "message": "Streaming started"})
else:
return jsonify({"status": "error", "message": "Failed to start streaming"}), 500
except Exception as e:
return jsonify(status="error", message=str(e)), 500
return jsonify({"status": "error", "message": f"Error: {str(e)}"}), 500
@app.route("/stop_stream", methods=["POST"])
@app.route('/stop_stream', methods=['POST'])
def stop_stream():
try:
stop_plotting()
return jsonify(status="success", message="Streaming stopped")
success = stop_plotting()
if success:
return jsonify({"status": "success", "message": "Streaming stopped"})
else:
return jsonify({"status": "error", "message": "Failed to stop streaming"}), 500
except Exception as e:
return jsonify(status="error", message=str(e)), 500
return jsonify({"status": "error", "message": f"Error: {str(e)}"}), 500
@app.route("/pause_stream", methods=["POST"])
@app.route('/pause_stream', methods=['POST'])
def pause_stream():
try:
result = pause_plotting()
return jsonify(status="success", message=f"Streaming {result}", state=result)
return jsonify({"status": "success", "message": f"Streaming {result}", "state": result})
except Exception as e:
return jsonify(status="error", message=str(e)), 500
return jsonify({"status": "error", "message": f"Error: {str(e)}"}), 500
@app.route("/get_stream_state")
@app.route('/get_stream_state', methods=['GET'])
def get_stream_state():
with config_lock:
streaming = config["streaming"]
paused = pause_event.is_set()
state = "stopped"
if streaming and not paused:
state = "running"
elif streaming and paused:
state = "paused"
else:
state = "stopped"
return jsonify(state=state)
@app.route("/update_params", methods=["POST"])
def update_params():
try:
with config_lock:
cf = request.form.get("center_freq", type=float)
sr = request.form.get("sample_rate", type=float)
nf = request.form.get("fft_size", type=int)
wm = request.form.get("window_ms", type=float)
if cf:
config["center_freq"] = cf
if sr:
config["sample_rate"] = sr
if nf:
config["NFFT"] = nf
if wm:
config["window_ms"] = wm
save_config()
return jsonify(status="success", message="Parameters updated")
except Exception as e:
return jsonify(status="error", message=str(e)), 500
@app.route("/update_gains", methods=["POST"])
def update_gains():
try:
usrp_tx = request.form.get("usrp_tx_gain", type=float) or usrp_tx_gain
usrp_rx = request.form.get("usrp_rx_gain", type=float) or usrp_rx_gain
scm_tx = request.form.get("scm_tx_gain", type=float) or scm_tx_gain
scm_rx = request.form.get("scm_rx_gain", type=float) or scm_rx_gain
ok = gain_update(usrp_tx, usrp_rx, scm_tx, scm_rx)
if ok:
return jsonify(status="success", message="Gains updated")
return jsonify(status="error", message="Failed"), 500
except Exception as e:
return jsonify(status="error", message=str(e)), 500
@app.route("/get_gains")
def get_gains():
return jsonify(
usrp_tx_gain=usrp_tx_gain,
usrp_rx_gain=usrp_rx_gain,
scm_tx_gain=scm_tx_gain,
scm_rx_gain=scm_rx_gain,
)
@app.route("/get_stats")
def get_stats():
with config_lock:
packets_received = config.get("packets_received", 0)
iq_bandwidth_mbps = config.get("iq_bandwidth_mbps", 0.0)
with iq_buffer_lock:
current_iq = np.array(iq_buffer, dtype=np.complex64)
power_db = compute_power_db(current_iq) if len(current_iq) > 0 else None
return jsonify(
current_slot="--",
packets_received=packets_received,
slots_correlated=0,
correlation_rate=0,
slot_rate=0,
iq_bandwidth_mbps=iq_bandwidth_mbps,
metadata_received=0,
slots_without_metadata=0,
avg_packets_per_slot=0,
power_db=round(power_db, 1) if power_db is not None else None,
allocated_rbs=0,
direction="--",
)
return jsonify({"state": state})
def save_config():
with config_lock:
cfg = dict(config)
try:
with open(os.path.join(os.getcwd(), "gain_viz.json"), "w") as f:
json.dump(cfg, f, indent=2, default=str)
with open(os.path.join(os.getcwd(), "gain_viz.json"), 'w') as f:
json.dump(cfg, f, indent=2)
except Exception as e:
print(f"Error saving config: {e}")
# ----------------- Main -----------------
def main():
# Ensure placeholder image exists
if not os.path.exists(PLOT_PATH):
fig, ax = plt.subplots(figsize=(14, 7))
fig.patch.set_facecolor('#1a1a2e')
ax.set_facecolor('#1a1a2e')
ax.text(0.5, 0.5, "Click Start to begin streaming",
ha="center", va="center", fontsize=18, color="#00d4ff")
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_visible(False)
fig.savefig(PLOT_PATH, facecolor=fig.get_facecolor())
fig, ax = plt.subplots(figsize=(12, 6))
ax.text(0.5, 0.5, "Click Start to begin streaming", ha='center', va='center', fontsize=16)
ax.set_title("Gain-Viz Spectrum Analyzer - Ready")
plt.savefig(PLOT_PATH)
plt.close(fig)
print("=" * 60)
print(" IQ Spectrum Analyzer (UDP IQ only, optimized)")
print("=" * 60)
print("Gain-Viz server started. Use the web interface to control streaming.")
app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=False)
socketio.run(
app,
host="0.0.0.0",
port=5000,
debug=True,
use_reloader=False,
allow_unsafe_werkzeug=True
)
if __name__ == "__main__":
if __name__ == '__main__':
main()

View File

@ -1,16 +0,0 @@
{
"usrp_tx_gain": 60,
"usrp_rx_gain": 30,
"scm_tx_gain": 30,
"scm_rx_gain": 30,
"sample_rate": 23040000.0,
"window_ms": 5.0,
"center_freq": 3415000000.0,
"NFFT": 512,
"iq_port": 5588,
"metadata_port": 5589,
"streaming": true,
"show_rb_overlay": true,
"num_rbs": 273,
"subcarriers_per_rb": 12
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 KiB

View File

@ -1,954 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Gain-Viz — Spectrum Viewer</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #2563eb;
--primary-dark: #1d4ed8;
--primary-light: #dbeafe;
--bg: #f8fafc;
--card: #ffffff;
--muted: #64748b;
--border: #e2e8f0;
--radius: 12px;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
background: var(--bg);
color: #0f172a;
line-height: 1.5;
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
overflow: hidden;
display: flex;
flex-direction: column;
height: calc(100vh - 40px);
}
header {
padding: 24px 32px;
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: #fff;
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
header h1 {
margin: 0;
font-size: 1.8rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
header h1::before {
content: "📡";
font-size: 1.5rem;
}
header p {
margin: 0;
opacity: .9;
font-size: 1rem;
font-weight: 400;
}
.content-wrapper {
display: flex;
flex: 1;
overflow: hidden;
}
.controls-panel {
width: 450px;
flex-shrink: 0;
padding: 24px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 24px;
overflow-y: auto;
}
.visualization-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card {
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 16px 0;
font-size: 1.2rem;
font-weight: 600;
color: #0f172a;
display: flex;
align-items: center;
gap: 8px;
}
.card h2::before {
content: "⚙️";
font-size: 1.1rem;
}
/* Gain grid */
.gain-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.gain-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid var(--border);
transition: all 0.2s ease;
}
.gain-item:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
.gain-item label {
font-weight: 500;
font-size: 0.95rem;
width: 100px;
flex-shrink: 0;
}
.gain-input {
flex: 1;
}
.gain-input input {
width: 100%;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 0.95rem;
transition: border-color 0.2s;
}
.gain-input input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.gain-input input:disabled {
background-color: #f1f5f9;
color: #94a3b8;
}
/* Toggle switch */
.toggle-switch {
position: relative;
width: 50px;
height: 26px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: #d1d5db;
border-radius: 24px;
transition: 0.25s;
cursor: pointer;
}
.slider:before {
content: "";
position: absolute;
left: 4px;
top: 4px;
width: 18px;
height: 18px;
background: white;
border-radius: 50%;
transition: 0.25s;
}
.toggle-switch input:checked + .slider {
background: var(--primary);
}
.toggle-switch input:checked + .slider:before {
transform: translateX(24px);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.btn {
padding: 10px 18px;
border-radius: 8px;
border: 0;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.btn.primary {
background: var(--primary);
color: #fff;
}
.btn.primary:hover:not(:disabled) {
background: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(37, 99, 235, 0.3);
}
.btn.secondary {
background: #f1f5f9;
color: #475569;
}
.btn.secondary:hover:not(:disabled) {
background: #e2e8f0;
}
.btn.success {
background: var(--success);
color: #fff;
}
.btn.warning {
background: var(--warning);
color: #fff;
}
.btn.error {
background: var(--error);
color: #fff;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.status {
margin-top: 12px;
padding: 12px;
border-radius: 6px;
display: none;
font-size: 0.9rem;
font-weight: 500;
}
.status.success {
background: rgba(16, 185, 129, 0.08);
color: #047857;
border: 1px solid rgba(16, 185, 129, 0.12);
}
.status.error {
background: rgba(239, 68, 68, 0.06);
color: #7f1d1d;
border: 1px solid rgba(239, 68, 68, 0.12);
}
/* Parameter grid */
.param-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.param-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.param-item label {
font-weight: 500;
font-size: 0.9rem;
color: #475569;
}
.param-item input {
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 0.95rem;
transition: border-color 0.2s;
}
.param-item input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Stream controls */
.stream-controls {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.stream-status {
padding: 8px 16px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
}
.status-stopped {
background: #fef2f2;
color: var(--error);
border: 1px solid #fecaca;
}
.status-running {
background: #f0fdf4;
color: var(--success);
border: 1px solid #bbf7d0;
}
.status-paused {
background: #fffbeb;
color: var(--warning);
border: 1px solid #fed7aa;
}
/* Plot area */
.plot-area {
flex: 1;
padding: 24px;
background: #f8fafc;
display: flex;
flex-direction: column;
overflow: hidden;
}
.plot-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.plot-header h3 {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
color: #0f172a;
display: flex;
align-items: center;
gap: 8px;
}
.plot-header h3::before {
content: "📊";
}
.plot-controls {
display: flex;
gap: 10px;
align-items: center;
}
.plot-card {
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: var(--shadow);
}
#plotImage {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 8px;
display: block;
background: #f8fafc;
border: 1px solid var(--border);
}
footer {
padding: 16px 32px;
text-align: center;
color: var(--muted);
font-size: 0.9rem;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
/* Responsive design */
@media (max-width: 1024px) {
.content-wrapper {
flex-direction: column;
}
.controls-panel {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
max-height: 40vh;
}
.param-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
body {
padding: 10px;
}
.container {
height: calc(100vh - 20px);
}
header {
padding: 20px;
}
.controls-panel, .plot-area {
padding: 16px;
}
.gain-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.gain-item label {
width: 100%;
}
.gain-input {
width: 100%;
}
.toggle-switch {
align-self: flex-end;
}
.form-actions {
flex-direction: column;
}
.stream-controls {
flex-direction: column;
}
}
/* Custom scrollbar */
.controls-panel::-webkit-scrollbar {
width: 6px;
}
.controls-panel::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.controls-panel::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.controls-panel::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Gain-Viz</h1>
<p>Interactive gain control and real-time RF visualization</p>
</header>
<div class="content-wrapper">
<div class="controls-panel">
<!-- Stream Controls Card -->
<div class="card">
<h2>Stream Controls</h2>
<div class="stream-controls">
<button class="btn success" id="startBtn">
<span>▶️</span> Start
</button>
<button class="btn warning" id="pauseBtn" disabled>
<span>⏸️</span> Pause
</button>
<button class="btn error" id="stopBtn" disabled>
<span>⏹️</span> Stop
</button>
</div>
<div class="stream-status status-stopped" id="streamStatus">
<span></span> Stopped
</div>
</div>
<!-- Gain Settings Card -->
<div class="card">
<h2>Gain Settings</h2>
<form id="gainForm">
<div class="gain-grid">
<!-- USRP Tx -->
<div class="gain-item">
<label for="usrp_tx_gain">USRP Tx</label>
<div class="gain-input">
<input type="number" id="usrp_tx_gain" name="usrp_tx_gain" value="60" disabled aria-label="USRP Tx Gain">
</div>
<label class="toggle-switch" title="Enable USRP Tx">
<input type="checkbox" class="gain-toggle" data-input="usrp_tx_gain" id="usrp_tx_toggle">
<span class="slider"></span>
</label>
</div>
<!-- SCM Tx -->
<div class="gain-item">
<label for="scm_tx_gain">SCM Tx</label>
<div class="gain-input">
<input type="number" id="scm_tx_gain" name="scm_tx_gain" value="30" disabled aria-label="SCM Tx Gain">
</div>
<label class="toggle-switch" title="Enable SCM Tx">
<input type="checkbox" class="gain-toggle" data-input="scm_tx_gain" id="scm_tx_toggle">
<span class="slider"></span>
</label>
</div>
<!-- USRP Rx -->
<div class="gain-item">
<label for="usrp_rx_gain">USRP Rx</label>
<div class="gain-input">
<input type="number" id="usrp_rx_gain" name="usrp_rx_gain" value="30" disabled aria-label="USRP Rx Gain">
</div>
<label class="toggle-switch" title="Enable USRP Rx">
<input type="checkbox" class="gain-toggle" data-input="usrp_rx_gain" id="usrp_rx_toggle">
<span class="slider"></span>
</label>
</div>
<!-- SCM Rx -->
<div class="gain-item">
<label for="scm_rx_gain">SCM Rx</label>
<div class="gain-input">
<input type="number" id="scm_rx_gain" name="scm_rx_gain" value="30" disabled aria-label="SCM Rx Gain">
</div>
<label class="toggle-switch" title="Enable SCM Rx">
<input type="checkbox" class="gain-toggle" data-input="scm_rx_gain" id="scm_rx_toggle">
<span class="slider"></span>
</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn primary" id="gainUpdateBtn">
<span>🔄</span> Update Gains
</button>
<button type="button" class="btn secondary" id="gainRefreshBtn" title="Reload gains from device">
<span></span> Refresh
</button>
</div>
<div id="gainStatusMessage" class="status"></div>
</form>
</div>
<!-- Parameters Card -->
<div class="card">
<h2>Spectrogram Parameters</h2>
<form id="paramForm">
<div class="param-grid">
<div class="param-item">
<label for="center_freq">Center Frequency (Hz)</label>
<input type="number" id="center_freq" name="center_freq" value="3415000000" step="1000000">
</div>
<div class="param-item">
<label for="sample_rate">Sample Rate (Hz)</label>
<input type="number" id="sample_rate" name="sample_rate" value="23040000" step="1000000">
</div>
<div class="param-item">
<label for="fft_size">FFT Size</label>
<input type="number" id="fft_size" name="fft_size" value="1024" step="128">
</div>
<div class="param-item">
<label for="window_ms">Window Size (ms)</label>
<input type="number" id="window_ms" name="window_ms" value="20" step="1">
</div>
<div class="param-item">
<label for="tcp_port">ZMQ Port</label>
<input type="number" id="tcp_port" name="tcp_port" value="5556" step="1">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn primary" id="paramUpdateBtn">
<span></span> Update Parameters
</button>
</div>
<div id="paramStatusMessage" class="status"></div>
</form>
</div>
</div>
<!-- Visualization Panel -->
<div class="visualization-panel">
<div class="plot-area">
<div class="plot-header">
<h3>Spectrum Analysis</h3>
<div class="plot-controls">
<button class="btn secondary" id="refreshPlotBtn">
<span>🔄</span> Refresh
</button>
</div>
</div>
<div class="plot-card">
<img id="plotImage" src="/plot" alt="Spectrum Analysis plot">
</div>
</div>
</div>
</div>
<footer>
Gain-Viz • Real-time RF monitoring
</footer>
</div>
<script>
(function(){
const plotImg = document.getElementById('plotImage');
let isPlotPaused = false;
let isStreaming = false;
let refreshInterval;
// Helper to show status for a specific element
function showStatus(id, message, type = 'success') {
const el = document.getElementById(id);
if (!el) return;
el.textContent = message;
el.className = 'status ' + (type === 'success' ? 'success' : 'error');
el.style.display = 'block';
if (type === 'success') setTimeout(()=>{ el.style.display = 'none'; }, 3000);
}
// Update stream status display
function updateStreamStatus(state) {
const statusEl = document.getElementById('streamStatus');
const startBtn = document.getElementById('startBtn');
const pauseBtn = document.getElementById('pauseBtn');
const stopBtn = document.getElementById('stopBtn');
statusEl.className = 'stream-status';
switch(state) {
case 'running':
statusEl.classList.add('status-running');
statusEl.innerHTML = '<span></span> Streaming';
startBtn.disabled = true;
pauseBtn.disabled = false;
stopBtn.disabled = false;
pauseBtn.innerHTML = '<span>⏸️</span> Pause';
isStreaming = true;
isPlotPaused = false;
break;
case 'paused':
statusEl.classList.add('status-paused');
statusEl.innerHTML = '<span></span> Paused';
startBtn.disabled = true;
pauseBtn.disabled = false;
stopBtn.disabled = false;
pauseBtn.innerHTML = '<span>▶️</span> Resume';
isStreaming = true;
isPlotPaused = true;
break;
case 'stopped':
statusEl.classList.add('status-stopped');
statusEl.innerHTML = '<span></span> Stopped';
startBtn.disabled = false;
pauseBtn.disabled = true;
stopBtn.disabled = true;
isStreaming = false;
isPlotPaused = false;
break;
}
}
// Stream control functions
async function startStream() {
try {
const response = await fetch('/start_stream', { method: 'POST' });
const data = await response.json();
if (data.status === 'success') {
updateStreamStatus('running');
showStatus('gainStatusMessage', 'Streaming started', 'success');
} else {
showStatus('gainStatusMessage', data.message, 'error');
}
} catch (err) {
console.error('Start stream error:', err);
showStatus('gainStatusMessage', 'Error starting stream', 'error');
}
}
async function stopStream() {
try {
const response = await fetch('/stop_stream', { method: 'POST' });
const data = await response.json();
if (data.status === 'success') {
updateStreamStatus('stopped');
showStatus('gainStatusMessage', 'Streaming stopped', 'success');
} else {
showStatus('gainStatusMessage', data.message, 'error');
}
} catch (err) {
console.error('Stop stream error:', err);
showStatus('gainStatusMessage', 'Error stopping stream', 'error');
}
}
async function pauseStream() {
try {
const response = await fetch('/pause_stream', { method: 'POST' });
const data = await response.json();
if (data.status === 'success') {
updateStreamStatus(data.state);
showStatus('gainStatusMessage', `Streaming ${data.state}`, 'success');
} else {
showStatus('gainStatusMessage', data.message, 'error');
}
} catch (err) {
console.error('Pause stream error:', err);
showStatus('gainStatusMessage', 'Error pausing stream', 'error');
}
}
// Check stream state periodically
async function checkStreamState() {
try {
const response = await fetch('/get_stream_state');
const data = await response.json();
updateStreamStatus(data.state);
} catch (err) {
console.error('Error checking stream state:', err);
}
}
// ---------------- Gain Form ----------------
const gainForm = document.getElementById('gainForm');
const toggles = document.querySelectorAll('.gain-toggle');
const gainUpdateBtn = document.getElementById('gainUpdateBtn');
const gainRefreshBtn = document.getElementById('gainRefreshBtn');
// enable/disable inputs depending on toggle
toggles.forEach(t => {
const inputId = t.dataset.input;
const inputEl = document.getElementById(inputId);
t.addEventListener('change', () => {
if (inputEl) {
inputEl.disabled = !t.checked;
if (t.checked) inputEl.focus();
}
});
});
// load existing gain values from server and populate inputs
function loadGains(){
fetch('/get_gains')
.then(r => r.json())
.then(data => {
if (!data) return;
if (data.usrp_tx_gain !== undefined) document.getElementById('usrp_tx_gain').value = data.usrp_tx_gain;
if (data.usrp_rx_gain !== undefined) document.getElementById('usrp_rx_gain').value = data.usrp_rx_gain;
if (data.scm_tx_gain !== undefined) document.getElementById('scm_tx_gain').value = data.scm_tx_gain;
if (data.scm_rx_gain !== undefined) document.getElementById('scm_rx_gain').value = data.scm_rx_gain;
// ensure toggles are off and inputs disabled initially
toggles.forEach(t => {
t.checked = false;
const input = document.getElementById(t.dataset.input);
if (input) input.disabled = true;
});
})
.catch(err => console.error('loadGains error', err));
}
gainForm.addEventListener('submit', function(e){
e.preventDefault();
const formData = new FormData();
let has = false;
toggles.forEach(t => {
if (t.checked) {
const inputId = t.dataset.input;
const val = document.getElementById(inputId).value;
formData.append(inputId, val);
has = true;
}
});
if (!has) {
showStatus('gainStatusMessage', 'Please enable at least one gain to update', 'error');
return;
}
gainUpdateBtn.disabled = true;
fetch('/update_gains', { method:'POST', body: formData })
.then(r => r.json())
.then(data => {
gainUpdateBtn.disabled = false;
if (data && data.status === 'success') {
showStatus('gainStatusMessage', data.message || 'Gains updated', 'success');
// reset toggles
toggles.forEach(t => {
t.checked = false;
const input = document.getElementById(t.dataset.input);
if (input) input.disabled = true;
});
// reload gains from server to reflect current state
loadGains();
} else {
showStatus('gainStatusMessage', data.message || 'Error updating gains', 'error');
}
})
.catch(err => {
gainUpdateBtn.disabled = false;
console.error(err);
showStatus('gainStatusMessage', 'Server error', 'error');
});
});
gainRefreshBtn.addEventListener('click', function(){
loadGains();
showStatus('gainStatusMessage','Gains reloaded','success');
});
// ---------------- Params Form ----------------
const paramForm = document.getElementById('paramForm');
paramForm.addEventListener('submit', function(e){
e.preventDefault();
const btn = document.getElementById('paramUpdateBtn');
const formData = new FormData(paramForm);
btn.disabled = true;
fetch('/update_params', { method:'POST', body: formData })
.then(r => r.json())
.then(data => {
btn.disabled = false;
if (data && data.status === 'success') {
showStatus('paramStatusMessage', data.message || 'Parameters updated', 'success');
} else {
showStatus('paramStatusMessage', data.message || 'Error updating parameters', 'error');
}
})
.catch(err => {
btn.disabled = false;
console.error(err);
showStatus('paramStatusMessage','Server error','error');
});
});
// ---------------- Plot refresh ----------------
function refreshPlot(){
if (!isStreaming || isPlotPaused) return;
// use cache-buster to avoid cached/partial file
plotImg.src = '/plot?_ts=' + Date.now();
}
// Start auto refresh only when streaming
function startAutoRefresh() {
refreshInterval = setInterval(refreshPlot, 500);
}
// Manual refresh button
document.getElementById('refreshPlotBtn').addEventListener('click', function() {
refreshPlot();
});
// Stream control buttons
document.getElementById('startBtn').addEventListener('click', startStream);
document.getElementById('stopBtn').addEventListener('click', stopStream);
document.getElementById('pauseBtn').addEventListener('click', pauseStream);
// Check stream state every 2 seconds
setInterval(checkStreamState, 2000);
// initial load
loadGains();
checkStreamState();
startAutoRefresh();
})();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

BIN
plot.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@ -36,8 +36,7 @@ dependencies = [
"matplotlib",
"numpy",
"pyzmq",
"pyserial",
"flask_socketio"
"pyserial"
]
[project.scripts]

View File

@ -1,494 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>IQ Spectrum Analyzer</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
margin: 0; padding: 20px;
background: #1a1a2e; color: #eee;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
h1 {
color: #00d4ff;
text-align: center;
margin-bottom: 20px;
font-size: 1.8em;
}
/* ---- Controls ---- */
.controls {
display: flex;
justify-content: center;
gap: 12px;
margin: 15px 0;
}
button {
padding: 10px 28px;
cursor: pointer;
background: #00d4ff;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-weight: bold;
font-size: 14px;
transition: all 0.2s;
}
button:hover { background: #33e0ff; transform: translateY(-1px); }
button:active { transform: translateY(0); }
button.stop-btn { background: #ff6b6b; color: #fff; }
button.stop-btn:hover { background: #ff8a8a; }
button.pause-btn { background: #ffd93d; color: #1a1a2e; }
button.pause-btn:hover { background: #ffe066; }
.stream-status {
text-align: center;
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
display: inline-block;
margin: 0 auto;
}
.status-running { background: rgba(0,212,255,0.15); color: #00d4ff; border: 1px solid rgba(0,212,255,0.3); }
.status-stopped { background: rgba(255,107,107,0.15); color: #ff6b6b; border: 1px solid rgba(255,107,107,0.3); }
.status-paused { background: rgba(255,217,61,0.15); color: #ffd93d; border: 1px solid rgba(255,217,61,0.3); }
/* ---- Stats bar ---- */
.stats-bar {
background: #16213e;
padding: 14px 20px;
border-radius: 10px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin: 15px 0;
border: 1px solid #1e3050;
}
.stat-item { text-align: center; }
.stat-value {
color: #00d4ff;
font-size: 18px;
font-weight: bold;
font-variant-numeric: tabular-nums;
}
.stat-value.good { color: #00d4ff; }
.stat-value.warn { color: #ffd93d; }
.stat-value.bad { color: #ff6b6b; }
.stat-label {
font-size: 11px;
color: #8899aa;
margin-top: 2px;
}
/* ---- PLOT CONTAINER — this is the key fix ---- */
.plot-container {
background: #0f0f23;
padding: 0; /* NO padding so image fills fully */
border-radius: 10px;
margin: 15px 0;
border: 1px solid #1e3050;
overflow: hidden; /* clip any overflow */
line-height: 0; /* remove gap below inline img */
}
#plot {
width: 100%; /* fill container width */
height: auto; /* maintain aspect ratio */
display: block; /* remove inline spacing */
border-radius: 10px;
}
/* ---- Parameters panel ---- */
.params-panel {
background: #16213e;
padding: 20px;
border-radius: 10px;
margin: 15px 0;
border: 1px solid #1e3050;
}
.params-panel h3 {
color: #00d4ff;
margin-bottom: 15px;
font-size: 1.1em;
}
.param-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.param-grid label {
font-size: 12px;
color: #8899aa;
display: block;
margin-bottom: 4px;
}
.param-grid input {
width: 100%;
padding: 8px 10px;
background: #0f0f23;
border: 1px solid #2a3a5e;
border-radius: 6px;
color: #eee;
font-size: 14px;
transition: border-color 0.2s;
}
.param-grid input:focus {
outline: none;
border-color: #00d4ff;
box-shadow: 0 0 0 2px rgba(0,212,255,0.15);
}
.apply-btn-wrapper {
text-align: center;
margin-top: 15px;
}
/* ---- Gain panel ---- */
.gain-panel {
background: #16213e;
padding: 20px;
border-radius: 10px;
margin: 15px 0;
border: 1px solid #1e3050;
}
.gain-panel h3 {
color: #00d4ff;
margin-bottom: 15px;
font-size: 1.1em;
}
.gain-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.gain-grid label {
font-size: 12px;
color: #8899aa;
display: block;
margin-bottom: 4px;
}
.gain-grid input {
width: 100%;
padding: 8px 10px;
background: #0f0f23;
border: 1px solid #2a3a5e;
border-radius: 6px;
color: #eee;
font-size: 14px;
}
.gain-grid input:focus {
outline: none;
border-color: #00d4ff;
}
/* ---- Footer ---- */
.footer {
text-align: center;
color: #445;
font-size: 12px;
margin-top: 20px;
padding: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>📡 IQ Spectrum Analyzer</h1>
<!-- Stream controls -->
<div class="controls">
<button onclick="startStream()" id="startBtn">▶ Start</button>
<button class="pause-btn" onclick="pauseStream()" id="pauseBtn">⏸ Pause</button>
<button class="stop-btn" onclick="stopStream()" id="stopBtn">⏹ Stop</button>
</div>
<div style="text-align:center; margin-bottom:10px;">
<span class="stream-status status-stopped" id="streamStatus">● Stopped</span>
</div>
<!-- Stats bar -->
<div class="stats-bar">
<div class="stat-item">
<div class="stat-value" id="slot">--</div>
<div class="stat-label">Current Slot</div>
</div>
<div class="stat-item">
<div class="stat-value" id="direction">--</div>
<div class="stat-label">Direction</div>
</div>
<div class="stat-item">
<div class="stat-value" id="power">--</div>
<div class="stat-label">Power (dB)</div>
</div>
<div class="stat-item">
<div class="stat-value" id="rbs">0</div>
<div class="stat-label">Allocated RBs</div>
</div>
<div class="stat-item">
<div class="stat-value" id="packets">0</div>
<div class="stat-label">IQ Packets</div>
</div>
<div class="stat-item">
<div class="stat-value" id="slots">0</div>
<div class="stat-label">Slots Correlated</div>
</div>
<div class="stat-item">
<div class="stat-value" id="correlation">0%</div>
<div class="stat-label">Correlation</div>
</div>
<div class="stat-item">
<div class="stat-value" id="slotRate">0</div>
<div class="stat-label">Slots/s</div>
</div>
<div class="stat-item">
<div class="stat-value" id="bandwidth">0</div>
<div class="stat-label">IQ BW (Mbps)</div>
</div>
</div>
<!-- Plot — no padding, image fills container -->
<div class="plot-container">
<img id="plot" src="/plot" alt="IQ Spectrum Plot">
</div>
<!-- Parameters -->
<div class="params-panel">
<h3>⚙ Spectrogram Parameters</h3>
<div class="param-grid">
<div>
<label>Center Freq (Hz)</label>
<input type="number" id="center_freq" value="3415000000" step="1000000">
</div>
<div>
<label>Sample Rate (Hz)</label>
<input type="number" id="sample_rate" value="23040000" step="1000000">
</div>
<div>
<label>FFT Size</label>
<input type="number" id="fft_size" value="1024" step="128">
</div>
<div>
<label>Window (ms)</label>
<input type="number" id="window_ms" value="20" step="1">
</div>
</div>
<div class="apply-btn-wrapper">
<button onclick="updateParams()">⚡ Apply Parameters</button>
</div>
</div>
<!-- Gains -->
<div class="gain-panel">
<h3>🎚 Gain Settings</h3>
<div class="gain-grid">
<div>
<label>USRP Tx Gain</label>
<input type="number" id="usrp_tx_gain" value="60">
</div>
<div>
<label>USRP Rx Gain</label>
<input type="number" id="usrp_rx_gain" value="30">
</div>
<div>
<label>SCM Tx Gain</label>
<input type="number" id="scm_tx_gain" value="30">
</div>
<div>
<label>SCM Rx Gain</label>
<input type="number" id="scm_rx_gain" value="30">
</div>
</div>
<div class="apply-btn-wrapper">
<button onclick="updateGains()">🔄 Update Gains</button>
<button onclick="loadGains()" style="background:#2a3a5e; color:#eee; margin-left:8px;">↻ Refresh</button>
</div>
</div>
<div class="footer">
IQ Spectrum Analyzer • Real-time RF monitoring with IQmetadata correlation
</div>
</div>
<script>
(function() {
const socket = io();
const plotImg = document.getElementById('plot');
let isStreaming = false;
// ---- WebSocket: real-time plot push ----
socket.on('plot_update', function(data) {
if (data && data.image) {
plotImg.src = 'data:image/png;base64,' + data.image;
}
});
// ---- Fallback: poll /plot if websocket image stale ----
// (The websocket push is primary; this is backup)
setInterval(function() {
if (isStreaming && !document.hidden) {
// Only use polling as fallback — websocket handles most updates
// Uncomment below if websocket is unreliable:
// plotImg.src = '/plot?_ts=' + Date.now();
}
}, 2000);
// ---- Stream controls ----
window.startStream = function() {
fetch('/start_stream', {method: 'POST'})
.then(r => r.json())
.then(d => { if (d.status === 'success') updateStatus('running'); });
};
window.stopStream = function() {
fetch('/stop_stream', {method: 'POST'})
.then(r => r.json())
.then(d => { if (d.status === 'success') updateStatus('stopped'); });
};
window.pauseStream = function() {
fetch('/pause_stream', {method: 'POST'})
.then(r => r.json())
.then(d => { if (d.status === 'success') updateStatus(d.state); });
};
function updateStatus(state) {
const el = document.getElementById('streamStatus');
el.className = 'stream-status';
switch(state) {
case 'running':
el.className += ' status-running';
el.textContent = '● Streaming';
isStreaming = true;
break;
case 'paused':
el.className += ' status-paused';
el.textContent = '● Paused';
isStreaming = true;
break;
default:
el.className += ' status-stopped';
el.textContent = '● Stopped';
isStreaming = false;
}
}
// ---- Stats polling ----
setInterval(function() {
fetch('/get_stats')
.then(r => r.json())
.then(d => {
document.getElementById('slot').textContent = d.current_slot;
document.getElementById('direction').textContent = d.direction || '--';
document.getElementById('power').textContent =
d.power_db !== null ? d.power_db.toFixed(1) : '--';
document.getElementById('rbs').textContent = d.allocated_rbs || 0;
document.getElementById('packets').textContent =
d.packets_received.toLocaleString();
document.getElementById('slots').textContent =
d.slots_correlated.toLocaleString();
// Correlation rate with color coding
const corrEl = document.getElementById('correlation');
const corrVal = d.correlation_rate || 0;
corrEl.textContent = corrVal.toFixed(1) + '%';
corrEl.className = 'stat-value ' +
(corrVal > 90 ? 'good' : corrVal > 50 ? 'warn' : 'bad');
document.getElementById('slotRate').textContent =
(d.slot_rate || 0).toFixed(1);
document.getElementById('bandwidth').textContent =
(d.iq_bandwidth_mbps || 0).toFixed(1);
})
.catch(() => {});
}, 1000);
// ---- Stream state polling ----
setInterval(function() {
fetch('/get_stream_state')
.then(r => r.json())
.then(d => updateStatus(d.state))
.catch(() => {});
}, 3000);
// ---- Parameters ----
window.updateParams = function() {
const formData = new FormData();
['center_freq', 'sample_rate', 'fft_size', 'window_ms'].forEach(id => {
formData.append(id, document.getElementById(id).value);
});
fetch('/update_params', {method: 'POST', body: formData})
.then(r => r.json())
.then(d => {
if (d.status === 'success') {
showToast('Parameters updated');
}
});
};
// ---- Gains ----
window.updateGains = function() {
const formData = new FormData();
['usrp_tx_gain', 'usrp_rx_gain', 'scm_tx_gain', 'scm_rx_gain'].forEach(id => {
formData.append(id, document.getElementById(id).value);
});
fetch('/update_gains', {method: 'POST', body: formData})
.then(r => r.json())
.then(d => {
if (d.status === 'success') showToast('Gains updated');
});
};
window.loadGains = function() {
fetch('/get_gains')
.then(r => r.json())
.then(d => {
if (d.usrp_tx_gain !== undefined)
document.getElementById('usrp_tx_gain').value = d.usrp_tx_gain;
if (d.usrp_rx_gain !== undefined)
document.getElementById('usrp_rx_gain').value = d.usrp_rx_gain;
if (d.scm_tx_gain !== undefined)
document.getElementById('scm_tx_gain').value = d.scm_tx_gain;
if (d.scm_rx_gain !== undefined)
document.getElementById('scm_rx_gain').value = d.scm_rx_gain;
showToast('Gains refreshed');
});
};
// ---- Toast notification ----
function showToast(msg) {
let toast = document.createElement('div');
toast.textContent = msg;
toast.style.cssText =
'position:fixed; bottom:30px; left:50%; transform:translateX(-50%);' +
'background:#00d4ff; color:#1a1a2e; padding:10px 24px; border-radius:8px;' +
'font-weight:bold; font-size:14px; z-index:9999; opacity:0;' +
'transition:opacity 0.3s;';
document.body.appendChild(toast);
requestAnimationFrame(() => toast.style.opacity = '1');
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 2000);
}
// ---- Init ----
loadGains();
})();
</script>
</body>
</html>