树莓派4b + USRP B210 搭建反无人机(反无)系统( HTML + CDN )

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 的数据流,但留给实时处理的余量不多。

关键限制

  1. 采样带宽建议控制在 20 MHz 以内(最多尝试 30 MHz),56 MHz 满血会丢包
  2. 不要同时开 MIMO 双通道 + 高带宽,二选一
  3. USB 3.0 口要直连,不要走 USB Hub,不要用延长线(或只用高质量短线)
  4. 供电必须稳:官方 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  &nbsp;
        <span class="text-purple-400">●</span> 5.8G  &nbsp;
        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()
相关推荐
21439652 小时前
HTML怎么创建时间轴布局_HTML结构化时间线写法【方法】
jvm·数据库·python
沐知全栈开发2 小时前
CSS Backgrounds (背景)
开发语言
gmaajt2 小时前
HTML函数开发需要SSD吗_SSD对HTML函数开发效率影响【详解】
jvm·数据库·python
LiAo_1996_Y2 小时前
p标签能嵌套div吗_HTML块级元素嵌套规则【解答】
jvm·数据库·python
2301_816660212 小时前
c++怎么将纯C的FILE-升级为C++的fstream_流缓冲绑定技巧【详解】
jvm·数据库·python
码界筑梦坊2 小时前
89-基于Django的加利福尼亚州各县死亡概况分析系统
数据库·python·信息可视化·数据分析·django·毕业设计
m0_514520572 小时前
CSS如何实现输入框提示文字的浮动动画_利用transform translateY上移
jvm·数据库·python
yejqvow122 小时前
php怎么调用字节跳动AI商品推荐_php如何基于用户行为生成千人千面
jvm·数据库·python
坐吃山猪2 小时前
MFlow03-数据模型解析
开发语言·python·源码·agent·记忆