Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
Ggael
|
50df0a2f95 |
BIN
dist/gain_viz-0.1.0-py3-none-any.whl
vendored
Normal file
BIN
dist/gain_viz-0.1.0-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
dist/gain_viz-0.1.0.tar.gz
vendored
Normal file
BIN
dist/gain_viz-0.1.0.tar.gz
vendored
Normal file
Binary file not shown.
73
gain_viz.egg-info/PKG-INFO
Normal file
73
gain_viz.egg-info/PKG-INFO
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
# 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.
|
||||||
14
gain_viz.egg-info/SOURCES.txt
Normal file
14
gain_viz.egg-info/SOURCES.txt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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
|
||||||
1
gain_viz.egg-info/dependency_links.txt
Normal file
1
gain_viz.egg-info/dependency_links.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
2
gain_viz.egg-info/entry_points.txt
Normal file
2
gain_viz.egg-info/entry_points.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[console_scripts]
|
||||||
|
gain_viz = gain_viz.app:main
|
||||||
6
gain_viz.egg-info/requires.txt
Normal file
6
gain_viz.egg-info/requires.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
flask
|
||||||
|
matplotlib
|
||||||
|
numpy
|
||||||
|
pyzmq
|
||||||
|
pyserial
|
||||||
|
flask_socketio
|
||||||
1
gain_viz.egg-info/top_level.txt
Normal file
1
gain_viz.egg-info/top_level.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
gain_viz
|
||||||
12
gain_viz.json
Normal file
12
gain_viz.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
BIN
gain_viz/__pycache__/iq_metadata_interface.cpython-311.pyc
Normal file
BIN
gain_viz/__pycache__/iq_metadata_interface.cpython-311.pyc
Normal file
Binary file not shown.
651
gain_viz/app.py
651
gain_viz/app.py
|
|
@ -1,6 +1,8 @@
|
||||||
from flask import Flask, render_template, send_file, request, jsonify
|
from flask import Flask, render_template, send_file, request, jsonify
|
||||||
import zmq
|
from flask_socketio import SocketIO
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import matplotlib.ticker as ticker
|
import matplotlib.ticker as ticker
|
||||||
import os
|
import os
|
||||||
|
|
@ -8,8 +10,14 @@ import threading
|
||||||
import time
|
import time
|
||||||
import serial
|
import serial
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import traceback
|
||||||
|
import socket
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||||
PLOT_PATH = os.path.join(os.getcwd(), "plot.png")
|
PLOT_PATH = os.path.join(os.getcwd(), "plot.png")
|
||||||
|
|
||||||
# ----------------- Shared Config -----------------
|
# ----------------- Shared Config -----------------
|
||||||
|
|
@ -22,42 +30,55 @@ config = {
|
||||||
"window_ms": 20,
|
"window_ms": 20,
|
||||||
"center_freq": 3.415e9,
|
"center_freq": 3.415e9,
|
||||||
"NFFT": 1024,
|
"NFFT": 1024,
|
||||||
"tcp_port": 5556,
|
"iq_port": 5588,
|
||||||
"streaming": False, # Added streaming state
|
"streaming": False,
|
||||||
|
"packets_received": 0,
|
||||||
|
"iq_bandwidth_mbps": 0.0,
|
||||||
}
|
}
|
||||||
config_lock = threading.Lock()
|
config_lock = threading.Lock()
|
||||||
|
|
||||||
# Global variables
|
|
||||||
usrp_tx_gain = config["usrp_tx_gain"]
|
usrp_tx_gain = config["usrp_tx_gain"]
|
||||||
usrp_rx_gain = config["usrp_rx_gain"]
|
usrp_rx_gain = config["usrp_rx_gain"]
|
||||||
scm_tx_gain = config["scm_tx_gain"]
|
scm_tx_gain = config["scm_tx_gain"]
|
||||||
scm_rx_gain = config["scm_rx_gain"]
|
scm_rx_gain = config["scm_rx_gain"]
|
||||||
|
|
||||||
# Plotting thread control
|
|
||||||
plot_thread = None
|
plot_thread = None
|
||||||
|
rx_thread = None
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
pause_event = threading.Event()
|
pause_event = threading.Event()
|
||||||
|
|
||||||
# ----------------- Serial / SCM -----------------
|
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
|
||||||
|
|
||||||
|
|
||||||
def connect_serial(port, baudrate=115200, timeout=1):
|
def connect_serial(port, baudrate=115200, timeout=1):
|
||||||
"""Connect to a serial port with even parity."""
|
|
||||||
try:
|
try:
|
||||||
ser = serial.Serial(
|
return serial.Serial(
|
||||||
port=port,
|
port=port,
|
||||||
baudrate=baudrate,
|
baudrate=baudrate,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
bytesize=serial.EIGHTBITS,
|
bytesize=serial.EIGHTBITS,
|
||||||
parity=serial.PARITY_EVEN,
|
parity=serial.PARITY_EVEN,
|
||||||
stopbits=serial.STOPBITS_ONE
|
stopbits=serial.STOPBITS_ONE,
|
||||||
)
|
)
|
||||||
return ser
|
|
||||||
except serial.SerialException as e:
|
except serial.SerialException as e:
|
||||||
print(f"Error connecting to {port}: {e}")
|
print(f"Error connecting to {port}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def send_command(ser, command):
|
def send_command(ser, command):
|
||||||
if ser and ser.is_open:
|
if ser and ser.is_open:
|
||||||
ser.write(command.encode('utf-8'))
|
ser.write(command.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def receive_feedback(ser):
|
def receive_feedback(ser):
|
||||||
if ser and ser.is_open:
|
if ser and ser.is_open:
|
||||||
|
|
@ -74,13 +95,12 @@ def receive_feedback(ser):
|
||||||
return ""
|
return ""
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def scm_conf(port, baudrate, rx_cmd, tx_cmd):
|
def scm_conf(port, baudrate, rx_cmd, tx_cmd):
|
||||||
ser = connect_serial(port, baudrate)
|
ser = connect_serial(port, baudrate)
|
||||||
commands = [rx_cmd, tx_cmd]
|
|
||||||
if ser:
|
if ser:
|
||||||
for cmd in commands:
|
for cmd in [rx_cmd, tx_cmd]:
|
||||||
feedback = None
|
feedback, attempt = None, 0
|
||||||
attempt = 0
|
|
||||||
while feedback != "OK" and attempt < 5:
|
while feedback != "OK" and attempt < 5:
|
||||||
send_command(ser, cmd + "\r")
|
send_command(ser, cmd + "\r")
|
||||||
feedback = receive_feedback(ser)
|
feedback = receive_feedback(ser)
|
||||||
|
|
@ -89,34 +109,29 @@ def scm_conf(port, baudrate, rx_cmd, tx_cmd):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# ----------------- Gain Updates -----------------
|
|
||||||
def gain_update(usrp_tx, usrp_rx, scm_tx, scm_rx):
|
def gain_update(usrp_tx, usrp_rx, scm_tx, scm_rx):
|
||||||
global usrp_tx_gain, usrp_rx_gain, scm_tx_gain, scm_rx_gain
|
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:
|
if usrp_tx != usrp_tx_gain:
|
||||||
usrp_tx_gain = usrp_tx
|
usrp_tx_gain = usrp_tx
|
||||||
os.system(f"tmux send-keys -t ran 'tx_gain 0 {usrp_tx_gain} ' C-m")
|
os.system(f"tmux send-keys -t ran 'tx_gain 0 {usrp_tx_gain} ' C-m")
|
||||||
|
|
||||||
if usrp_rx != usrp_rx_gain:
|
if usrp_rx != usrp_rx_gain:
|
||||||
usrp_rx_gain = usrp_rx
|
usrp_rx_gain = usrp_rx
|
||||||
os.system(f"tmux send-keys -t ran 'rx_gain 0 {usrp_rx_gain} ' C-m")
|
os.system(f"tmux send-keys -t ran 'rx_gain 0 {usrp_rx_gain} ' C-m")
|
||||||
|
|
||||||
if scm_tx != scm_tx_gain:
|
if scm_tx != scm_tx_gain:
|
||||||
scm_tx_gain = scm_tx
|
scm_tx_gain = scm_tx
|
||||||
scm_change = True
|
scm_change = True
|
||||||
|
|
||||||
if scm_rx != scm_rx_gain:
|
if scm_rx != scm_rx_gain:
|
||||||
scm_rx_gain = scm_rx
|
scm_rx_gain = scm_rx
|
||||||
scm_change = True
|
scm_change = True
|
||||||
|
|
||||||
|
if scm_change:
|
||||||
t_cmd = f"HW:GAIN 0 TX 0 {scm_tx_gain}"
|
t_cmd = f"HW:GAIN 0 TX 0 {scm_tx_gain}"
|
||||||
r_cmd = f"HW:GAIN 1 RX 0 {scm_rx_gain}"
|
r_cmd = f"HW:GAIN 1 RX 0 {scm_rx_gain}"
|
||||||
|
|
||||||
if scm_change:
|
|
||||||
scm_conf("/dev/ttyUSB0", 115200, r_cmd, t_cmd)
|
scm_conf("/dev/ttyUSB0", 115200, r_cmd, t_cmd)
|
||||||
scm_conf("/dev/ttyUSB1", 115200, r_cmd, t_cmd)
|
scm_conf("/dev/ttyUSB1", 115200, r_cmd, t_cmd)
|
||||||
|
|
||||||
with config_lock:
|
with config_lock:
|
||||||
config["scm_tx_gain"] = scm_tx_gain
|
config["scm_tx_gain"] = scm_tx_gain
|
||||||
config["scm_rx_gain"] = scm_rx_gain
|
config["scm_rx_gain"] = scm_rx_gain
|
||||||
|
|
@ -127,14 +142,96 @@ def gain_update(usrp_tx, usrp_rx, scm_tx, scm_rx):
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# ----------------- Plot Generation -----------------
|
|
||||||
def generate_spectrum_plot():
|
def parse_iq_payload(payload):
|
||||||
socket = None
|
if len(payload) <= 2:
|
||||||
iq_sample = np.zeros(1, dtype=np.complex64)
|
return None
|
||||||
last_port = 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():
|
while not stop_event.is_set():
|
||||||
# Check if we're paused
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_spectrum_plot():
|
||||||
|
while not stop_event.is_set():
|
||||||
if pause_event.is_set():
|
if pause_event.is_set():
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
continue
|
continue
|
||||||
|
|
@ -144,118 +241,179 @@ def generate_spectrum_plot():
|
||||||
window_ms = config["window_ms"]
|
window_ms = config["window_ms"]
|
||||||
center_freq = config["center_freq"]
|
center_freq = config["center_freq"]
|
||||||
NFFT = config["NFFT"]
|
NFFT = config["NFFT"]
|
||||||
tcp_port = config["tcp_port"]
|
|
||||||
streaming = config["streaming"]
|
streaming = config["streaming"]
|
||||||
|
|
||||||
# Only process if streaming is active
|
|
||||||
if not streaming:
|
if not streaming:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Reconnect if port changed or socket is None
|
with iq_buffer_lock:
|
||||||
if socket is None or tcp_port != last_port:
|
current_iq = np.array(iq_buffer, dtype=np.complex64)
|
||||||
if socket:
|
|
||||||
socket.close()
|
if len(current_iq) == 0:
|
||||||
try:
|
fig, axes = plt.subplots(2, 1, figsize=(14, 7))
|
||||||
context = zmq.Context()
|
fig.patch.set_facecolor('#1a1a2e')
|
||||||
socket = context.socket(zmq.SUB)
|
for ax in axes:
|
||||||
socket.setsockopt(zmq.CONFLATE, 1)
|
ax.set_facecolor('#1a1a2e')
|
||||||
socket.setsockopt_string(zmq.SUBSCRIBE, "")
|
ax.set_xticks([])
|
||||||
socket.setsockopt(zmq.RCVTIMEO, 1000)
|
ax.set_yticks([])
|
||||||
socket.connect(f"tcp://localhost:{tcp_port}")
|
for spine in ax.spines.values():
|
||||||
last_port = tcp_port
|
spine.set_visible(False)
|
||||||
print(f"Connected to ZMQ on port {tcp_port}")
|
axes[0].text(0.5, 0.5, "Waiting for IQ samples on UDP port 5588 ...",
|
||||||
except Exception as e:
|
ha="center", va="center", transform=axes[0].transAxes,
|
||||||
print(f"ZMQ connection error: {e}")
|
fontsize=18, color="#00d4ff")
|
||||||
socket = None
|
_save_and_emit(fig)
|
||||||
time.sleep(1)
|
time.sleep(PLOT_REFRESH_SEC)
|
||||||
continue
|
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:
|
try:
|
||||||
msg = socket.recv(zmq.NOBLOCK)
|
window_samples = int(sample_rate * window_ms / 1000)
|
||||||
float_data = np.frombuffer(msg, dtype=np.float32)
|
if len(current_iq) > window_samples:
|
||||||
if float_data.size >= 2:
|
current_iq = current_iq[-window_samples:]
|
||||||
complex_data = float_data.reshape(-1, 2)
|
elif len(current_iq) < window_samples:
|
||||||
iq_all = complex_data[:, 0] + 1j * complex_data[:, 1]
|
current_iq = np.pad(current_iq, (window_samples - len(current_iq), 0), mode="constant")
|
||||||
if len(iq_all) >= window_samples:
|
|
||||||
iq_sample = iq_all[-window_samples:]
|
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)
|
||||||
|
|
||||||
|
if len(current_iq) > MAX_TIME_PLOT_POINTS:
|
||||||
|
step = max(1, len(current_iq) // MAX_TIME_PLOT_POINTS)
|
||||||
|
plot_iq = current_iq[::step]
|
||||||
else:
|
else:
|
||||||
iq_sample = np.pad(iq_all, (window_samples - len(iq_all), 0))
|
plot_iq = current_iq
|
||||||
|
|
||||||
# Create plot
|
times_ms = np.linspace(0, total_duration_ms, len(plot_iq), endpoint=False)
|
||||||
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 6))
|
|
||||||
fig.subplots_adjust(hspace=0.4)
|
|
||||||
|
|
||||||
# Time-domain plot
|
fig, axes = plt.subplots(
|
||||||
times_ms = np.arange(len(iq_sample)) * 1000 / sample_rate
|
2, 1,
|
||||||
ax1.plot(times_ms, np.real(iq_sample), label="Real", color='b')
|
figsize=(14, 7),
|
||||||
ax1.plot(times_ms, np.imag(iq_sample), label="Imag", color='r')
|
gridspec_kw={"height_ratios": [1, 1]},
|
||||||
ax1.set_xlim(0, window_ms)
|
)
|
||||||
ax1.set_xlabel("Time (ms)")
|
fig.patch.set_facecolor('#1a1a2e')
|
||||||
ax1.set_ylabel("IQ Amplitude")
|
fig.subplots_adjust(left=0.07, right=0.98, top=0.94, bottom=0.08, hspace=0.32)
|
||||||
ax1.grid(True, which='both', linestyle='--', linewidth=0.5)
|
|
||||||
ax1.legend()
|
|
||||||
|
|
||||||
# Spectrogram
|
ax_time = axes[0]
|
||||||
cmap = plt.get_cmap('twilight')
|
ax_spec = axes[1]
|
||||||
ax2.specgram(
|
|
||||||
iq_sample,
|
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,
|
||||||
Fs=sample_rate,
|
Fs=sample_rate,
|
||||||
Fc=center_freq,
|
Fc=center_freq,
|
||||||
NFFT=NFFT,
|
NFFT=NFFT,
|
||||||
noverlap=512,
|
noverlap=noverlap,
|
||||||
cmap=cmap
|
cmap="twilight",
|
||||||
|
mode="magnitude",
|
||||||
|
scale="dB",
|
||||||
)
|
)
|
||||||
ax2.set_xlabel("Time (ms)")
|
|
||||||
ax2.set_ylabel("Frequency (Hz)")
|
ax_spec.set_xlim(0, total_duration_s)
|
||||||
ax2.grid(False)
|
ax_spec.set_ylim(freq_low, freq_high)
|
||||||
ax2.set_ylim(center_freq - sample_rate / 2,
|
ax_spec.margins(x=0, y=0)
|
||||||
center_freq + sample_rate / 2)
|
|
||||||
ax2.xaxis.set_major_formatter(
|
ax_spec.xaxis.set_major_formatter(
|
||||||
ticker.FuncFormatter(lambda t, pos: '{0:g}'.format(t*1e3))
|
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.xaxis.set_minor_locator(ticker.AutoMinorLocator())
|
|
||||||
|
|
||||||
plt.savefig(PLOT_PATH, bbox_inches='tight')
|
ax_spec.set_xlabel("Time (ms)", color='#aaa', fontsize=9)
|
||||||
plt.close(fig)
|
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)
|
||||||
|
|
||||||
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:
|
except Exception as e:
|
||||||
print(f"Plot generation error: {e}")
|
print(f"Plot generation error: {e}")
|
||||||
fig, ax = plt.subplots(figsize=(12, 6))
|
traceback.print_exc()
|
||||||
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(0.1)
|
time.sleep(PLOT_REFRESH_SEC)
|
||||||
|
|
||||||
# Cleanup when stopping
|
|
||||||
if socket:
|
|
||||||
socket.close()
|
|
||||||
print("Plotting thread stopped")
|
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():
|
def start_plotting():
|
||||||
"""Start the plotting thread"""
|
global plot_thread, rx_thread, latest_iq_data, iq_buffer
|
||||||
global plot_thread, stop_event, pause_event
|
|
||||||
|
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
pause_event.clear()
|
pause_event.clear()
|
||||||
|
|
||||||
|
with latest_data_lock:
|
||||||
|
latest_iq_data = None
|
||||||
|
|
||||||
with config_lock:
|
with config_lock:
|
||||||
config["streaming"] = True
|
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():
|
if plot_thread is None or not plot_thread.is_alive():
|
||||||
plot_thread = threading.Thread(target=generate_spectrum_plot, daemon=True)
|
plot_thread = threading.Thread(target=generate_spectrum_plot, daemon=True)
|
||||||
|
|
@ -264,194 +422,213 @@ def start_plotting():
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def stop_plotting():
|
def stop_plotting():
|
||||||
"""Stop the plotting thread"""
|
global plot_thread, rx_thread, udp_sock
|
||||||
global plot_thread, stop_event
|
|
||||||
|
|
||||||
with config_lock:
|
with config_lock:
|
||||||
config["streaming"] = False
|
config["streaming"] = False
|
||||||
|
|
||||||
stop_event.set()
|
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():
|
if plot_thread and plot_thread.is_alive():
|
||||||
plot_thread.join(timeout=2.0)
|
plot_thread.join(timeout=3.0)
|
||||||
|
|
||||||
# Create stopped message plot
|
fig, ax = plt.subplots(figsize=(14, 7))
|
||||||
fig, ax = plt.subplots(figsize=(12, 6))
|
fig.patch.set_facecolor('#1a1a2e')
|
||||||
|
ax.set_facecolor('#1a1a2e')
|
||||||
ax.text(0.5, 0.5, "Streaming Stopped\nClick Start to begin",
|
ax.text(0.5, 0.5, "Streaming Stopped\nClick Start to begin",
|
||||||
ha='center', va='center', transform=ax.transAxes, fontsize=16)
|
ha="center", va="center", transform=ax.transAxes,
|
||||||
ax.set_title("Spectrum Analyzer - Stopped")
|
fontsize=18, color="#00d4ff")
|
||||||
plt.savefig(PLOT_PATH, bbox_inches='tight')
|
ax.set_xticks([])
|
||||||
plt.close(fig)
|
ax.set_yticks([])
|
||||||
|
for spine in ax.spines.values():
|
||||||
print("Plotting thread stopped")
|
spine.set_visible(False)
|
||||||
|
_save_and_emit(fig)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def pause_plotting():
|
|
||||||
"""Pause the plotting updates"""
|
|
||||||
global pause_event
|
|
||||||
|
|
||||||
|
def pause_plotting():
|
||||||
if pause_event.is_set():
|
if pause_event.is_set():
|
||||||
pause_event.clear()
|
pause_event.clear()
|
||||||
print("Plotting resumed")
|
|
||||||
return "resumed"
|
return "resumed"
|
||||||
else:
|
else:
|
||||||
pause_event.set()
|
pause_event.set()
|
||||||
print("Plotting paused")
|
|
||||||
return "paused"
|
return "paused"
|
||||||
|
|
||||||
# ----------------- Flask Routes -----------------
|
|
||||||
@app.route('/')
|
@app.route("/")
|
||||||
def index():
|
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:
|
@app.route("/plot")
|
||||||
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
|
|
||||||
|
|
||||||
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():
|
def plot():
|
||||||
try:
|
return send_file(PLOT_PATH, mimetype="image/png")
|
||||||
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('/update_params', methods=['POST'])
|
@app.route("/start_stream", 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():
|
def start_stream():
|
||||||
try:
|
try:
|
||||||
success = start_plotting()
|
start_plotting()
|
||||||
if success:
|
return jsonify(status="success", message="Streaming started")
|
||||||
return jsonify({"status": "success", "message": "Streaming started"})
|
|
||||||
else:
|
|
||||||
return jsonify({"status": "error", "message": "Failed to start streaming"}), 500
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"status": "error", "message": f"Error: {str(e)}"}), 500
|
return jsonify(status="error", message=str(e)), 500
|
||||||
|
|
||||||
@app.route('/stop_stream', methods=['POST'])
|
|
||||||
|
@app.route("/stop_stream", methods=["POST"])
|
||||||
def stop_stream():
|
def stop_stream():
|
||||||
try:
|
try:
|
||||||
success = stop_plotting()
|
stop_plotting()
|
||||||
if success:
|
return jsonify(status="success", message="Streaming stopped")
|
||||||
return jsonify({"status": "success", "message": "Streaming stopped"})
|
|
||||||
else:
|
|
||||||
return jsonify({"status": "error", "message": "Failed to stop streaming"}), 500
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"status": "error", "message": f"Error: {str(e)}"}), 500
|
return jsonify(status="error", message=str(e)), 500
|
||||||
|
|
||||||
@app.route('/pause_stream', methods=['POST'])
|
|
||||||
|
@app.route("/pause_stream", methods=["POST"])
|
||||||
def pause_stream():
|
def pause_stream():
|
||||||
try:
|
try:
|
||||||
result = pause_plotting()
|
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:
|
except Exception as e:
|
||||||
return jsonify({"status": "error", "message": f"Error: {str(e)}"}), 500
|
return jsonify(status="error", message=str(e)), 500
|
||||||
|
|
||||||
@app.route('/get_stream_state', methods=['GET'])
|
|
||||||
|
@app.route("/get_stream_state")
|
||||||
def get_stream_state():
|
def get_stream_state():
|
||||||
with config_lock:
|
with config_lock:
|
||||||
streaming = config["streaming"]
|
streaming = config["streaming"]
|
||||||
paused = pause_event.is_set()
|
paused = pause_event.is_set()
|
||||||
|
|
||||||
state = "stopped"
|
|
||||||
if streaming and not paused:
|
if streaming and not paused:
|
||||||
state = "running"
|
state = "running"
|
||||||
elif streaming and paused:
|
elif streaming and paused:
|
||||||
state = "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():
|
def save_config():
|
||||||
with config_lock:
|
with config_lock:
|
||||||
cfg = dict(config)
|
cfg = dict(config)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(os.getcwd(), "gain_viz.json"), 'w') as f:
|
with open(os.path.join(os.getcwd(), "gain_viz.json"), "w") as f:
|
||||||
json.dump(cfg, f, indent=2)
|
json.dump(cfg, f, indent=2, default=str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving config: {e}")
|
print(f"Error saving config: {e}")
|
||||||
|
|
||||||
# ----------------- Main -----------------
|
|
||||||
def main():
|
def main():
|
||||||
# Ensure placeholder image exists
|
|
||||||
if not os.path.exists(PLOT_PATH):
|
if not os.path.exists(PLOT_PATH):
|
||||||
fig, ax = plt.subplots(figsize=(12, 6))
|
fig, ax = plt.subplots(figsize=(14, 7))
|
||||||
ax.text(0.5, 0.5, "Click Start to begin streaming", ha='center', va='center', fontsize=16)
|
fig.patch.set_facecolor('#1a1a2e')
|
||||||
ax.set_title("Gain-Viz Spectrum Analyzer - Ready")
|
ax.set_facecolor('#1a1a2e')
|
||||||
plt.savefig(PLOT_PATH)
|
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())
|
||||||
plt.close(fig)
|
plt.close(fig)
|
||||||
|
|
||||||
print("Gain-Viz server started. Use the web interface to control streaming.")
|
print("=" * 60)
|
||||||
app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=False)
|
print(" IQ Spectrum Analyzer (UDP IQ only, optimized)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
socketio.run(
|
||||||
|
app,
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=5000,
|
||||||
|
debug=True,
|
||||||
|
use_reloader=False,
|
||||||
|
allow_unsafe_werkzeug=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
16
gain_viz/gain_viz.json
Normal file
16
gain_viz/gain_viz.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
1066
gain_viz/iq_metadata_interface.py
Normal file
1066
gain_viz/iq_metadata_interface.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
gain_viz/plot.png
Normal file
BIN
gain_viz/plot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 689 KiB |
954
gain_viz/templates/ind.html
Normal file
954
gain_viz/templates/ind.html
Normal file
|
|
@ -0,0 +1,954 @@
|
||||||
|
<!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
|
|
@ -36,7 +36,8 @@ dependencies = [
|
||||||
"matplotlib",
|
"matplotlib",
|
||||||
"numpy",
|
"numpy",
|
||||||
"pyzmq",
|
"pyzmq",
|
||||||
"pyserial"
|
"pyserial",
|
||||||
|
"flask_socketio"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
494
templates/index.html
Normal file
494
templates/index.html
Normal file
|
|
@ -0,0 +1,494 @@
|
||||||
|
<!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 IQ–metadata 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>
|
||||||
Loading…
Reference in New Issue
Block a user