brainstorming:
硬件能力分析
1. USRP B210 的能力边界
| 参数 | 规格 | 对反无系统的意义 |
|---|---|---|
| 频率范围 | 70 MHz -- 6 GHz | ✅ 覆盖无人机主流频段(2.4G / 5.8G / 915M / 433M) |
| 瞬时带宽 | 最大 56 MHz(USB 3.0) | ✅ 可一次看完整个 2.4G ISM 频段(83.5 MHz)虽勉强但可用 |
| 通道数 | 2×2 MIMO | ✅ 可做双天线测向(干涉/相位差) |
| ADC | 12-bit | 灵敏度够用 |
| 接口 | USB 3.0 | ⚠️ 树莓派的瓶颈在这里 |
2. 树莓派的瓶颈(⚠️ 关键)
| 型号 | USB 3.0 | 算力 | 评价 |
|---|---|---|---|
| Pi 4B (4G/8G) | ✅ 有 | 中 | 最低可用配置,勉强跑 20 MHz 带宽 |
| Pi 5 (8G) | ✅ 有 | 高 | 推荐,可跑 40+ MHz 实时处理 |
| Pi 3B/4B (2G) | ❌/低 | 低 | 不推荐 |
现实检查 :B210 满血工作(56 MHz @ 12bit I/Q)需要约 170 MB/s 持续带宽 ,树莓派 5 的 USB 3.0 + 内存带宽刚好能扛住存盘或单流 FFT ,但要做复杂实时解调会吃力。
建议的折中方案:
- 采样带宽设到 20-40 MHz(而不是 56 MHz)
- FFT/频谱瀑布 在树莓派上实时做 ✅
- 深度协议解析(如 DJI OcuSync 的 DroneID 解码)建议把 I/Q 数据传到 PC 处理,或用树莓派做初筛
无人机信号特征知识库
这是系统的"大脑",你需要知道要侦测什么。
1. 常见无人机通信频段
| 频段 | 用途 | 典型机型 |
|---|---|---|
| 2.400 -- 2.4835 GHz | 图传 + 遥控(最主流) | DJI Mavic/Mini/Air 系列、Autel、大部分 FPV |
| 5.725 -- 5.850 GHz | 图传(高清) | DJI 高端、FPV 模拟图传 |
| 5.150 -- 5.250 GHz | 图传(部分) | DJI OcuSync 2.0/3.0 |
| 900 MHz (902-928 ISM) | 远距离遥控 | 部分工业无人机、TBS Crossfire |
| 433 MHz / 868 MHz | 遥测/低速链路 | DIY 机型、MAVLink |
| 1.2 GHz | 模拟图传(小众) | FPV 老玩家 |
2. 可识别的无人机信号特征(指纹)
- DJI OcuSync / Lightbridge :跳频图案特殊,有 DroneID 广播帧(含序列号、GPS 坐标、操控者位置),已被逆向过,开源项目有解码器。
- WiFi 无人机(Parrot、Tello、廉价机):发 802.11 beacon,SSID 有特征字符串("Tello-xxx"、"ARDrone")。
- Remote ID(2024+ 新法规):欧美强制广播的标准化识别信号(Wi-Fi NAN / Bluetooth LE Long Range),有现成协议。
- FPV 数字图传(DJI O3、Walksnail、HDZero):固定带宽的 OFDM,跳频模式各异。
- 模拟图传 (5.8G VTX):连续 FM 调频信号,带宽 ~18 MHz,频谱"平顶状",极易识别。
软件技术栈推荐
方案 A:快速原型(推荐入门)
[ USRP B210 ] ←USB3.0→ [ 树莓派 5 ]
↓
[ UHD 驱动 ]
↓
[ GNU Radio 3.10 ]
↓
┌────────────┬────────────┬──────────────┐
↓ ↓ ↓ ↓
频谱瀑布 能量检测 特征匹配 Web 前端
(QT GUI) (CFAR 阈值) (模板比对) (Flask+WS)
依赖安装(树莓派 OS 64-bit):
bash
sudo apt install libuhd-dev uhd-host gnuradio python3-uhd
sudo uhd_images_downloader # 下载 FPGA 固件
uhd_find_devices # 确认 B210 连接
uhd_usrp_probe # 查看能力
方案 B:进阶(机器学习识别)
USRP → 树莓派(I/Q采集 + 短时FFT)→ 频谱图像 → CNN分类器
↓
[DJI / WiFi / FPV / 背景]
可用 TensorFlow Lite 在树莓派上跑一个轻量级 CNN(MobileNet 级),对 256×256 的频谱瀑布图做分类,准确率可达 90%+。
方案 C:用开源现成项目(最省事)
| 项目 | 说明 | 适合度 |
|---|---|---|
| DragonOS | 集成了大量 SDR+反无工具的 Linux 发行版 | ⭐⭐⭐⭐⭐ |
| DroneID(开源版) | 专门解码 DJI DroneID 广播 | ⭐⭐⭐⭐⭐ |
| Sentinel(开源项目) | 基于 SDR 的无人机检测框架 | ⭐⭐⭐⭐ |
| gr-drone | GNU Radio 的无人机信号库 | ⭐⭐⭐⭐ |
| CRC/URH (Universal Radio Hacker) | 信号分析与逆向 | ⭐⭐⭐ |
1)Pi 4B 8G 的能力边界(先做个体检)
USB 3.0 实测性能
Pi 4B 的 USB 3.0 控制器(VL805)是它的最大瓶颈:
| 指标 | Pi 4B 实测 | Pi 5 对比 | B210 满载需求 |
|---|---|---|---|
| USB 3.0 单口吞吐 | ~350 MB/s | ~800 MB/s | 170 MB/s @ 56M带宽 |
| 内存带宽 | ~4 GB/s | ~8 GB/s | - |
| CPU(4×A72 @1.5GHz) | ~15 GFLOPS | ~25 GFLOPS | - |
结论 :Pi 4B 能扛住 B210 的数据流,但留给实时处理的余量不多。
关键限制
- 采样带宽建议控制在 20 MHz 以内(最多尝试 30 MHz),56 MHz 满血会丢包
- 不要同时开 MIMO 双通道 + 高带宽,二选一
- USB 3.0 口要直连,不要走 USB Hub,不要用延长线(或只用高质量短线)
- 供电必须稳:官方 5V/3A Type-C 电源是底线,USRP + Pi 峰值能吃到 15W
3)硬件清单(针对 Pi 4B 优化)
| 组件 | 型号建议 | 重要性 |
|---|---|---|
| 树莓派 4B 8G | 已定 | - |
| 散热 | 必须主动散热(Argon ONE M.2 外壳 或 官方主动散热器) | ⭐⭐⭐⭐⭐ |
| 电源 | 官方 5V/3A USB-C,不要用手机充电器 | ⭐⭐⭐⭐⭐ |
| 存储 | SSD via USB 3.0(不用 SD 卡) | ⭐⭐⭐⭐⭐ |
| USRP 供电 | B210 另配 6V 外接电源(USB 供电不够) | ⭐⭐⭐⭐⭐ |
| USB 线 | 原厂短线(≤30cm),带屏蔽 | ⭐⭐⭐⭐ |
| 天线 | 2.4G+5.8G 双频 LPDA 定向天线 ×2 | ⭐⭐⭐⭐ |
一、硬件连接准备
1.1 必备硬件清单
| 设备 | 要求 | 注意事项 |
|---|---|---|
| 树莓派 4B | 8GB 版本强烈推荐 | 4GB 也能跑,但编译时会慢 |
| SD 卡 | 64GB Class 10 以上 | UHD 源码+编译占 5GB+ |
| 电源 | 官方 5V/3A USB-C | 劣质电源会让 B210 断流 |
| USB 线 | USB 3.0 蓝色线,≤30cm | 关键!差线材直接导致 overflow |
| B210 天线 | 2.4GHz 天线(SMA 公头) | 接 RX2 口 |
| 散热 | 主动散热(风扇) | 满负荷跑会降频 |
1.2 连接方式
┌─────────────┐ USB 3.0 短线 ┌─────────────┐
│ Pi 4B │ ═══════════════════════ │ B210 │
│ (蓝色 USB3)│ │ (USB3 口) │
└──────┬──────┘ └──────┬──────┘
│ │
│ 5V/3A │ 天线接 RX2
│ │ (不要接 TX/RX)
▼ ▼
[ 官方电源 ] [ 2.4GHz 天线 ]
⚠️ 关键提示:
- 必须插 Pi 4B 的蓝色 USB3.0 口(不是黑色 2.0!)
- 不要用 USB 延长线或 Hub
- B210 需要 USB 3.0 的供电+带宽,USB 2.0 会直接报错
二、系统基础配置
2.1 烧录系统
推荐用 Raspberry Pi OS 64-bit (Bookworm):
# 在电脑上用 Raspberry Pi Imager 烧录
# 选择: Raspberry Pi OS (64-bit) - Bookworm
# 烧录时记得设置好 WiFi/SSH/用户名密码
2.2 首次开机后的系统优化
# SSH 进入树莓派
ssh pi@<树莓派IP>
# 更新系统(第一步必做)
sudo apt update
sudo apt full-upgrade -y
# 扩大 swap(编译 UHD 需要,否则 4GB 内存会 OOM)
sudo dphys-swapfile swapoff
sudo nano /etc/dphys-swapfile
# 修改: CONF_SWAPSIZE=2048
sudo dphys-swapfile setup
sudo dphys-swapfile swapon
# 验证 swap
free -h
# 应该看到 Swap: 2.0Gi
2.3 USB 缓冲区调优(B210 稳定运行关键)
# 创建配置文件
sudo tee /etc/sysctl.d/99-usrp.conf > /dev/null <<EOF
# USRP B210 性能优化
net.core.rmem_max=33554432
net.core.wmem_max=33554432
net.core.rmem_default=33554432
net.core.wmem_default=33554432
net.core.netdev_max_backlog=5000
EOF
sudo sysctl -p /etc/sysctl.d/99-usrp.conf
# USB 缓冲区大小(B210 关键参数)
echo 1000 | sudo tee /sys/module/usbcore/parameters/usbfs_memory_mb
# 永久生效
sudo tee -a /etc/rc.local > /dev/null <<EOF
echo 1000 > /sys/module/usbcore/parameters/usbfs_memory_mb
EOF
2.4 CPU 性能模式(防止自动降频)
# 安装 cpufrequtils
sudo apt install -y cpufrequtils
# 设为 performance 模式
sudo tee /etc/default/cpufrequtils > /dev/null <<EOF
GOVERNOR="performance"
EOF
sudo systemctl restart cpufrequtils
# 验证
cpufreq-info | grep "current CPU frequency"
# 应该显示 1.5GHz(Pi 4B 默认最高频率)
三、安装 UHD 驱动(B210 的核心)
两种方式:APT 安装(快但版本旧)vs 源码编译(慢但最新稳定)。
方式 A:APT 安装(先试这个,10 分钟搞定)
sudo apt install -y libuhd-dev libuhd4.1.0 uhd-host python3-uhd
# 下载 FPGA 固件
sudo /usr/lib/uhd/utils/uhd_images_downloader.py
# 设置 udev 规则(允许非 root 访问 B210)
sudo cp /usr/lib/uhd/utils/uhd-usrp.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
# 把自己加到 usrp 用户组
sudo groupadd -f usrp
sudo usermod -aG usrp $USER
# 重新登录使组权限生效
exit
# 重新 SSH 登录
测试是否识别 B210:
# 插好 B210 后
uhd_find_devices
# 成功的输出类似:
# --------------------------------------------------
# -- UHD Device 0
# --------------------------------------------------
# Device Address:
# serial: 3xxxxxx
# name: MyB210
# product: B210
# type: b200
如果上面能识别出 B210,可以跳过方式 B,直接进入第四章。
方式 B:源码编译(APT 版本有问题时用)
bash
# 安装依赖
sudo apt install -y autoconf automake build-essential ccache cmake \
cpufrequtils doxygen ethtool fort77 g++ gir1.2-gtk-3.0 git \
gobject-introspection gpsd gpsd-clients inetutils-tools libasound2-dev \
libboost-all-dev libcomedi-dev libcppunit-dev libfftw3-bin libfftw3-dev \
libfftw3-doc libfontconfig1-dev libgmp-dev libgps-dev libgsl-dev \
liblog4cpp5-dev libncurses5 libncurses5-dev libpulse-dev libqt5opengl5-dev \
libqwt-qt5-dev libsdl1.2-dev libtool libudev-dev libusb-1.0-0 \
libusb-1.0-0-dev libusb-dev libxi-dev libxrender-dev libzmq3-dev \
libzmq5 ncurses-bin python3-cheetah python3-click python3-click-plugins \
python3-dev python3-docutils python3-gi python3-gi-cairo python3-gps \
python3-lxml python3-mako python3-numpy python3-opengl python3-pyqt5 \
python3-requests python3-scipy python3-setuptools python3-six python3-yaml \
python3-zmq python3-pip swig wget
# 下载 UHD 源码(选稳定版本)
cd ~
git clone https://github.com/EttusResearch/uhd.git
cd uhd
git checkout v4.6.0.0 # 稳定版
# 编译(⏰ 这一步要 1-2 小时,耐心等)
cd host
mkdir build && cd build
cmake -DCMAKE_INSTALL_PREFIX=/usr/local \
-DENABLE_TESTS=OFF \
-DENABLE_C_API=ON \
-DENABLE_PYTHON_API=ON \
-DENABLE_MANUAL=OFF \
-DENABLE_DOXYGEN=OFF \
../
# 用 3 个核心编译(留 1 个核心给系统,防止卡死)
make -j3
# 安装
sudo make install
sudo ldconfig
# 下载固件
sudo /usr/local/lib/uhd/utils/uhd_images_downloader.py
# udev 规则
sudo cp /usr/local/lib/uhd/utils/uhd-usrp.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
四、验证 B210 工作正常
4.1 基础识别测试
uhd_find_devices
期望输出:
-- UHD Device 0
-- Device Address:
-- serial: 3xxxxxxx
-- name:
-- product: B210
-- type: b200
4.2 探测设备详细信息
uhd_usrp_probe
这会首次下载 FPGA 镜像到 B210(需要 30 秒,别中断)。成功后会打印完整的设备信息树。
常见错误:
RuntimeError: Error in open_device: insufficient permissions
→ 说明 udev 规则没生效,重新执行:
sudo cp /usr/lib/uhd/utils/uhd-usrp.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
然后拔插一次 B210。
4.3 性能基准测试(最关键)
# 测试 Pi 4B 能稳定跑多高采样率
uhd_rx_samples_to_file \
--freq 2.437e9 \
--rate 20e6 \
--gain 50 \
--duration 5 \
--file /tmp/test.dat \
--type short
# 如果出现大量 "O"(Overflow),说明采样率太高
# Pi 4B + B210 经验值:
# - USB 3.0 连接 + 好线材:20-25 MHz 稳定
# - USB 2.0 或劣质线:只能 5-8 MHz
期望看到(无警告输出):
Press Ctrl + C to stop streaming...
[INFO] [UHD] ...
Done!
如果一直打印 O 字符:
# 降采样率到 10 MHz 再试
uhd_rx_samples_to_file --freq 2.437e9 --rate 10e6 --gain 50 \
--duration 5 --file /tmp/test.dat --type short
五、安装 Python 依赖
# 科学计算库
sudo apt install -y python3-numpy python3-scipy python3-matplotlib
# 如果 UHD 用 APT 装的,Python 绑定已有
python3 -c "import uhd; print(uhd.__version__)"
# 应该打印版本号
# 如果 import 失败,手动装
pip3 install --break-system-packages uhd
测试 Python 能否控制 B210:
查看全部
# test_b210.py
import uhd
usrp = uhd.usrp.MultiUSRP()
print("设备信息:", usrp.get_pp_string())
print("支持的采样率范围:")
print(f" 最大: {usrp.get_rx_rates().stop()/1e6} MHz")
print("✓ Python UHD 正常")
运行
python3 test_b210.py
六、创建 Spark 检测器工作目录
bash
# 创建项目目录
mkdir -p ~/spark_detector
cd ~/spark_detector
# 创建文件结构
mkdir logs captures
touch detector.py config.py
6.1 配置文件 config.py
python
# config.py
"""DJI Spark 检测器配置"""
# ==================== B210 配置 ====================
# 采样率:Pi 4B 稳定值
# 20e6 = USB3 + 好线材
# 10e6 = 保守稳定值
# 5e6 = USB2 兜底
SAMPLE_RATE = 10e6
# 中心频率:Spark 默认用 WiFi 信道 1/6/11
# 信道 1 = 2.412 GHz, 信道 6 = 2.437 GHz, 信道 11 = 2.462 GHz
# 20e6 采样率能覆盖 ±10MHz,刚好覆盖一个信道
CENTER_FREQ = 2.437e9 # 信道 6
# 增益:0-76 dB
# 室内/近距离测试:40-50
# 户外远距离:65-76
GAIN = 60
# 天线端口
ANTENNA = "RX2"
# ==================== 检测参数 ====================
FFT_SIZE = 2048 # 频谱分辨率
DETECTION_THRESHOLD_DB = 15 # SNR 阈值(高于噪底 15dB 才认定有信号)
WIFI_BW_MIN = 15e6 # WiFi 信号最小带宽
WIFI_BW_MAX = 22e6 # WiFi 信号最大带宽
CONFIRM_COUNT = 3 # 连续多少次检测才告警(抗抖动)
ALERT_COOLDOWN = 5.0 # 告警冷却时间(秒)
# ==================== 信道跳转 ====================
# 扫描多个 WiFi 信道
ENABLE_CHANNEL_HOPPING = True
CHANNELS = {
1: 2.412e9,
6: 2.437e9,
11: 2.462e9,
}
HOP_INTERVAL = 2.0 # 每信道停留秒数
# ==================== 日志 ====================
LOG_DIR = "logs"
CAPTURE_DIR = "captures"
SAVE_IQ_ON_DETECT = False # 检测到后是否保存 I/Q 数据(调试用)
运行
6.2 主检测器 detector.py
python
查看全部
if is_wifi:
self.detection_count += 1
if self.detection_count >= cfg.CONFIRM_COUNT:
self.alert(peak, snr, bw, off)
self.detection_count = 0
else:
self.detection_count = max(0, self.detection_count - 1)
# 实时状态(打点)
if int(time.time()) % 5 == 0:
sys.stdout.write(".")
sys.stdout.flush()
except KeyboardInterrupt:
print("\n[*] 收到停止信号")
finally:
self.stop_stream()
self.log_fp.close()
print("[*] 已清理退出")
if __name__ == "__main__":
detector = SparkDetector()
detector.run()
运行
6.3 运行
bash
cd ~/spark_detector
python3 detector.py
期望输出:
scheme
[*] 初始化 B210...
[*] 日志文件: logs/spark_20260421_153022.log
[*] 流已启动
[*] 开始扫描... 按 Ctrl+C 停止
[*] 采样率: 10.0 MHz
[*] 增益: 60 dB
[*] 信道跳转: 开启
...........
[!] 2026-04-21 15:31:05 疑似 DJI Spark / WiFi 类无人机信号
信道: 6 (2.437 GHz)
峰值频率: 2.4372 GHz (偏移 +0.20 MHz)
...
七、系统调优(让它跑得更稳)
7.1 实时发现 Overflow 就降采样率
如果运行中一直打印 OOOOO:
python
# 修改 config.py
SAMPLE_RATE = 5e6 # 从 10 MHz 降到 5 MHz
运行
7.2 Pi 4B 性能监控
开第二个 SSH 终端:
bash
# 监控 CPU/温度/USB
watch -n 1 'echo "CPU:"; vcgencmd measure_clock arm; \
echo "温度:"; vcgencmd measure_temp; \
echo "负载:"; uptime; \
echo "USB:"; lsusb | grep Ettus'
警示信号:
- 温度 >75°C → 加风扇
- CPU 负载 >3.5(4 核)→ 降采样率或 FFT 大小
- USB 设备消失 → 供电/线材问题
7.3 自启动服务
bash
# 创建 systemd 服务
sudo tee /etc/systemd/system/spark-detector.service > /dev/null <<EOF
[Unit]
Description=DJI Spark Detector
After=network.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/spark_detector
ExecStart=/usr/bin/python3 /home/pi/spark_detector/detector.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable spark-detector
sudo systemctl start spark-detector
# 查看日志
journalctl -u spark-detector -f
八、常见问题排错清单
| 现象 | 原因 | 解决 |
|---|---|---|
uhd_find_devices 找不到设备 |
1. USB 口不对 2. 权限 3. 供电不足 | 换蓝色 USB3 口;加 udev 规则;换 3A 电源 |
insufficient permissions |
udev 规则没加 | 见 3.1 节最后一步 |
一直打印 O (overflow) |
采样率过高 / 线材差 / Pi 过热 | 降到 5 MHz;换短 USB3 线;加风扇 |
LIBUSB_ERROR_IO |
USB 供电抖动 | 换官方电源;USB 线 ≤30cm |
Python import uhd 失败 |
Python 绑定没装 | sudo apt install python3-uhd |
| 频谱全是噪声 | 天线没接 / 接 TX/RX 口了 | 天线接 RX2 口 |
| FPGA 镜像下载失败 | 网络问题 | sudo uhd_images_downloader -m b2xx |
| 树莓派卡死 | swap 不够 / 温度过高 | 扩 swap 到 2GB;加散热 |
| Spark 开机但检测不到 | 信道错 / 增益低 / 距离太远 | 打开信道跳转;增益调到 70;靠近测试 |
九、验证整套系统的完整测试流程
9.1 单元测试
bash
# 1. 硬件识别
uhd_find_devices
# ✓ 应看到 B210
# 2. FPGA 加载
uhd_usrp_probe 2>&1 | head -20
# ✓ 应看到详细设备树
# 3. 数据流
uhd_rx_samples_to_file --freq 2.437e9 --rate 10e6 --gain 50 \
--duration 3 --file /tmp/t.dat
ls -la /tmp/t.dat
# ✓ 文件大小应约 240 MB(10e6 × 3s × 8字节)
# 4. Python 绑定
python3 -c "import uhd; u=uhd.usrp.MultiUSRP(); print(u.get_pp_string())"
# ✓ 打印设备信息
9.2 Spark 实战测试
bash
# 1. 启动检测器
python3 ~/spark_detector/detector.py
# 2. 另找一台设备,打开 Spark(或朋友家借一台)
# - 开机前:屏幕应打印 "......"(只有噪声)
# - 开机后:5-10 秒内应该看到告警
# 3. 验证检测距离
# 室内:5-10 米应稳定触发
# 户外:用板载鞭状天线 100-200 米
# 户外+定向板状天线:500m-1km
十、下一步扩展(你现在能做的)
搞定上面后,你有几个方向深化:
① 融合 WiFi 网卡做二次确认(推荐立刻做)
- B210 发现宽带信号 → 触发 WiFi 网卡 monitor → 抓 Spark SSID → 确认
- 两种方式结果互补,误报率降到 <1%
② Web 可视化
- Flask + WebSocket 实时推送
- 浏览器看瀑布图 + 告警列表
③ RSSI → 距离的校准
- 实测 5m / 20m / 50m / 100m 的 RSSI
- 拟合 log-distance 模型,距离估算误差能降到 ±20%
④ 两台 Pi+B210 做 TDOA 定位
- 这个就有意思了,能定位 Spark 的位置坐标
测试完后工程代码如下:前端dashboard.html,后端mini2_detect_server.py,( HTML + CDN )
dashboard.html
python
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Mini 2 Detector · Live</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: 'SF Mono', Menlo, Consolas, monospace; }
.glow-g { text-shadow: 0 0 8px #10b981, 0 0 16px #10b98177; }
.glow-r { text-shadow: 0 0 10px #ef4444, 0 0 24px #ef4444cc, 0 0 40px #ef4444aa; }
.glow-a { text-shadow: 0 0 8px #f59e0b, 0 0 16px #f59e0b88; }
.grid-bg { background-image:
linear-gradient(rgba(16,185,129,.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(16,185,129,.07) 1px, transparent 1px);
background-size: 24px 24px; }
@keyframes sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
.scan-sweep { background: linear-gradient(90deg, transparent, #10b98144, transparent); animation: sweep 1.5s infinite; }
@keyframes siren { 0%,100% { background-color: rgba(239,68,68,.15); } 50% { background-color: rgba(239,68,68,.45); } }
.siren-bg { animation: siren 0.6s infinite; }
@keyframes flash { 0% { opacity: 0.8; } 100% { opacity: 0; } }
.flash-layer { animation: flash 0.4s ease-out; }
@keyframes shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
.shake { animation: shake 0.15s 4; }
@keyframes beaconRing { 0% { transform: scale(1); opacity: 1; } 100% { transform: scale(2.5); opacity: 0; } }
.beacon-ring { animation: beaconRing 1.2s infinite; }
canvas { display: block; }
</style>
</head>
<body class="bg-slate-950 text-slate-200 min-h-screen grid-bg">
<div id="flashOverlay" class="fixed inset-0 bg-red-500 pointer-events-none z-50" style="opacity:0;"></div>
<div class="max-w-7xl mx-auto p-4 space-y-4">
<div id="alertBanner"
class="rounded-lg border-2 border-emerald-500/30 p-4 transition-all duration-300"
style="background: rgba(15,23,42,0.8);">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="relative">
<div id="beacon" class="w-6 h-6 rounded-full bg-emerald-500"></div>
<div id="beaconRing" class="absolute inset-0 rounded-full bg-emerald-500/60"></div>
</div>
<div>
<div id="alertTitle" class="text-3xl md:text-4xl font-black tracking-tight glow-g text-emerald-400">
AREA CLEAR
</div>
<div id="alertSubtitle" class="text-sm text-slate-400 mt-1">
No OcuSync activity detected
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="text-right">
<div class="text-[10px] text-slate-500 uppercase">Last Hit</div>
<div id="lastHitAgo" class="text-lg font-bold text-slate-400 font-mono">--</div>
</div>
<button id="soundToggle"
class="px-3 py-2 rounded border border-slate-700 bg-slate-800 hover:bg-slate-700 text-xs">
🔇 SOUND: OFF
</button>
</div>
</div>
</div>
<section class="grid grid-cols-2 md:grid-cols-5 gap-3">
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-3">
<div class="text-[10px] uppercase tracking-wider text-slate-500">Scans</div>
<div id="scanCount" class="text-2xl font-bold">0</div>
</div>
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-3">
<div class="text-[10px] uppercase tracking-wider text-slate-500">Detections</div>
<div id="detectionCount" class="text-2xl font-bold text-amber-400">0</div>
</div>
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-3">
<div class="text-[10px] uppercase tracking-wider text-slate-500">Dual-Band</div>
<div id="dualBandFlag" class="text-2xl font-bold text-slate-500">NO</div>
</div>
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-3">
<div class="text-[10px] uppercase tracking-wider text-slate-500">Hopping</div>
<div id="hoppingFlag" class="text-2xl font-bold text-slate-500">NO</div>
</div>
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-3">
<div class="text-[10px] uppercase tracking-wider text-slate-500">Scanning</div>
<div id="currentBand" class="text-lg font-bold text-emerald-300 font-mono truncate">---</div>
</div>
</section>
<section class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-semibold text-emerald-400">▸ LIVE BAND STATUS <span class="text-[10px] text-slate-500">(red glow = OCU hit, fades over 5s)</span></div>
</div>
<div id="bandGrid" class="grid grid-cols-3 md:grid-cols-9 gap-2"></div>
</section>
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<div class="text-sm font-semibold text-emerald-400 mb-1">▸ HOP TIMELINE (last 30s)</div>
<div class="text-[10px] text-slate-500 mb-2">
<span class="text-blue-400">●</span> 2.4G
<span class="text-purple-400">●</span> 5.8G
Each dot = OCU hit.
</div>
<canvas id="hopCanvas" width="600" height="200" class="w-full bg-slate-950/60 rounded"></canvas>
</div>
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<div class="text-sm font-semibold text-emerald-400 mb-3">▸ SNR + PEAK-HOLD (dB)</div>
<div id="snrBars" class="space-y-1.5"></div>
</div>
</section>
<section class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4 lg:col-span-2">
<div class="text-sm font-semibold text-emerald-400 mb-1">▸ WATERFALL · OCU HITS</div>
<div class="text-[10px] text-slate-500 mb-2">Red = OCUSYNC, Blue = WiFi, Amber = narrow, Gray = noise</div>
<canvas id="waterfallCanvas" width="800" height="260" class="w-full"></canvas>
</div>
<div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4">
<div class="text-sm font-semibold text-amber-400 mb-3">▸ DETECTION LOG</div>
<div id="detectionLog" class="space-y-2 max-h-64 overflow-y-auto text-xs font-mono"></div>
</div>
</section>
<footer class="text-center text-xs text-slate-600 pt-4">
USRP B210 · OcuSync 2.0 · Poll 500ms · <code>localhost:8765</code>
</footer>
</div>
<script>
// ========== Config ==========
const API_URL = 'http://localhost:8765/api/status';
const POLL_MS = 500;
const STICKY_WINDOW = 5.0;
const PEAK_HOLD_MS = 3000;
// ========== State ==========
let peakHold = {};
let lastDetectionTs = 0; // in SERVER time
let clockOffset = 0; // serverNow - clientNow (seconds)
let prevDetectionCount = 0;
let soundOn = false;
let audioCtx = null;
let apiConnected = false;
function serverNow() { return Date.now()/1000 + clockOffset; }
// ========== Audio ==========
function initAudio() {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
catch(e) {}
}
function beep(freq=1200, dur=0.15, vol=0.3) {
if (!soundOn || !audioCtx) return;
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.frequency.value = freq; o.type = 'square'; g.gain.value = vol;
o.connect(g); g.connect(audioCtx.destination);
o.start();
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
o.stop(audioCtx.currentTime + dur);
}
function alarmBurst() {
beep(1400,0.12,0.35);
setTimeout(()=>beep(900,0.12,0.35),140);
setTimeout(()=>beep(1400,0.12,0.35),280);
}
document.getElementById('soundToggle').addEventListener('click', e => {
soundOn = !soundOn;
if (soundOn && !audioCtx) initAudio();
e.target.textContent = soundOn ? '🔊 SOUND: ON' : '🔇 SOUND: OFF';
e.target.classList.toggle('bg-emerald-800', soundOn);
e.target.classList.toggle('border-emerald-500', soundOn);
if (soundOn) beep(880,0.1,0.2);
});
// ========== Helpers ==========
function fmtAgo(sec) {
if (!isFinite(sec) || sec < 0 || sec > 99999) return '--';
if (sec < 60) return sec.toFixed(1) + 's';
return Math.floor(sec/60) + 'm ' + Math.floor(sec%60) + 's';
}
function safeNum(v, def=0) { return (typeof v === 'number' && isFinite(v)) ? v : def; }
function snrColor(snr) {
if (snr < 5) return '#334155';
if (snr < 10) return '#3b82f6';
if (snr < 15) return '#06b6d4';
if (snr < 20) return '#10b981';
if (snr < 25) return '#f59e0b';
return '#ef4444';
}
function triggerFlash() {
const f = document.getElementById('flashOverlay');
f.style.opacity = 0.8;
f.classList.remove('flash-layer'); void f.offsetWidth; f.classList.add('flash-layer');
setTimeout(() => { f.style.opacity = 0; }, 400);
}
function triggerShake() {
const b = document.getElementById('alertBanner');
b.classList.remove('shake'); void b.offsetWidth; b.classList.add('shake');
}
// ========== Banner (FIX: always reset inline background) ==========
function updateBanner(data, now) {
const banner = document.getElementById('alertBanner');
const title = document.getElementById('alertTitle');
const sub = document.getElementById('alertSubtitle');
const beacon = document.getElementById('beacon');
const ring = document.getElementById('beaconRing');
const lastHitEl = document.getElementById('lastHitAgo');
const sinceHit = data.last_detection_ts > 0 ? (now - data.last_detection_ts) : 9999;
lastHitEl.textContent = data.last_detection_ts > 0 ? fmtAgo(sinceHit) + ' ago' : 'never';
const active = sinceHit < 7;
const high = active && data.dual_band && data.hopping;
// Always clear inline style first so class-based backgrounds work
banner.style.background = '';
if (high) {
title.textContent = '⚠ DRONE DETECTED';
title.className = 'text-3xl md:text-4xl font-black tracking-tight glow-r text-red-400';
sub.innerHTML = '<span class="text-red-300 font-bold">HIGH CONFIDENCE</span> · OcuSync 2.0 · Dual-band + Hopping';
banner.className = 'rounded-lg border-2 border-red-500 p-4 transition-all duration-300 siren-bg';
beacon.className = 'w-6 h-6 rounded-full bg-red-500';
ring.className = 'absolute inset-0 rounded-full bg-red-500/60 beacon-ring';
lastHitEl.className = 'text-lg font-bold text-red-400 font-mono glow-r';
} else if (active) {
title.textContent = '⚡ SIGNAL DETECTED';
title.className = 'text-3xl md:text-4xl font-black tracking-tight glow-a text-amber-400';
sub.innerHTML = '<span class="text-amber-300 font-bold">OcuSync-like signature</span> · verifying pattern...';
banner.className = 'rounded-lg border-2 border-amber-500 p-4 transition-all duration-300';
banner.style.background = 'rgba(120,53,15,0.35)';
beacon.className = 'w-6 h-6 rounded-full bg-amber-500';
ring.className = 'absolute inset-0 rounded-full bg-amber-500/60 beacon-ring';
lastHitEl.className = 'text-lg font-bold text-amber-400 font-mono';
} else {
title.textContent = apiConnected ? 'AREA CLEAR' : 'API OFFLINE';
title.className = 'text-3xl md:text-4xl font-black tracking-tight ' +
(apiConnected ? 'glow-g text-emerald-400' : 'text-slate-500');
sub.textContent = apiConnected ? 'No OcuSync activity detected' : 'Waiting for backend...';
banner.className = 'rounded-lg border-2 border-emerald-500/30 p-4 transition-all duration-300';
banner.style.background = 'rgba(15,23,42,0.8)';
beacon.className = 'w-6 h-6 rounded-full ' + (apiConnected ? 'bg-emerald-500' : 'bg-slate-600');
ring.className = 'absolute inset-0 rounded-full bg-emerald-500/40';
lastHitEl.className = 'text-lg font-bold text-slate-400 font-mono';
}
}
// ========== Band Grid (FIX: safe access to last_snr) ==========
function renderBandGrid(scan, bandHits, currentBand, now) {
const grid = document.getElementById('bandGrid');
grid.innerHTML = '';
scan.forEach(b => {
const hit = bandHits[b.band] || {};
const since = hit.last_hit_ts ? (now - hit.last_hit_ts) : 9999;
const stickyActive = since >= 0 && since < STICKY_WINDOW;
const intensity = stickyActive ? Math.max(0, 1 - since / STICKY_WINDOW) : 0;
const isScanning = (b.band === currentBand);
let border = 'border-slate-700', bg = 'bg-slate-800/40', tcolor = 'text-slate-400', label = '···';
if (b.verdict === 'WIFI') { border='border-blue-700'; bg='bg-blue-900/30'; tcolor='text-blue-300'; label='WIFI'; }
if (b.verdict === 'NARROW') { border='border-amber-700'; bg='bg-amber-900/20'; tcolor='text-amber-300'; label='NAR'; }
if (stickyActive) { border='border-red-500'; tcolor='text-red-300'; label='OCU'; }
const el = document.createElement('div');
el.className = `relative overflow-hidden rounded-md border-2 ${border} ${stickyActive ? '' : bg} p-2 transition-all duration-200`;
if (stickyActive) {
el.style.background = `rgba(239,68,68,${0.15 + intensity * 0.45})`;
el.style.boxShadow = `0 0 ${8 + intensity*20}px rgba(239,68,68,${0.3 + intensity*0.5})`;
}
const topInfo = stickyActive
? `<span class="text-[9px] text-red-300">${fmtAgo(since)} ago</span>`
: `<span class="text-[10px] text-slate-500">${b.group || ''}</span>`;
const snrShown = stickyActive ? safeNum(hit.last_snr, 0) : safeNum(b.snr, 0);
const hitCount = safeNum(hit.hit_count, 0);
el.innerHTML = `
${isScanning ? '<div class="absolute inset-0 scan-sweep pointer-events-none"></div>' : ''}
<div class="flex justify-between items-start">
${topInfo}
<span class="text-[9px] ${tcolor} font-bold">${label}</span>
</div>
<div class="text-[10px] font-mono text-slate-300 mt-1">${b.band}</div>
<div class="flex justify-between text-[10px] mt-1">
<span class="text-slate-500">SNR</span>
<span class="font-bold ${stickyActive ? 'text-red-300' : tcolor}">${snrShown.toFixed(1)}</span>
</div>
<div class="flex justify-between text-[10px]">
<span class="text-slate-500">Hits</span>
<span class="${hitCount > 0 ? 'text-red-300 font-bold' : 'text-slate-500'}">${hitCount}</span>
</div>
`;
grid.appendChild(el);
});
}
// ========== SNR bars ==========
function renderSnrBars(scan, now) {
scan.forEach(b => {
const snr = safeNum(b.snr, 0);
const prev = peakHold[b.band];
if (!prev || snr >= prev.snr || (now - prev.ts) * 1000 > PEAK_HOLD_MS) {
peakHold[b.band] = { snr: snr, ts: now };
}
});
const root = document.getElementById('snrBars');
root.innerHTML = '';
const maxSnr = 40;
scan.forEach(b => {
const cur = safeNum(b.snr, 0);
const pk = safeNum(peakHold[b.band]?.snr, cur);
const curPct = Math.min(100, Math.max(0, (cur/maxSnr)*100));
const pkPct = Math.min(100, Math.max(0, (pk/maxSnr)*100));
const c = snrColor(cur);
const ocu = !!b.is_ocusync;
const row = document.createElement('div');
row.innerHTML = `
<div class="flex justify-between text-[10px] mb-0.5">
<span class="font-mono ${ocu ? 'text-red-300 font-bold' : 'text-slate-400'}">${b.band}
<span class="text-slate-600">(${b.group || ''})</span>
${ocu ? '<span class="text-red-400 ml-1">● OCU</span>' : ''}
</span>
<span class="font-mono" style="color:${c}">${cur.toFixed(1)} dB
<span class="text-slate-500"> pk ${pk.toFixed(1)}</span>
</span>
</div>
<div class="h-3 bg-slate-800 rounded overflow-hidden relative">
<div class="h-full" style="width:${curPct}%; background:${c}; box-shadow:0 0 6px ${c}"></div>
<div class="absolute top-0 h-full" style="left:${pkPct}%; width:2px; background:#fbbf24; box-shadow:0 0 4px #fbbf24;"></div>
</div>
`;
root.appendChild(row);
});
}
// ========== Hop timeline ==========
function renderHopTimeline(hopTimeline, bandOrder, now) {
const cv = document.getElementById('hopCanvas');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
ctx.fillStyle = '#020617'; ctx.fillRect(0,0,W,H);
const TIME_WINDOW = 30;
const padL = 70, padR = 8, padT = 8, padB = 20;
const chartW = W - padL - padR, chartH = H - padT - padB;
const denom = Math.max(1, bandOrder.length - 1);
ctx.strokeStyle = 'rgba(16,185,129,0.08)'; ctx.lineWidth = 1;
bandOrder.forEach((b, i) => {
const y = padT + (i / denom) * chartH;
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(W-padR, y); ctx.stroke();
ctx.fillStyle = b.startsWith('2') ? '#60a5fa' : '#c084fc';
ctx.font = '9px monospace';
ctx.fillText(b, 4, y+3);
});
ctx.fillStyle = '#475569'; ctx.font='9px monospace';
for (let s=0; s<=TIME_WINDOW; s+=5) {
const x = padL + ((TIME_WINDOW - s) / TIME_WINDOW) * chartW;
ctx.strokeStyle = 'rgba(100,116,139,0.15)';
ctx.beginPath(); ctx.moveTo(x, padT); ctx.lineTo(x, H-padB); ctx.stroke();
ctx.fillText(`-${s}s`, x-10, H-6);
}
const bandIdx = {};
bandOrder.forEach((b, i) => bandIdx[b] = i);
(hopTimeline || []).forEach(hit => {
const dt = now - hit.ts;
if (dt > TIME_WINDOW || dt < 0) return;
const i = bandIdx[hit.band]; if (i === undefined) return;
const x = padL + ((TIME_WINDOW - dt) / TIME_WINDOW) * chartW;
const y = padT + (i / denom) * chartH;
const color = hit.group === '2.4G' ? '#3b82f6' : '#a855f7';
const r = Math.max(3, Math.min(9, safeNum(hit.snr,10) / 3));
const grd = ctx.createRadialGradient(x, y, 0, x, y, r*2);
grd.addColorStop(0, color); grd.addColorStop(1, 'transparent');
ctx.fillStyle = grd;
ctx.beginPath(); ctx.arc(x, y, r*2, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); ctx.fill();
});
const recent = (hopTimeline || []).filter(h => (now - h.ts) <= TIME_WINDOW && (now - h.ts) >= 0).slice(-15);
if (recent.length > 1) {
ctx.strokeStyle = 'rgba(239,68,68,0.5)';
ctx.lineWidth = 1.5;
ctx.beginPath();
let started = false;
recent.forEach((hit) => {
const i = bandIdx[hit.band]; if (i === undefined) return;
const x = padL + ((TIME_WINDOW - (now - hit.ts)) / TIME_WINDOW) * chartW;
const y = padT + (i / denom) * chartH;
if (!started) { ctx.moveTo(x,y); started = true; } else { ctx.lineTo(x,y); }
});
if (started) ctx.stroke();
}
ctx.strokeStyle = '#10b981'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(W-padR, padT); ctx.lineTo(W-padR, H-padB); ctx.stroke();
ctx.fillStyle = '#10b981'; ctx.fillText('NOW', W-padR-24, padT+10);
}
// ========== Waterfall ==========
function renderWaterfall(history, bandOrder) {
const cv = document.getElementById('waterfallCanvas');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
ctx.fillStyle = '#020617'; ctx.fillRect(0,0,W,H);
if (!history || !history.length || !bandOrder.length) return;
const cellH = H / bandOrder.length;
const cellW = W / Math.max(history.length, 80);
history.forEach((row, ti) => {
const x = ti * cellW;
bandOrder.forEach((bname, bi) => {
const y = bi * cellH;
const b = (row.results || []).find(r => r.band === bname);
if (!b) return;
let color;
if (b.verdict === 'OCUSYNC') color = '#ef4444';
else if (b.verdict === 'WIFI') color = '#3b82f6';
else if (b.verdict === 'NARROW') color = '#f59e0b';
else color = `rgba(51,65,85,${Math.min(1, 0.3 + safeNum(b.snr,0)/30)})`;
ctx.fillStyle = color;
ctx.globalAlpha = b.verdict === 'OCUSYNC' ? 1 : (b.verdict === 'NOISE' ? 0.4 : 0.75);
ctx.fillRect(x, y, cellW+0.5, cellH-0.5);
ctx.globalAlpha = 1;
if (b.verdict === 'OCUSYNC') {
ctx.shadowColor = '#ef4444'; ctx.shadowBlur = 8;
ctx.fillRect(x, y, cellW+0.5, cellH-0.5);
ctx.shadowBlur = 0;
}
});
});
ctx.fillStyle = '#64748b'; ctx.font='9px monospace';
bandOrder.forEach((bname, bi) => {
ctx.fillText(bname, 4, bi*cellH + cellH/2 + 3);
});
}
// ========== Detection log ==========
function renderDetectionLog(list) {
const root = document.getElementById('detectionLog');
root.innerHTML = '';
if (!list || !list.length) {
root.innerHTML = '<div class="text-slate-600 text-center py-6">No detections yet</div>';
return;
}
list.forEach(d => {
const el = document.createElement('div');
const cls = d.high_confidence ? 'border-red-500 bg-red-950/40' : 'border-amber-600 bg-amber-950/20';
el.className = `border-l-2 ${cls} pl-2 py-1`;
el.innerHTML = `
<div class="flex justify-between">
<span class="text-amber-300 font-bold">#${d.id}</span>
<span class="text-slate-500">${d.time}</span>
</div>
<div class="text-slate-300 mt-0.5 truncate">Bands: <span class="text-red-400">${(d.bands||[]).join(', ')}</span></div>
<div class="flex gap-2 text-[10px] mt-0.5">
${d.dual_band ? '<span class="text-red-400">● DUAL</span>' : '<span class="text-slate-600">○ dual</span>'}
${d.hopping ? '<span class="text-red-400">● HOP</span>' : '<span class="text-slate-600">○ hop</span>'}
${d.high_confidence ? '<span class="text-red-400 font-bold">⚠ HIGH</span>' : ''}
</div>
`;
root.appendChild(el);
});
}
// ========== Poll ==========
async function poll() {
try {
const r = await fetch(API_URL, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
apiConnected = true;
// Compute clock offset (server - client)
clockOffset = (data.now || 0) - (Date.now()/1000);
const now = data.now;
if (data.detection_count > prevDetectionCount && prevDetectionCount > 0) {
triggerFlash(); triggerShake(); alarmBurst();
}
prevDetectionCount = data.detection_count;
lastDetectionTs = data.last_detection_ts || 0;
document.getElementById('scanCount').textContent = data.scan_count;
document.getElementById('detectionCount').textContent = data.detection_count;
document.getElementById('currentBand').textContent = data.current_band || '---';
const db = document.getElementById('dualBandFlag');
db.textContent = data.dual_band ? 'YES' : 'NO';
db.className = 'text-2xl font-bold ' + (data.dual_band ? 'text-red-400 glow-r' : 'text-slate-500');
const hop = document.getElementById('hoppingFlag');
hop.textContent = data.hopping ? 'YES' : 'NO';
hop.className = 'text-2xl font-bold ' + (data.hopping ? 'text-red-400 glow-r' : 'text-slate-500');
updateBanner(data, now);
const bandOrder = (data.config && data.config.bands) || [];
if (data.last_scan && data.last_scan.length) {
renderBandGrid(data.last_scan, data.band_hits || {}, data.current_band, now);
renderSnrBars(data.last_scan, now);
}
renderHopTimeline(data.hop_timeline || [], bandOrder, now);
renderWaterfall(data.history || [], bandOrder);
renderDetectionLog(data.detections || []);
} catch (e) {
apiConnected = false;
document.getElementById('alertSubtitle').textContent = 'API disconnected: ' + e.message;
}
}
// Smooth decay animation (FIX: use serverNow() for consistent time base)
function tick() {
if (lastDetectionTs > 0) {
const since = serverNow() - lastDetectionTs;
const el = document.getElementById('lastHitAgo');
if (el && apiConnected) el.textContent = fmtAgo(since) + ' ago';
}
requestAnimationFrame(tick);
}
tick();
poll();
setInterval(poll, POLL_MS);
</script>
</body>
</html>
mini2_detect_server.py
python
# mini2_server.py - subprocess-isolated scanner
import numpy as np
import time
import json
import threading
import traceback
import multiprocessing as mp
import queue as _queue
import os
import signal
from datetime import datetime
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# ========== Config ==========
GAIN = 70
SAMP_RATE = 25e6
FFT_SIZE = 2048
NUM_AVG = 16
SAMPLES_PER_BAND = 65536
HISTORY_LEN = 5
SCORE_THRESHOLD = 5
HTTP_PORT = 8765
WATERFALL_ROWS = 120
STICKY_WINDOW_SEC = 6
MAX_DETECTIONS_LOG = 40
HOP_TIMELINE_MAXLEN = 400
scan_freqs = {
"2.400-2.425": 2.4125e9, "2.425-2.450": 2.4375e9,
"2.450-2.475": 2.4625e9, "2.475-2.500": 2.4875e9,
"5.725-5.750": 5.7375e9, "5.750-5.775": 5.7625e9,
"5.775-5.800": 5.7875e9, "5.800-5.825": 5.8125e9,
"5.825-5.850": 5.8375e9,
}
BAND_ORDER = list(scan_freqs.keys())
def band_group(b): return "2.4G" if b.startswith("2.") else "5.8G"
# ============================================================
# ========== CHILD PROCESS: USRP scanner worker ==============
# ============================================================
def scanner_worker(out_q):
"""
Runs in subprocess. Imports UHD here so the parent never touches it.
On any fatal error, simply exits --- parent will respawn.
"""
try:
import uhd
except Exception as e:
out_q.put({"type": "fatal", "msg": f"import uhd failed: {e}"})
return
# ----- feature extraction (unchanged from your original) -----
def analyze_band(samples):
if samples is None: return None
psd_avg = np.zeros(FFT_SIZE); n_used = 0
for i in range(NUM_AVG):
chunk = samples[i*FFT_SIZE:(i+1)*FFT_SIZE]
if len(chunk) < FFT_SIZE: break
fft = np.fft.fftshift(np.fft.fft(chunk * np.hanning(FFT_SIZE)))
psd_avg += np.abs(fft) ** 2; n_used += 1
if n_used == 0: return None
psd_avg /= n_used
psd_db = 10 * np.log10(psd_avg / FFT_SIZE + 1e-20)
noise = float(np.median(psd_db)); peak = float(np.max(psd_db))
snr = peak - noise
bin_width_hz = SAMP_RATE / FFT_SIZE
active_mask = psd_db > (noise + 6)
active_bw = float(np.sum(active_mask) * bin_width_hz / 1e6)
half_thr = noise + (peak - noise) * 0.5
main_mask = psd_db > half_thr
main_bw = float(np.sum(main_mask) * bin_width_hz / 1e6)
if np.sum(active_mask) > 10:
active_lin = 10 ** (psd_db[active_mask] / 10)
gm = np.exp(np.mean(np.log(active_lin + 1e-20)))
am = np.mean(active_lin); flatness = float(gm / am)
else:
flatness = 0.0
power_t = np.abs(samples) ** 2
thr = np.median(power_t) * 4
burst_ratio = float(np.mean(power_t > thr))
edge_sharp = 99.0
if main_bw > 2:
idx = np.where(main_mask)[0]; left = idx[0]; l_end = left
while l_end > 0 and psd_db[l_end] > noise + 3:
l_end -= 1
edge_sharp = float((left - l_end) * bin_width_hz / 1e6)
return {'noise': noise, 'peak': peak, 'snr': snr,
'active_bw': active_bw, 'main_bw': main_bw,
'flatness': flatness, 'burst': burst_ratio, 'edge': edge_sharp}
def score_band(f):
if f is None or f['snr'] < 10: return 0, [], "noise"
score = 0; reasons = []
if f['main_bw'] > 16 or f['active_bw'] > 18:
return -5, ["TOO_WIDE"], "wifi"
if 8 <= f['main_bw'] <= 12: score += 2; reasons.append("BW10")
elif 6 <= f['main_bw'] < 8 or 12 < f['main_bw'] <= 14:
score += 1; reasons.append("BW~")
if f['flatness'] > 0.60: score += 2; reasons.append("FLAT")
elif f['flatness'] > 0.45: score += 1; reasons.append("flat")
if 0.10 < f['burst'] < 0.60: score += 2; reasons.append("BURST")
elif f['burst'] >= 0.85: score -= 2; reasons.append("CONT")
if 0 < f['edge'] < 1.0: score += 1; reasons.append("SHARP")
if score >= SCORE_THRESHOLD: cls = "ocusync"
elif score >= 3: cls = "suspect"
else: cls = "other"
return score, reasons, cls
# ----- USRP init -----
try:
usrp = uhd.usrp.MultiUSRP("type=b200")
usrp.set_rx_rate(SAMP_RATE)
usrp.set_rx_gain(GAIN, 0)
usrp.set_rx_antenna("RX2", 0)
st_args = uhd.usrp.StreamArgs("fc32", "sc16")
st_args.channels = [0]
streamer = usrp.get_rx_stream(st_args)
metadata = uhd.types.RXMetadata()
buf = np.zeros(streamer.get_max_num_samps(), dtype=np.complex64)
except Exception as e:
out_q.put({"type": "fatal", "msg": f"USRP init failed: {e}"})
return
out_q.put({"type": "ready", "pid": os.getpid()})
def capture_band(freq):
usrp.set_rx_freq(uhd.libpyuhd.types.tune_request(freq), 0)
time.sleep(0.15)
c = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont)
c.stream_now = True
streamer.issue_stream_cmd(c)
for _ in range(8):
streamer.recv(buf, metadata, 0.5)
samples = np.zeros(SAMPLES_PER_BAND, dtype=np.complex64)
got = 0
while got < SAMPLES_PER_BAND:
n = streamer.recv(buf, metadata, 1.0)
if n == 0: break
cp = min(n, SAMPLES_PER_BAND - got)
samples[got:got+cp] = buf[:cp]; got += cp
streamer.issue_stream_cmd(
uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont))
t_end = time.time() + 0.15
while time.time() < t_end:
if streamer.recv(buf, metadata, 0.05) == 0: break
return samples if got >= FFT_SIZE * 2 else None
# ----- scan loop -----
scan_count = 0
try:
while True:
scan_count += 1
out_q.put({"type": "scan_start", "scan_count": scan_count,
"time": datetime.now().strftime("%H:%M:%S")})
results = []
for band_name, freq in scan_freqs.items():
out_q.put({"type": "scanning", "band": band_name})
try:
samples = capture_band(freq)
feat = analyze_band(samples)
except Exception as e:
# non-fatal-from-python: let it fall through
print(f"[worker] capture {band_name} err: {e}", flush=True)
feat = None
score, reasons, cls = score_band(feat)
verdict_map = {"ocusync":"OCUSYNC","wifi":"WIFI",
"suspect":"NARROW","noise":"NOISE","other":"OTHER"}
if feat is None:
res = {"band": band_name, "group": band_group(band_name),
"snr": 0.0, "main_bw": 0.0, "active_bw": 0.0,
"flatness": 0.0, "burst": 0.0, "edge": 99.0,
"score": 0, "reasons": [], "class": "noise",
"verdict": "NOISE", "is_ocusync": False}
else:
res = {"band": band_name, "group": band_group(band_name),
"snr": feat["snr"], "main_bw": feat["main_bw"],
"active_bw": feat["active_bw"],
"flatness": feat["flatness"],
"burst": feat["burst"], "edge": feat["edge"],
"score": score, "reasons": reasons,
"class": cls,
"verdict": verdict_map.get(cls, "OTHER"),
"is_ocusync": (cls == "ocusync")}
results.append(res)
out_q.put({"type": "band_result", "result": res,
"ts": time.time()})
out_q.put({"type": "scan_done", "results": results,
"ts": time.time()})
except Exception as e:
out_q.put({"type": "fatal", "msg": f"worker exception: {e}\n"
f"{traceback.format_exc()}"})
return
# If we fall out for any reason, parent will respawn.
# ============================================================
# ========== PARENT PROCESS: HTTP + state + monitor ==========
# ============================================================
_lock = threading.Lock()
STATE = {
"scan_count": 0, "detection_count": 0, "current_band": "",
"dual_band": False, "hopping": False, "last_detection_ts": 0.0,
"last_scan": [], "band_hits": {},
"hop_timeline": deque(maxlen=HOP_TIMELINE_MAXLEN),
"history": deque(maxlen=WATERFALL_ROWS),
"detections": deque(maxlen=MAX_DETECTIONS_LOG),
"usrp_status": "init",
"worker_restarts": 0,
"last_worker_error": "",
}
class APIHandler(BaseHTTPRequestHandler):
def log_message(self, *a, **kw): pass
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,OPTIONS")
self.send_header("Access-Control-Allow-Headers", "*")
def do_OPTIONS(self):
self.send_response(204); self._cors(); self.end_headers()
def do_GET(self):
try:
if self.path.startswith("/api/status"):
with _lock:
now = time.time()
bh = {k: dict(v) for k, v in STATE["band_hits"].items()
if now - v["last_hit_ts"] < STICKY_WINDOW_SEC}
hop = [dict(h) for h in STATE["hop_timeline"]
if now - h["ts"] <= 30]
payload = {
"now": now,
"scan_count": STATE["scan_count"],
"detection_count": STATE["detection_count"],
"current_band": STATE["current_band"],
"dual_band": STATE["dual_band"],
"hopping": STATE["hopping"],
"last_detection_ts": STATE["last_detection_ts"],
"last_scan": list(STATE["last_scan"]),
"band_hits": bh,
"hop_timeline": hop,
"history": list(STATE["history"]),
"detections": list(STATE["detections"]),
"usrp_status": STATE["usrp_status"],
"worker_restarts": STATE["worker_restarts"],
"last_worker_error": STATE["last_worker_error"],
"config": {"bands": BAND_ORDER,
"score_threshold": SCORE_THRESHOLD},
}
body = json.dumps(payload, default=str).encode()
self.send_response(200); self._cors()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers(); self.wfile.write(body)
else:
self.send_response(404); self._cors(); self.end_headers()
except Exception:
try:
self.send_response(500); self._cors(); self.end_headers()
self.wfile.write(b'{"error":"internal"}')
except Exception: pass
traceback.print_exc()
def start_http_server():
srv = ThreadingHTTPServer(("0.0.0.0", HTTP_PORT), APIHandler)
print(f"[HTTP] API: http://localhost:{HTTP_PORT}/api/status", flush=True)
srv.serve_forever()
# ----- band history kept in parent, shared across worker restarts -----
band_history = {b: deque(maxlen=HISTORY_LEN) for b in scan_freqs}
detection_count = 0
def process_scan_done(results):
"""Cross-scan verification --- same logic as original."""
global detection_count
hit_bands = [r["band"] for r in results if r.get("is_ocusync")]
for r in results:
band_history[r["band"]].append(bool(r.get("is_ocusync")))
hopping_bands = [b for b, h in band_history.items()
if any(h) and not all(h) and len(h) >= 3]
persistent_bands = [b for b, h in band_history.items()
if len(h) >= 3 and all(h)]
strong_hit = len(hit_bands) >= 1
hop_confirmed = len(hopping_bands) >= 2
dual_band = (any(b.startswith("2.4") for b in hit_bands) and
any(b.startswith("5.") for b in hit_bands))
confirmed = strong_hit and (hop_confirmed or dual_band)
print(f"hits={hit_bands} hop={hopping_bands} persist={persistent_bands}",
flush=True)
with _lock:
STATE["current_band"] = ""
STATE["dual_band"] = bool(dual_band)
STATE["hopping"] = bool(hop_confirmed)
STATE["last_scan"] = results
STATE["history"].append({"ts": time.time(), "results": results})
if confirmed:
detection_count += 1
STATE["detection_count"] = detection_count
STATE["last_detection_ts"] = time.time()
STATE["detections"].appendleft({
"id": detection_count,
"time": datetime.now().strftime("%H:%M:%S"),
"bands": list(hit_bands),
"dual_band": bool(dual_band),
"hopping": bool(hop_confirmed),
"high_confidence": bool(dual_band and hop_confirmed),
})
print(f"*** DETECTION #{detection_count} "
f"hop={hop_confirmed} dual={dual_band} ***", flush=True)
def process_band_result(band_name, res, ts):
"""Update band_hits and hop_timeline on OCU hit."""
if not res.get("is_ocusync"): return
snr = float(res.get("snr", 0))
with _lock:
prev = STATE["band_hits"].get(band_name, {"hit_count": 0})
STATE["band_hits"][band_name] = {
"last_hit_ts": ts, "last_snr": snr,
"hit_count": int(prev["hit_count"]) + 1,
}
STATE["hop_timeline"].append({
"ts": ts, "band": band_name,
"group": band_group(band_name), "snr": snr,
})
def spawn_worker():
q = mp.Queue(maxsize=200)
p = mp.Process(target=scanner_worker, args=(q,), daemon=True)
p.start()
print(f"[parent] spawned worker pid={p.pid}", flush=True)
return p, q
def main():
print("=" * 95)
print("DJI Mini 2 OcuSync Detector (isolated-subprocess edition)")
print(f"Gain={GAIN}dB BW={SAMP_RATE/1e6:.0f}MHz "
f"Bands={len(scan_freqs)} Threshold={SCORE_THRESHOLD}")
print("=" * 95, flush=True)
threading.Thread(target=start_http_server, daemon=True).start()
proc, q = spawn_worker()
with _lock: STATE["usrp_status"] = "starting"
shutting_down = False
def shutdown(*_):
nonlocal shutting_down
shutting_down = True
try: proc.terminate()
except Exception: pass
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
backoff = 2
try:
while not shutting_down:
# Pull messages
try:
msg = q.get(timeout=0.5)
except _queue.Empty:
msg = None
if msg is not None:
mt = msg.get("type")
if mt == "ready":
with _lock: STATE["usrp_status"] = "ok"
backoff = 2
print(f"[parent] worker ready (pid={msg.get('pid')})",
flush=True)
elif mt == "scan_start":
with _lock:
STATE["scan_count"] = msg["scan_count"]
print(f"--- Scan #{msg['scan_count']} at {msg['time']} ---",
flush=True)
elif mt == "scanning":
with _lock: STATE["current_band"] = msg["band"]
elif mt == "band_result":
r = msg["result"]
process_band_result(r["band"], r, msg["ts"])
# short live print
if r.get("verdict") == "OCUSYNC":
print(f" {r['band']:<14} OCUSYNC SNR={r['snr']:.1f} "
f"reasons={r.get('reasons')}", flush=True)
elif mt == "scan_done":
process_scan_done(msg["results"])
elif mt == "fatal":
err = msg.get("msg", "")
with _lock:
STATE["usrp_status"] = "reconnecting"
STATE["last_worker_error"] = err[:300]
print(f"[parent] worker reported fatal: {err}", flush=True)
# Monitor worker liveness
if not proc.is_alive():
code = proc.exitcode
print(f"[parent] worker died (exitcode={code}). "
f"Restarting in {backoff}s...", flush=True)
with _lock:
STATE["usrp_status"] = "reconnecting"
STATE["worker_restarts"] += 1
if not STATE["last_worker_error"]:
STATE["last_worker_error"] = f"process exit code {code}"
# drain remaining msgs
try:
while True: q.get_nowait()
except _queue.Empty: pass
try: proc.join(timeout=1)
except Exception: pass
time.sleep(backoff)
backoff = min(backoff * 2, 20)
proc, q = spawn_worker()
with _lock: STATE["usrp_status"] = "starting"
except KeyboardInterrupt:
pass
finally:
print("\nShutting down...", flush=True)
try: proc.terminate(); proc.join(timeout=3)
except Exception: pass
if __name__ == "__main__":
# B210/UHD uses native threads + libusb that don't play well with fork()
mp.set_start_method("spawn", force=True)
main()