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:快速原型(推荐入门)
1[ USRP B210 ] ←USB3.0→ [ 树莓派 5 ] 2 ↓ 3 [ UHD 驱动 ] 4 ↓ 5 [ GNU Radio 3.10 ] 6 ↓ 7 ┌────────────┬────────────┬──────────────┐ 8 ↓ ↓ ↓ ↓ 9 频谱瀑布 能量检测 特征匹配 Web 前端 10 (QT GUI) (CFAR 阈值) (模板比对) (Flask+WS) 11
依赖安装(树莓派 OS 64-bit):
bash
1sudo apt install libuhd-dev uhd-host gnuradio python3-uhd 2sudo uhd_images_downloader # 下载 FPGA 固件 3uhd_find_devices # 确认 B210 连接 4uhd_usrp_probe # 查看能力 5
方案 B:进阶(机器学习识别)
1USRP → 树莓派(I/Q采集 + 短时FFT)→ 频谱图像 → CNN分类器 2 ↓ 3 [DJI / WiFi / FPV / 背景] 4
可用 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 连接方式
1 ┌─────────────┐ USB 3.0 短线 ┌─────────────┐ 2 │ Pi 4B │ ═══════════════════════ │ B210 │ 3 │ (蓝色 USB3)│ │ (USB3 口) │ 4 └──────┬──────┘ └──────┬──────┘ 5 │ │ 6 │ 5V/3A │ 天线接 RX2 7 │ │ (不要接 TX/RX) 8 ▼ ▼ 9 [ 官方电源 ] [ 2.4GHz 天线 ] 10
⚠️ 关键提示:
- 必须插 Pi 4B 的蓝色 USB3.0 口(不是黑色 2.0!)
- 不要用 USB 延长线或 Hub
- B210 需要 USB 3.0 的供电+带宽,USB 2.0 会直接报错
二、系统基础配置
2.1 烧录系统
推荐用 Raspberry Pi OS 64-bit (Bookworm):
1# 在电脑上用 Raspberry Pi Imager 烧录 2# 选择: Raspberry Pi OS (64-bit) - Bookworm 3# 烧录时记得设置好 WiFi/SSH/用户名密码 4
2.2 首次开机后的系统优化
1# SSH 进入树莓派 2ssh pi@<树莓派IP> 3 4# 更新系统(第一步必做) 5sudo apt update 6sudo apt full-upgrade -y 7 8# 扩大 swap(编译 UHD 需要,否则 4GB 内存会 OOM) 9sudo dphys-swapfile swapoff 10sudo nano /etc/dphys-swapfile 11# 修改: CONF_SWAPSIZE=2048 12sudo dphys-swapfile setup 13sudo dphys-swapfile swapon 14 15# 验证 swap 16free -h 17# 应该看到 Swap: 2.0Gi 18
2.3 USB 缓冲区调优(B210 稳定运行关键)
1# 创建配置文件 2sudo tee /etc/sysctl.d/99-usrp.conf > /dev/null <<EOF 3# USRP B210 性能优化 4net.core.rmem_max=33554432 5net.core.wmem_max=33554432 6net.core.rmem_default=33554432 7net.core.wmem_default=33554432 8net.core.netdev_max_backlog=5000 9EOF 10 11sudo sysctl -p /etc/sysctl.d/99-usrp.conf 12 13# USB 缓冲区大小(B210 关键参数) 14echo 1000 | sudo tee /sys/module/usbcore/parameters/usbfs_memory_mb 15 16# 永久生效 17sudo tee -a /etc/rc.local > /dev/null <<EOF 18echo 1000 > /sys/module/usbcore/parameters/usbfs_memory_mb 19EOF 20
2.4 CPU 性能模式(防止自动降频)
1# 安装 cpufrequtils 2sudo apt install -y cpufrequtils 3 4# 设为 performance 模式 5sudo tee /etc/default/cpufrequtils > /dev/null <<EOF 6GOVERNOR="performance" 7EOF 8 9sudo systemctl restart cpufrequtils 10 11# 验证 12cpufreq-info | grep "current CPU frequency" 13# 应该显示 1.5GHz(Pi 4B 默认最高频率) 14
三、安装 UHD 驱动(B210 的核心)
两种方式:APT 安装(快但版本旧)vs 源码编译(慢但最新稳定)。
方式 A:APT 安装(先试这个,10 分钟搞定)
1sudo apt install -y libuhd-dev libuhd4.1.0 uhd-host python3-uhd 2 3# 下载 FPGA 固件 4sudo /usr/lib/uhd/utils/uhd_images_downloader.py 5 6# 设置 udev 规则(允许非 root 访问 B210) 7sudo cp /usr/lib/uhd/utils/uhd-usrp.rules /etc/udev/rules.d/ 8sudo udevadm control --reload-rules 9sudo udevadm trigger 10 11# 把自己加到 usrp 用户组 12sudo groupadd -f usrp 13sudo usermod -aG usrp $USER 14 15# 重新登录使组权限生效 16exit 17# 重新 SSH 登录 18
测试是否识别 B210:
1# 插好 B210 后 2uhd_find_devices 3 4# 成功的输出类似: 5# -------------------------------------------------- 6# -- UHD Device 0 7# -------------------------------------------------- 8# Device Address: 9# serial: 3xxxxxx 10# name: MyB210 11# product: B210 12# type: b200 13
如果上面能识别出 B210,可以跳过方式 B,直接进入第四章。
方式 B:源码编译(APT 版本有问题时用)
bash
1# 安装依赖 2sudo apt install -y autoconf automake build-essential ccache cmake \ 3 cpufrequtils doxygen ethtool fort77 g++ gir1.2-gtk-3.0 git \ 4 gobject-introspection gpsd gpsd-clients inetutils-tools libasound2-dev \ 5 libboost-all-dev libcomedi-dev libcppunit-dev libfftw3-bin libfftw3-dev \ 6 libfftw3-doc libfontconfig1-dev libgmp-dev libgps-dev libgsl-dev \ 7 liblog4cpp5-dev libncurses5 libncurses5-dev libpulse-dev libqt5opengl5-dev \ 8 libqwt-qt5-dev libsdl1.2-dev libtool libudev-dev libusb-1.0-0 \ 9 libusb-1.0-0-dev libusb-dev libxi-dev libxrender-dev libzmq3-dev \ 10 libzmq5 ncurses-bin python3-cheetah python3-click python3-click-plugins \ 11 python3-dev python3-docutils python3-gi python3-gi-cairo python3-gps \ 12 python3-lxml python3-mako python3-numpy python3-opengl python3-pyqt5 \ 13 python3-requests python3-scipy python3-setuptools python3-six python3-yaml \ 14 python3-zmq python3-pip swig wget 15 16# 下载 UHD 源码(选稳定版本) 17cd ~ 18git clone https://github.com/EttusResearch/uhd.git 19cd uhd 20git checkout v4.6.0.0 # 稳定版 21 22# 编译(⏰ 这一步要 1-2 小时,耐心等) 23cd host 24mkdir build && cd build 25cmake -DCMAKE_INSTALL_PREFIX=/usr/local \ 26 -DENABLE_TESTS=OFF \ 27 -DENABLE_C_API=ON \ 28 -DENABLE_PYTHON_API=ON \ 29 -DENABLE_MANUAL=OFF \ 30 -DENABLE_DOXYGEN=OFF \ 31 ../ 32 33# 用 3 个核心编译(留 1 个核心给系统,防止卡死) 34make -j3 35 36# 安装 37sudo make install 38sudo ldconfig 39 40# 下载固件 41sudo /usr/local/lib/uhd/utils/uhd_images_downloader.py 42 43# udev 规则 44sudo cp /usr/local/lib/uhd/utils/uhd-usrp.rules /etc/udev/rules.d/ 45sudo udevadm control --reload-rules 46sudo udevadm trigger 47
四、验证 B210 工作正常
4.1 基础识别测试
1uhd_find_devices 2
期望输出:
1-- UHD Device 0 2-- Device Address: 3-- serial: 3xxxxxxx 4-- name: 5-- product: B210 6-- type: b200 7
4.2 探测设备详细信息
1uhd_usrp_probe 2
这会首次下载 FPGA 镜像到 B210(需要 30 秒,别中断)。成功后会打印完整的设备信息树。
常见错误:
1RuntimeError: Error in open_device: insufficient permissions 2
→ 说明 udev 规则没生效,重新执行:
1sudo cp /usr/lib/uhd/utils/uhd-usrp.rules /etc/udev/rules.d/ 2sudo udevadm control --reload-rules && sudo udevadm trigger 3
然后拔插一次 B210。
4.3 性能基准测试(最关键)
1# 测试 Pi 4B 能稳定跑多高采样率 2uhd_rx_samples_to_file \ 3 --freq 2.437e9 \ 4 --rate 20e6 \ 5 --gain 50 \ 6 --duration 5 \ 7 --file /tmp/test.dat \ 8 --type short 9 10# 如果出现大量 "O"(Overflow),说明采样率太高 11# Pi 4B + B210 经验值: 12# - USB 3.0 连接 + 好线材:20-25 MHz 稳定 13# - USB 2.0 或劣质线:只能 5-8 MHz 14
期望看到(无警告输出):
1Press Ctrl + C to stop streaming... 2[INFO] [UHD] ... 3Done! 4
如果一直打印 O 字符:
1# 降采样率到 10 MHz 再试 2uhd_rx_samples_to_file --freq 2.437e9 --rate 10e6 --gain 50 \ 3 --duration 5 --file /tmp/test.dat --type short 4
五、安装 Python 依赖
1# 科学计算库 2sudo apt install -y python3-numpy python3-scipy python3-matplotlib 3 4# 如果 UHD 用 APT 装的,Python 绑定已有 5python3 -c "import uhd; print(uhd.__version__)" 6# 应该打印版本号 7 8# 如果 import 失败,手动装 9pip3 install --break-system-packages uhd 10
测试 Python 能否控制 B210:
查看全部
1# test_b210.py 2import uhd 3 4usrp = uhd.usrp.MultiUSRP() 5print("设备信息:", usrp.get_pp_string()) 6print("支持的采样率范围:") 7print(f" 最大: {usrp.get_rx_rates().stop()/1e6} MHz") 8print("✓ Python UHD 正常") 9
运行
1python3 test_b210.py 2
六、创建 Spark 检测器工作目录
bash
1# 创建项目目录 2mkdir -p ~/spark_detector 3cd ~/spark_detector 4 5# 创建文件结构 6mkdir logs captures 7touch detector.py config.py 8
6.1 配置文件 config.py
python
1# config.py 2"""DJI Spark 检测器配置""" 3 4# ==================== B210 配置 ==================== 5# 采样率:Pi 4B 稳定值 6# 20e6 = USB3 + 好线材 7# 10e6 = 保守稳定值 8# 5e6 = USB2 兜底 9SAMPLE_RATE = 10e6 10 11# 中心频率:Spark 默认用 WiFi 信道 1/6/11 12# 信道 1 = 2.412 GHz, 信道 6 = 2.437 GHz, 信道 11 = 2.462 GHz 13# 20e6 采样率能覆盖 ±10MHz,刚好覆盖一个信道 14CENTER_FREQ = 2.437e9 # 信道 6 15 16# 增益:0-76 dB 17# 室内/近距离测试:40-50 18# 户外远距离:65-76 19GAIN = 60 20 21# 天线端口 22ANTENNA = "RX2" 23 24# ==================== 检测参数 ==================== 25FFT_SIZE = 2048 # 频谱分辨率 26DETECTION_THRESHOLD_DB = 15 # SNR 阈值(高于噪底 15dB 才认定有信号) 27WIFI_BW_MIN = 15e6 # WiFi 信号最小带宽 28WIFI_BW_MAX = 22e6 # WiFi 信号最大带宽 29CONFIRM_COUNT = 3 # 连续多少次检测才告警(抗抖动) 30ALERT_COOLDOWN = 5.0 # 告警冷却时间(秒) 31 32# ==================== 信道跳转 ==================== 33# 扫描多个 WiFi 信道 34ENABLE_CHANNEL_HOPPING = True 35CHANNELS = { 36 1: 2.412e9, 37 6: 2.437e9, 38 11: 2.462e9, 39} 40HOP_INTERVAL = 2.0 # 每信道停留秒数 41 42# ==================== 日志 ==================== 43LOG_DIR = "logs" 44CAPTURE_DIR = "captures" 45SAVE_IQ_ON_DETECT = False # 检测到后是否保存 I/Q 数据(调试用) 46
运行
6.2 主检测器 detector.py
python
查看全部
1 2 if is_wifi: 3 self.detection_count += 1 4 if self.detection_count >= cfg.CONFIRM_COUNT: 5 self.alert(peak, snr, bw, off) 6 self.detection_count = 0 7 else: 8 self.detection_count = max(0, self.detection_count - 1) 9 # 实时状态(打点) 10 if int(time.time()) % 5 == 0: 11 sys.stdout.write(".") 12 sys.stdout.flush() 13 14 except KeyboardInterrupt: 15 print("\n[*] 收到停止信号") 16 finally: 17 self.stop_stream() 18 self.log_fp.close() 19 print("[*] 已清理退出") 20 21 22if __name__ == "__main__": 23 detector = SparkDetector() 24 detector.run() 25
运行
6.3 运行
bash
1cd ~/spark_detector 2python3 detector.py 3
期望输出:
scheme
1[*] 初始化 B210... 2[*] 日志文件: logs/spark_20260421_153022.log 3[*] 流已启动 4 5[*] 开始扫描... 按 Ctrl+C 停止 6[*] 采样率: 10.0 MHz 7[*] 增益: 60 dB 8[*] 信道跳转: 开启 9 10........... 11[!] 2026-04-21 15:31:05 疑似 DJI Spark / WiFi 类无人机信号 12 信道: 6 (2.437 GHz) 13 峰值频率: 2.4372 GHz (偏移 +0.20 MHz) 14 ... 15
七、系统调优(让它跑得更稳)
7.1 实时发现 Overflow 就降采样率
如果运行中一直打印 OOOOO:
python
1# 修改 config.py 2SAMPLE_RATE = 5e6 # 从 10 MHz 降到 5 MHz 3
运行
7.2 Pi 4B 性能监控
开第二个 SSH 终端:
bash
1# 监控 CPU/温度/USB 2watch -n 1 'echo "CPU:"; vcgencmd measure_clock arm; \ 3 echo "温度:"; vcgencmd measure_temp; \ 4 echo "负载:"; uptime; \ 5 echo "USB:"; lsusb | grep Ettus' 6
警示信号:
- 温度 >75°C → 加风扇
- CPU 负载 >3.5(4 核)→ 降采样率或 FFT 大小
- USB 设备消失 → 供电/线材问题
7.3 自启动服务
bash
1# 创建 systemd 服务 2sudo tee /etc/systemd/system/spark-detector.service > /dev/null <<EOF 3[Unit] 4Description=DJI Spark Detector 5After=network.target 6 7[Service] 8Type=simple 9User=pi 10WorkingDirectory=/home/pi/spark_detector 11ExecStart=/usr/bin/python3 /home/pi/spark_detector/detector.py 12Restart=on-failure 13RestartSec=5 14 15[Install] 16WantedBy=multi-user.target 17EOF 18 19sudo systemctl daemon-reload 20sudo systemctl enable spark-detector 21sudo systemctl start spark-detector 22 23# 查看日志 24journalctl -u spark-detector -f 25
八、常见问题排错清单
| 现象 | 原因 | 解决 |
|---|---|---|
| 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# 1. 硬件识别 2uhd_find_devices 3# ✓ 应看到 B210 4 5# 2. FPGA 加载 6uhd_usrp_probe 2>&1 | head -20 7# ✓ 应看到详细设备树 8 9# 3. 数据流 10uhd_rx_samples_to_file --freq 2.437e9 --rate 10e6 --gain 50 \ 11 --duration 3 --file /tmp/t.dat 12ls -la /tmp/t.dat 13# ✓ 文件大小应约 240 MB(10e6 × 3s × 8字节) 14 15# 4. Python 绑定 16python3 -c "import uhd; u=uhd.usrp.MultiUSRP(); print(u.get_pp_string())" 17# ✓ 打印设备信息 18
9.2 Spark 实战测试
bash
1# 1. 启动检测器 2python3 ~/spark_detector/detector.py 3 4# 2. 另找一台设备,打开 Spark(或朋友家借一台) 5# - 开机前:屏幕应打印 "......"(只有噪声) 6# - 开机后:5-10 秒内应该看到告警 7 8# 3. 验证检测距离 9# 室内:5-10 米应稳定触发 10# 户外:用板载鞭状天线 100-200 米 11# 户外+定向板状天线:500m-1km 12
十、下一步扩展(你现在能做的)
搞定上面后,你有几个方向深化:
① 融合 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
1<!DOCTYPE html> 2<html lang="en"> 3<head> 4<meta charset="UTF-8"> 5<title>Mini 2 Detector · Live</title> 6<meta name="viewport" content="width=device-width, initial-scale=1"> 7<script src="https://cdn.tailwindcss.com"></script> 8<style> 9 body { font-family: 'SF Mono', Menlo, Consolas, monospace; } 10 .glow-g { text-shadow: 0 0 8px #10b981, 0 0 16px #10b98177; } 11 .glow-r { text-shadow: 0 0 10px #ef4444, 0 0 24px #ef4444cc, 0 0 40px #ef4444aa; } 12 .glow-a { text-shadow: 0 0 8px #f59e0b, 0 0 16px #f59e0b88; } 13 .grid-bg { background-image: 14 linear-gradient(rgba(16,185,129,.07) 1px, transparent 1px), 15 linear-gradient(90deg, rgba(16,185,129,.07) 1px, transparent 1px); 16 background-size: 24px 24px; } 17 @keyframes sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } 18 .scan-sweep { background: linear-gradient(90deg, transparent, #10b98144, transparent); animation: sweep 1.5s infinite; } 19 @keyframes siren { 0%,100% { background-color: rgba(239,68,68,.15); } 50% { background-color: rgba(239,68,68,.45); } } 20 .siren-bg { animation: siren 0.6s infinite; } 21 @keyframes flash { 0% { opacity: 0.8; } 100% { opacity: 0; } } 22 .flash-layer { animation: flash 0.4s ease-out; } 23 @keyframes shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } } 24 .shake { animation: shake 0.15s 4; } 25 @keyframes beaconRing { 0% { transform: scale(1); opacity: 1; } 100% { transform: scale(2.5); opacity: 0; } } 26 .beacon-ring { animation: beaconRing 1.2s infinite; } 27 canvas { display: block; } 28</style> 29</head> 30<body class="bg-slate-950 text-slate-200 min-h-screen grid-bg"> 31 32<div id="flashOverlay" class="fixed inset-0 bg-red-500 pointer-events-none z-50" style="opacity:0;"></div> 33 34<div class="max-w-7xl mx-auto p-4 space-y-4"> 35 36 <div id="alertBanner" 37 class="rounded-lg border-2 border-emerald-500/30 p-4 transition-all duration-300" 38 style="background: rgba(15,23,42,0.8);"> 39 <div class="flex items-center justify-between"> 40 <div class="flex items-center gap-4"> 41 <div class="relative"> 42 <div id="beacon" class="w-6 h-6 rounded-full bg-emerald-500"></div> 43 <div id="beaconRing" class="absolute inset-0 rounded-full bg-emerald-500/60"></div> 44 </div> 45 <div> 46 <div id="alertTitle" class="text-3xl md:text-4xl font-black tracking-tight glow-g text-emerald-400"> 47 AREA CLEAR 48 </div> 49 <div id="alertSubtitle" class="text-sm text-slate-400 mt-1"> 50 No OcuSync activity detected 51 </div> 52 </div> 53 </div> 54 <div class="flex items-center gap-4"> 55 <div class="text-right"> 56 <div class="text-[10px] text-slate-500 uppercase">Last Hit</div> 57 <div id="lastHitAgo" class="text-lg font-bold text-slate-400 font-mono">--</div> 58 </div> 59 <button id="soundToggle" 60 class="px-3 py-2 rounded border border-slate-700 bg-slate-800 hover:bg-slate-700 text-xs"> 61 🔇 SOUND: OFF 62 </button> 63 </div> 64 </div> 65 </div> 66 67 <section class="grid grid-cols-2 md:grid-cols-5 gap-3"> 68 <div class="bg-slate-900/70 border border-slate-800 rounded-lg p-3"> 69 <div class="text-[10px] uppercase tracking-wider text-slate-500">Scans</div> 70 <div id="scanCount" class="text-2xl font-bold">0</div> 71 </div> 72 <div class="bg-slate-900/70 border border-slate-800 rounded-lg p-3"> 73 <div class="text-[10px] uppercase tracking-wider text-slate-500">Detections</div> 74 <div id="detectionCount" class="text-2xl font-bold text-amber-400">0</div> 75 </div> 76 <div class="bg-slate-900/70 border border-slate-800 rounded-lg p-3"> 77 <div class="text-[10px] uppercase tracking-wider text-slate-500">Dual-Band</div> 78 <div id="dualBandFlag" class="text-2xl font-bold text-slate-500">NO</div> 79 </div> 80 <div class="bg-slate-900/70 border border-slate-800 rounded-lg p-3"> 81 <div class="text-[10px] uppercase tracking-wider text-slate-500">Hopping</div> 82 <div id="hoppingFlag" class="text-2xl font-bold text-slate-500">NO</div> 83 </div> 84 <div class="bg-slate-900/70 border border-slate-800 rounded-lg p-3"> 85 <div class="text-[10px] uppercase tracking-wider text-slate-500">Scanning</div> 86 <div id="currentBand" class="text-lg font-bold text-emerald-300 font-mono truncate">---</div> 87 </div> 88 </section> 89 90 <section class="bg-slate-900/70 border border-slate-800 rounded-lg p-4"> 91 <div class="flex items-center justify-between mb-3"> 92 <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> 93 </div> 94 <div id="bandGrid" class="grid grid-cols-3 md:grid-cols-9 gap-2"></div> 95 </section> 96 97 <section class="grid grid-cols-1 lg:grid-cols-2 gap-4"> 98 <div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4"> 99 <div class="text-sm font-semibold text-emerald-400 mb-1">▸ HOP TIMELINE (last 30s)</div> 100 <div class="text-[10px] text-slate-500 mb-2"> 101 <span class="text-blue-400">●</span> 2.4G 102 <span class="text-purple-400">●</span> 5.8G 103 Each dot = OCU hit. 104 </div> 105 <canvas id="hopCanvas" width="600" height="200" class="w-full bg-slate-950/60 rounded"></canvas> 106 </div> 107 108 <div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4"> 109 <div class="text-sm font-semibold text-emerald-400 mb-3">▸ SNR + PEAK-HOLD (dB)</div> 110 <div id="snrBars" class="space-y-1.5"></div> 111 </div> 112 </section> 113 114 <section class="grid grid-cols-1 lg:grid-cols-3 gap-4"> 115 <div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4 lg:col-span-2"> 116 <div class="text-sm font-semibold text-emerald-400 mb-1">▸ WATERFALL · OCU HITS</div> 117 <div class="text-[10px] text-slate-500 mb-2">Red = OCUSYNC, Blue = WiFi, Amber = narrow, Gray = noise</div> 118 <canvas id="waterfallCanvas" width="800" height="260" class="w-full"></canvas> 119 </div> 120 <div class="bg-slate-900/70 border border-slate-800 rounded-lg p-4"> 121 <div class="text-sm font-semibold text-amber-400 mb-3">▸ DETECTION LOG</div> 122 <div id="detectionLog" class="space-y-2 max-h-64 overflow-y-auto text-xs font-mono"></div> 123 </div> 124 </section> 125 126 <footer class="text-center text-xs text-slate-600 pt-4"> 127 USRP B210 · OcuSync 2.0 · Poll 500ms · <code>localhost:8765</code> 128 </footer> 129</div> 130 131<script> 132// ========== Config ========== 133const API_URL = 'http://localhost:8765/api/status'; 134const POLL_MS = 500; 135const STICKY_WINDOW = 5.0; 136const PEAK_HOLD_MS = 3000; 137 138// ========== State ========== 139let peakHold = {}; 140let lastDetectionTs = 0; // in SERVER time 141let clockOffset = 0; // serverNow - clientNow (seconds) 142let prevDetectionCount = 0; 143let soundOn = false; 144let audioCtx = null; 145let apiConnected = false; 146 147function serverNow() { return Date.now()/1000 + clockOffset; } 148 149// ========== Audio ========== 150function initAudio() { 151 try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } 152 catch(e) {} 153} 154function beep(freq=1200, dur=0.15, vol=0.3) { 155 if (!soundOn || !audioCtx) return; 156 const o = audioCtx.createOscillator(); 157 const g = audioCtx.createGain(); 158 o.frequency.value = freq; o.type = 'square'; g.gain.value = vol; 159 o.connect(g); g.connect(audioCtx.destination); 160 o.start(); 161 g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur); 162 o.stop(audioCtx.currentTime + dur); 163} 164function alarmBurst() { 165 beep(1400,0.12,0.35); 166 setTimeout(()=>beep(900,0.12,0.35),140); 167 setTimeout(()=>beep(1400,0.12,0.35),280); 168} 169 170document.getElementById('soundToggle').addEventListener('click', e => { 171 soundOn = !soundOn; 172 if (soundOn && !audioCtx) initAudio(); 173 e.target.textContent = soundOn ? '🔊 SOUND: ON' : '🔇 SOUND: OFF'; 174 e.target.classList.toggle('bg-emerald-800', soundOn); 175 e.target.classList.toggle('border-emerald-500', soundOn); 176 if (soundOn) beep(880,0.1,0.2); 177}); 178 179// ========== Helpers ========== 180function fmtAgo(sec) { 181 if (!isFinite(sec) || sec < 0 || sec > 99999) return '--'; 182 if (sec < 60) return sec.toFixed(1) + 's'; 183 return Math.floor(sec/60) + 'm ' + Math.floor(sec%60) + 's'; 184} 185function safeNum(v, def=0) { return (typeof v === 'number' && isFinite(v)) ? v : def; } 186function snrColor(snr) { 187 if (snr < 5) return '#334155'; 188 if (snr < 10) return '#3b82f6'; 189 if (snr < 15) return '#06b6d4'; 190 if (snr < 20) return '#10b981'; 191 if (snr < 25) return '#f59e0b'; 192 return '#ef4444'; 193} 194 195function triggerFlash() { 196 const f = document.getElementById('flashOverlay'); 197 f.style.opacity = 0.8; 198 f.classList.remove('flash-layer'); void f.offsetWidth; f.classList.add('flash-layer'); 199 setTimeout(() => { f.style.opacity = 0; }, 400); 200} 201function triggerShake() { 202 const b = document.getElementById('alertBanner'); 203 b.classList.remove('shake'); void b.offsetWidth; b.classList.add('shake'); 204} 205 206// ========== Banner (FIX: always reset inline background) ========== 207function updateBanner(data, now) { 208 const banner = document.getElementById('alertBanner'); 209 const title = document.getElementById('alertTitle'); 210 const sub = document.getElementById('alertSubtitle'); 211 const beacon = document.getElementById('beacon'); 212 const ring = document.getElementById('beaconRing'); 213 const lastHitEl = document.getElementById('lastHitAgo'); 214 215 const sinceHit = data.last_detection_ts > 0 ? (now - data.last_detection_ts) : 9999; 216 lastHitEl.textContent = data.last_detection_ts > 0 ? fmtAgo(sinceHit) + ' ago' : 'never'; 217 218 const active = sinceHit < 7; 219 const high = active && data.dual_band && data.hopping; 220 221 // Always clear inline style first so class-based backgrounds work 222 banner.style.background = ''; 223 224 if (high) { 225 title.textContent = '⚠ DRONE DETECTED'; 226 title.className = 'text-3xl md:text-4xl font-black tracking-tight glow-r text-red-400'; 227 sub.innerHTML = '<span class="text-red-300 font-bold">HIGH CONFIDENCE</span> · OcuSync 2.0 · Dual-band + Hopping'; 228 banner.className = 'rounded-lg border-2 border-red-500 p-4 transition-all duration-300 siren-bg'; 229 beacon.className = 'w-6 h-6 rounded-full bg-red-500'; 230 ring.className = 'absolute inset-0 rounded-full bg-red-500/60 beacon-ring'; 231 lastHitEl.className = 'text-lg font-bold text-red-400 font-mono glow-r'; 232 } else if (active) { 233 title.textContent = '⚡ SIGNAL DETECTED'; 234 title.className = 'text-3xl md:text-4xl font-black tracking-tight glow-a text-amber-400'; 235 sub.innerHTML = '<span class="text-amber-300 font-bold">OcuSync-like signature</span> · verifying pattern...'; 236 banner.className = 'rounded-lg border-2 border-amber-500 p-4 transition-all duration-300'; 237 banner.style.background = 'rgba(120,53,15,0.35)'; 238 beacon.className = 'w-6 h-6 rounded-full bg-amber-500'; 239 ring.className = 'absolute inset-0 rounded-full bg-amber-500/60 beacon-ring'; 240 lastHitEl.className = 'text-lg font-bold text-amber-400 font-mono'; 241 } else { 242 title.textContent = apiConnected ? 'AREA CLEAR' : 'API OFFLINE'; 243 title.className = 'text-3xl md:text-4xl font-black tracking-tight ' + 244 (apiConnected ? 'glow-g text-emerald-400' : 'text-slate-500'); 245 sub.textContent = apiConnected ? 'No OcuSync activity detected' : 'Waiting for backend...'; 246 banner.className = 'rounded-lg border-2 border-emerald-500/30 p-4 transition-all duration-300'; 247 banner.style.background = 'rgba(15,23,42,0.8)'; 248 beacon.className = 'w-6 h-6 rounded-full ' + (apiConnected ? 'bg-emerald-500' : 'bg-slate-600'); 249 ring.className = 'absolute inset-0 rounded-full bg-emerald-500/40'; 250 lastHitEl.className = 'text-lg font-bold text-slate-400 font-mono'; 251 } 252} 253 254// ========== Band Grid (FIX: safe access to last_snr) ========== 255function renderBandGrid(scan, bandHits, currentBand, now) { 256 const grid = document.getElementById('bandGrid'); 257 grid.innerHTML = ''; 258 scan.forEach(b => { 259 const hit = bandHits[b.band] || {}; 260 const since = hit.last_hit_ts ? (now - hit.last_hit_ts) : 9999; 261 const stickyActive = since >= 0 && since < STICKY_WINDOW; 262 const intensity = stickyActive ? Math.max(0, 1 - since / STICKY_WINDOW) : 0; 263 const isScanning = (b.band === currentBand); 264 265 let border = 'border-slate-700', bg = 'bg-slate-800/40', tcolor = 'text-slate-400', label = '···'; 266 if (b.verdict === 'WIFI') { border='border-blue-700'; bg='bg-blue-900/30'; tcolor='text-blue-300'; label='WIFI'; } 267 if (b.verdict === 'NARROW') { border='border-amber-700'; bg='bg-amber-900/20'; tcolor='text-amber-300'; label='NAR'; } 268 if (stickyActive) { border='border-red-500'; tcolor='text-red-300'; label='OCU'; } 269 270 const el = document.createElement('div'); 271 el.className = [`relative overflow-hidden rounded-md border-2 ${border} ${stickyActive ? '' : bg} p-2 transition-all duration-200`](https://xplanc.org/primers/document/zh/02.Python/EX.%E5%86%85%E5%BB%BA%E5%87%BD%E6%95%B0/EX.all.md); 272 if (stickyActive) { 273 el.style.background = `rgba(239,68,68,${0.15 + intensity * 0.45})`; 274 el.style.boxShadow = `0 0 ${8 + intensity*20}px rgba(239,68,68,${0.3 + intensity*0.5})`; 275 } 276 277 const topInfo = stickyActive 278 ? [`<span class="text-[9px] text-red-300">${fmtAgo(since)} ago</span>`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.span.md) 279 : [`<span class="text-[10px] text-slate-500">${b.group || ''}</span>`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.span.md); 280 281 const snrShown = stickyActive ? safeNum(hit.last_snr, 0) : safeNum(b.snr, 0); 282 const hitCount = safeNum(hit.hit_count, 0); 283 284 el.innerHTML = ` 285 ${isScanning ? '<div class="absolute inset-0 scan-sweep pointer-events-none"></div>' : ''} 286 <div class="flex justify-between items-start"> 287 ${topInfo} 288 <span class="text-[9px] ${tcolor} font-bold">${label}</span> 289 </div> 290 <div class="text-[10px] font-mono text-slate-300 mt-1">${b.band}</div> 291 <div class="flex justify-between text-[10px] mt-1"> 292 <span class="text-slate-500">SNR</span> 293 <span class="font-bold ${stickyActive ? 'text-red-300' : tcolor}">${snrShown.toFixed(1)}</span> 294 </div> 295 <div class="flex justify-between text-[10px]"> 296 <span class="text-slate-500">Hits</span> 297 <span class="${hitCount > 0 ? 'text-red-300 font-bold' : 'text-slate-500'}">${hitCount}</span> 298 </div> 299 `; 300 grid.appendChild(el); 301 }); 302} 303 304// ========== SNR bars ========== 305function renderSnrBars(scan, now) { 306 scan.forEach(b => { 307 const snr = safeNum(b.snr, 0); 308 const prev = peakHold[b.band]; 309 if (!prev || snr >= prev.snr || (now - prev.ts) * 1000 > PEAK_HOLD_MS) { 310 peakHold[b.band] = { snr: snr, ts: now }; 311 } 312 }); 313 const root = document.getElementById('snrBars'); 314 root.innerHTML = ''; 315 const maxSnr = 40; 316 scan.forEach(b => { 317 const cur = safeNum(b.snr, 0); 318 const pk = safeNum(peakHold[b.band]?.snr, cur); 319 const curPct = Math.min(100, Math.max(0, (cur/maxSnr)*100)); 320 const pkPct = Math.min(100, Math.max(0, (pk/maxSnr)*100)); 321 const c = snrColor(cur); 322 const ocu = !!b.is_ocusync; 323 const row = document.createElement('div'); 324 row.innerHTML = ` 325 <div class="flex justify-between text-[10px] mb-0.5"> 326 <span class="font-mono ${ocu ? 'text-red-300 font-bold' : 'text-slate-400'}">${b.band} 327 <span class="text-slate-600">(${b.group || ''})</span> 328 ${ocu ? '<span class="text-red-400 ml-1">● OCU</span>' : ''} 329 </span> 330 <span class="font-mono" style="color:${c}">${cur.toFixed(1)} dB 331 <span class="text-slate-500"> pk ${pk.toFixed(1)}</span> 332 </span> 333 </div> 334 <div class="h-3 bg-slate-800 rounded overflow-hidden relative"> 335 <div class="h-full" style="width:${curPct}%; background:${c}; box-shadow:0 0 6px ${c}"></div> 336 <div class="absolute top-0 h-full" style="left:${pkPct}%; width:2px; background:#fbbf24; box-shadow:0 0 4px #fbbf24;"></div> 337 </div> 338 `; 339 root.appendChild(row); 340 }); 341} 342 343// ========== Hop timeline ========== 344function renderHopTimeline(hopTimeline, bandOrder, now) { 345 const cv = document.getElementById('hopCanvas'); 346 const ctx = cv.getContext('2d'); 347 const W = cv.width, H = cv.height; 348 ctx.fillStyle = '#020617'; ctx.fillRect(0,0,W,H); 349 350 const TIME_WINDOW = 30; 351 const padL = 70, padR = 8, padT = 8, padB = 20; 352 const chartW = W - padL - padR, chartH = H - padT - padB; 353 const denom = Math.max(1, bandOrder.length - 1); 354 355 ctx.strokeStyle = 'rgba(16,185,129,0.08)'; ctx.lineWidth = 1; 356 bandOrder.forEach((b, i) => { 357 const y = padT + (i / denom) * chartH; 358 ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(W-padR, y); ctx.stroke(); 359 ctx.fillStyle = b.startsWith('2') ? '#60a5fa' : '#c084fc'; 360 ctx.font = '9px monospace'; 361 ctx.fillText(b, 4, y+3); 362 }); 363 364 ctx.fillStyle = '#475569'; ctx.font='9px monospace'; 365 for (let s=0; s<=TIME_WINDOW; s+=5) { 366 const x = padL + ((TIME_WINDOW - s) / TIME_WINDOW) * chartW; 367 ctx.strokeStyle = 'rgba(100,116,139,0.15)'; 368 ctx.beginPath(); ctx.moveTo(x, padT); ctx.lineTo(x, H-padB); ctx.stroke(); 369 ctx.fillText([`-${s}s`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.s.md), x-10, H-6); 370 } 371 372 const bandIdx = {}; 373 bandOrder.forEach((b, i) => bandIdx[b] = i); 374 (hopTimeline || []).forEach(hit => { 375 const dt = now - hit.ts; 376 if (dt > TIME_WINDOW || dt < 0) return; 377 const i = bandIdx[hit.band]; if (i === undefined) return; 378 const x = padL + ((TIME_WINDOW - dt) / TIME_WINDOW) * chartW; 379 const y = padT + (i / denom) * chartH; 380 const color = hit.group === '2.4G' ? '#3b82f6' : '#a855f7'; 381 const r = Math.max(3, Math.min(9, safeNum(hit.snr,10) / 3)); 382 const grd = ctx.createRadialGradient(x, y, 0, x, y, r*2); 383 grd.addColorStop(0, color); grd.addColorStop(1, 'transparent'); 384 ctx.fillStyle = grd; 385 ctx.beginPath(); ctx.arc(x, y, r*2, 0, Math.PI*2); ctx.fill(); 386 ctx.fillStyle = color; 387 ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); ctx.fill(); 388 }); 389 390 const recent = (hopTimeline || []).filter(h => (now - h.ts) <= TIME_WINDOW && (now - h.ts) >= 0).slice(-15); 391 if (recent.length > 1) { 392 ctx.strokeStyle = 'rgba(239,68,68,0.5)'; 393 ctx.lineWidth = 1.5; 394 ctx.beginPath(); 395 let started = false; 396 recent.forEach((hit) => { 397 const i = bandIdx[hit.band]; if (i === undefined) return; 398 const x = padL + ((TIME_WINDOW - (now - hit.ts)) / TIME_WINDOW) * chartW; 399 const y = padT + (i / denom) * chartH; 400 if (!started) { ctx.moveTo(x,y); started = true; } else { ctx.lineTo(x,y); } 401 }); 402 if (started) ctx.stroke(); 403 } 404 405 ctx.strokeStyle = '#10b981'; ctx.lineWidth = 1.5; 406 ctx.beginPath(); ctx.moveTo(W-padR, padT); ctx.lineTo(W-padR, H-padB); ctx.stroke(); 407 ctx.fillStyle = '#10b981'; ctx.fillText('NOW', W-padR-24, padT+10); 408} 409 410// ========== Waterfall ========== 411function renderWaterfall(history, bandOrder) { 412 const cv = document.getElementById('waterfallCanvas'); 413 const ctx = cv.getContext('2d'); 414 const W = cv.width, H = cv.height; 415 ctx.fillStyle = '#020617'; ctx.fillRect(0,0,W,H); 416 if (!history || !history.length || !bandOrder.length) return; 417 const cellH = H / bandOrder.length; 418 const cellW = W / Math.max(history.length, 80); 419 420 history.forEach((row, ti) => { 421 const x = ti * cellW; 422 bandOrder.forEach((bname, bi) => { 423 const y = bi * cellH; 424 const b = (row.results || []).find(r => r.band === bname); 425 if (!b) return; 426 let color; 427 if (b.verdict === 'OCUSYNC') color = '#ef4444'; 428 else if (b.verdict === 'WIFI') color = '#3b82f6'; 429 else if (b.verdict === 'NARROW') color = '#f59e0b'; 430 else color = [`rgba(51,65,85,${Math.min(1, 0.3 + safeNum(b.snr,0)/30)})`](https://xplanc.org/primers/document/zh/02.Python/EX.%E5%86%85%E5%BB%BA%E5%87%BD%E6%95%B0/EX.min.md); 431 ctx.fillStyle = color; 432 ctx.globalAlpha = b.verdict === 'OCUSYNC' ? 1 : (b.verdict === 'NOISE' ? 0.4 : 0.75); 433 ctx.fillRect(x, y, cellW+0.5, cellH-0.5); 434 ctx.globalAlpha = 1; 435 if (b.verdict === 'OCUSYNC') { 436 ctx.shadowColor = '#ef4444'; ctx.shadowBlur = 8; 437 ctx.fillRect(x, y, cellW+0.5, cellH-0.5); 438 ctx.shadowBlur = 0; 439 } 440 }); 441 }); 442 443 ctx.fillStyle = '#64748b'; ctx.font='9px monospace'; 444 bandOrder.forEach((bname, bi) => { 445 ctx.fillText(bname, 4, bi*cellH + cellH/2 + 3); 446 }); 447} 448 449// ========== Detection log ========== 450function renderDetectionLog(list) { 451 const root = document.getElementById('detectionLog'); 452 root.innerHTML = ''; 453 if (!list || !list.length) { 454 root.innerHTML = '<div class="text-slate-600 text-center py-6">No detections yet</div>'; 455 return; 456 } 457 list.forEach(d => { 458 const el = document.createElement('div'); 459 const cls = d.high_confidence ? 'border-red-500 bg-red-950/40' : 'border-amber-600 bg-amber-950/20'; 460 el.className = `border-l-2 ${cls} pl-2 py-1`; 461 el.innerHTML = ` 462 <div class="flex justify-between"> 463 <span class="text-amber-300 font-bold">#${d.id}</span> 464 <span class="text-slate-500">${d.time}</span> 465 </div> 466 <div class="text-slate-300 mt-0.5 truncate">Bands: <span class="text-red-400">${(d.bands||[]).join(', ')}</span></div> 467 <div class="flex gap-2 text-[10px] mt-0.5"> 468 ${d.dual_band ? '<span class="text-red-400">● DUAL</span>' : '<span class="text-slate-600">○ dual</span>'} 469 ${d.hopping ? '<span class="text-red-400">● HOP</span>' : '<span class="text-slate-600">○ hop</span>'} 470 ${d.high_confidence ? '<span class="text-red-400 font-bold">⚠ HIGH</span>' : ''} 471 </div> 472 `; 473 root.appendChild(el); 474 }); 475} 476 477// ========== Poll ========== 478async function poll() { 479 try { 480 const r = await fetch(API_URL, { cache: 'no-store' }); 481 if (!r.ok) throw new Error('HTTP ' + r.status); 482 const data = await r.json(); 483 apiConnected = true; 484 485 // Compute clock offset (server - client) 486 clockOffset = (data.now || 0) - (Date.now()/1000); 487 const now = data.now; 488 489 if (data.detection_count > prevDetectionCount && prevDetectionCount > 0) { 490 triggerFlash(); triggerShake(); alarmBurst(); 491 } 492 prevDetectionCount = data.detection_count; 493 lastDetectionTs = data.last_detection_ts || 0; 494 495 document.getElementById('scanCount').textContent = data.scan_count; 496 document.getElementById('detectionCount').textContent = data.detection_count; 497 document.getElementById('currentBand').textContent = data.current_band || '---'; 498 499 const db = document.getElementById('dualBandFlag'); 500 db.textContent = data.dual_band ? 'YES' : 'NO'; 501 db.className = 'text-2xl font-bold ' + (data.dual_band ? 'text-red-400 glow-r' : 'text-slate-500'); 502 const hop = document.getElementById('hoppingFlag'); 503 hop.textContent = data.hopping ? 'YES' : 'NO'; 504 hop.className = 'text-2xl font-bold ' + (data.hopping ? 'text-red-400 glow-r' : 'text-slate-500'); 505 506 updateBanner(data, now); 507 const bandOrder = (data.config && data.config.bands) || []; 508 if (data.last_scan && data.last_scan.length) { 509 renderBandGrid(data.last_scan, data.band_hits || {}, data.current_band, now); 510 renderSnrBars(data.last_scan, now); 511 } 512 renderHopTimeline(data.hop_timeline || [], bandOrder, now); 513 renderWaterfall(data.history || [], bandOrder); 514 renderDetectionLog(data.detections || []); 515 516 } catch (e) { 517 apiConnected = false; 518 document.getElementById('alertSubtitle').textContent = 'API disconnected: ' + e.message; 519 } 520} 521 522// Smooth decay animation (FIX: use serverNow() for consistent time base) 523function tick() { 524 if (lastDetectionTs > 0) { 525 const since = serverNow() - lastDetectionTs; 526 const el = document.getElementById('lastHitAgo'); 527 if (el && apiConnected) el.textContent = fmtAgo(since) + ' ago'; 528 } 529 requestAnimationFrame(tick); 530} 531tick(); 532 533poll(); 534setInterval(poll, POLL_MS); 535</script> 536</body> 537</html>
mini2_detect_server.py
1# mini2_server.py - subprocess-isolated scanner 2import numpy as np 3import time 4import json 5import threading 6import traceback 7import multiprocessing as mp 8import queue as _queue 9import os 10import signal 11from datetime import datetime 12from collections import deque 13from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 14 15# ========== Config ========== 16GAIN = 70 17SAMP_RATE = 25e6 18FFT_SIZE = 2048 19NUM_AVG = 16 20SAMPLES_PER_BAND = 65536 21HISTORY_LEN = 5 22SCORE_THRESHOLD = 5 23 24HTTP_PORT = 8765 25WATERFALL_ROWS = 120 26STICKY_WINDOW_SEC = 6 27MAX_DETECTIONS_LOG = 40 28HOP_TIMELINE_MAXLEN = 400 29 30scan_freqs = { 31 "2.400-2.425": 2.4125e9, "2.425-2.450": 2.4375e9, 32 "2.450-2.475": 2.4625e9, "2.475-2.500": 2.4875e9, 33 "5.725-5.750": 5.7375e9, "5.750-5.775": 5.7625e9, 34 "5.775-5.800": 5.7875e9, "5.800-5.825": 5.8125e9, 35 "5.825-5.850": 5.8375e9, 36} 37BAND_ORDER = list(scan_freqs.keys()) 38def band_group(b): return "2.4G" if b.startswith("2.") else "5.8G" 39 40# ============================================================ 41# ========== CHILD PROCESS: USRP scanner worker ============== 42# ============================================================ 43def scanner_worker(out_q): 44 """ 45 Runs in subprocess. Imports UHD here so the parent never touches it. 46 On any fatal error, simply exits — parent will respawn. 47 """ 48 try: 49 import uhd 50 except Exception as e: 51 out_q.put({"type": "fatal", "msg": f"import uhd failed: {e}"}) 52 return 53 54 # ----- feature extraction (unchanged from your original) ----- 55 def analyze_band(samples): 56 if samples is None: return None 57 psd_avg = np.zeros(FFT_SIZE); n_used = 0 58 for i in range(NUM_AVG): 59 chunk = samples[i*FFT_SIZE:(i+1)*FFT_SIZE] 60 if len(chunk) < FFT_SIZE: break 61 fft = np.fft.fftshift(np.fft.fft(chunk * np.hanning(FFT_SIZE))) 62 psd_avg += np.abs(fft) ** 2; n_used += 1 63 if n_used == 0: return None 64 psd_avg /= n_used 65 psd_db = 10 * np.log10(psd_avg / FFT_SIZE + 1e-20) 66 noise = float(np.median(psd_db)); peak = float(np.max(psd_db)) 67 snr = peak - noise 68 bin_width_hz = SAMP_RATE / FFT_SIZE 69 active_mask = psd_db > (noise + 6) 70 active_bw = float(np.sum(active_mask) * bin_width_hz / 1e6) 71 half_thr = noise + (peak - noise) * 0.5 72 main_mask = psd_db > half_thr 73 main_bw = float(np.sum(main_mask) * bin_width_hz / 1e6) 74 if np.sum(active_mask) > 10: 75 active_lin = 10 ** (psd_db[active_mask] / 10) 76 gm = np.exp(np.mean(np.log(active_lin + 1e-20))) 77 am = np.mean(active_lin); flatness = float(gm / am) 78 else: 79 flatness = 0.0 80 power_t = np.abs(samples) ** 2 81 thr = np.median(power_t) * 4 82 burst_ratio = float(np.mean(power_t > thr)) 83 edge_sharp = 99.0 84 if main_bw > 2: 85 idx = np.where(main_mask)[0]; left = idx[0]; l_end = left 86 while l_end > 0 and psd_db[l_end] > noise + 3: 87 l_end -= 1 88 edge_sharp = float((left - l_end) * bin_width_hz / 1e6) 89 return {'noise': noise, 'peak': peak, 'snr': snr, 90 'active_bw': active_bw, 'main_bw': main_bw, 91 'flatness': flatness, 'burst': burst_ratio, 'edge': edge_sharp} 92 93 def score_band(f): 94 if f is None or f['snr'] < 10: return 0, [], "noise" 95 score = 0; reasons = [] 96 if f['main_bw'] > 16 or f['active_bw'] > 18: 97 return -5, ["TOO_WIDE"], "wifi" 98 if 8 <= f['main_bw'] <= 12: score += 2; reasons.append("BW10") 99 elif 6 <= f['main_bw'] < 8 or 12 < f['main_bw'] <= 14: 100 score += 1; reasons.append("BW~") 101 if f['flatness'] > 0.60: score += 2; reasons.append("FLAT") 102 elif f['flatness'] > 0.45: score += 1; reasons.append("flat") 103 if 0.10 < f['burst'] < 0.60: score += 2; reasons.append("BURST") 104 elif f['burst'] >= 0.85: score -= 2; reasons.append("CONT") 105 if 0 < f['edge'] < 1.0: score += 1; reasons.append("SHARP") 106 if score >= SCORE_THRESHOLD: cls = "ocusync" 107 elif score >= 3: cls = "suspect" 108 else: cls = "other" 109 return score, reasons, cls 110 111 # ----- USRP init ----- 112 try: 113 usrp = uhd.usrp.MultiUSRP("type=b200") 114 usrp.set_rx_rate(SAMP_RATE) 115 usrp.set_rx_gain(GAIN, 0) 116 usrp.set_rx_antenna("RX2", 0) 117 st_args = uhd.usrp.StreamArgs("fc32", "sc16") 118 st_args.channels = [0] 119 streamer = usrp.get_rx_stream(st_args) 120 metadata = uhd.types.RXMetadata() 121 buf = np.zeros(streamer.get_max_num_samps(), dtype=np.complex64) 122 except Exception as e: 123 out_q.put({"type": "fatal", "msg": f"USRP init failed: {e}"}) 124 return 125 126 out_q.put({"type": "ready", "pid": os.getpid()}) 127 128 def capture_band(freq): 129 usrp.set_rx_freq(uhd.libpyuhd.types.tune_request(freq), 0) 130 time.sleep(0.15) 131 c = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont) 132 c.stream_now = True 133 streamer.issue_stream_cmd(c) 134 for _ in range(8): 135 streamer.recv(buf, metadata, 0.5) 136 samples = np.zeros(SAMPLES_PER_BAND, dtype=np.complex64) 137 got = 0 138 while got < SAMPLES_PER_BAND: 139 n = streamer.recv(buf, metadata, 1.0) 140 if n == 0: break 141 cp = min(n, SAMPLES_PER_BAND - got) 142 samples[got:got+cp] = buf[:cp]; got += cp 143 streamer.issue_stream_cmd( 144 uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont)) 145 t_end = time.time() + 0.15 146 while time.time() < t_end: 147 if streamer.recv(buf, metadata, 0.05) == 0: break 148 return samples if got >= FFT_SIZE * 2 else None 149 150 # ----- scan loop ----- 151 scan_count = 0 152 try: 153 while True: 154 scan_count += 1 155 out_q.put({"type": "scan_start", "scan_count": scan_count, 156 "time": datetime.now().strftime("%H:%M:%S")}) 157 results = [] 158 for band_name, freq in scan_freqs.items(): 159 out_q.put({"type": "scanning", "band": band_name}) 160 try: 161 samples = capture_band(freq) 162 feat = analyze_band(samples) 163 except Exception as e: 164 # non-fatal-from-python: let it fall through 165 print(f"[worker] capture {band_name} err: {e}", flush=True) 166 feat = None 167 168 score, reasons, cls = score_band(feat) 169 verdict_map = {"ocusync":"OCUSYNC","wifi":"WIFI", 170 "suspect":"NARROW","noise":"NOISE","other":"OTHER"} 171 if feat is None: 172 res = {"band": band_name, "group": band_group(band_name), 173 "snr": 0.0, "main_bw": 0.0, "active_bw": 0.0, 174 "flatness": 0.0, "burst": 0.0, "edge": 99.0, 175 "score": 0, "reasons": [], "class": "noise", 176 "verdict": "NOISE", "is_ocusync": False} 177 else: 178 res = {"band": band_name, "group": band_group(band_name), 179 "snr": feat["snr"], "main_bw": feat["main_bw"], 180 "active_bw": feat["active_bw"], 181 "flatness": feat["flatness"], 182 "burst": feat["burst"], "edge": feat["edge"], 183 "score": score, "reasons": reasons, 184 "class": cls, 185 "verdict": verdict_map.get(cls, "OTHER"), 186 "is_ocusync": (cls == "ocusync")} 187 results.append(res) 188 out_q.put({"type": "band_result", "result": res, 189 "ts": time.time()}) 190 out_q.put({"type": "scan_done", "results": results, 191 "ts": time.time()}) 192 except Exception as e: 193 out_q.put({"type": "fatal", "msg": f"worker exception: {e}\n" 194 f"{traceback.format_exc()}"}) 195 return 196 # If we fall out for any reason, parent will respawn. 197 198# ============================================================ 199# ========== PARENT PROCESS: HTTP + state + monitor ========== 200# ============================================================ 201_lock = threading.Lock() 202STATE = { 203 "scan_count": 0, "detection_count": 0, "current_band": "", 204 "dual_band": False, "hopping": False, "last_detection_ts": 0.0, 205 "last_scan": [], "band_hits": {}, 206 "hop_timeline": deque(maxlen=HOP_TIMELINE_MAXLEN), 207 "history": deque(maxlen=WATERFALL_ROWS), 208 "detections": deque(maxlen=MAX_DETECTIONS_LOG), 209 "usrp_status": "init", 210 "worker_restarts": 0, 211 "last_worker_error": "", 212} 213 214class APIHandler(BaseHTTPRequestHandler): 215 def log_message(self, *a, **kw): pass 216 def _cors(self): 217 self.send_header("Access-Control-Allow-Origin", "*") 218 self.send_header("Access-Control-Allow-Methods", "GET,OPTIONS") 219 self.send_header("Access-Control-Allow-Headers", "*") 220 def do_OPTIONS(self): 221 self.send_response(204); self._cors(); self.end_headers() 222 def do_GET(self): 223 try: 224 if self.path.startswith("/api/status"): 225 with _lock: 226 now = time.time() 227 bh = {k: dict(v) for k, v in STATE["band_hits"].items() 228 if now - v["last_hit_ts"] < STICKY_WINDOW_SEC} 229 hop = [dict(h) for h in STATE["hop_timeline"] 230 if now - h["ts"] <= 30] 231 payload = { 232 "now": now, 233 "scan_count": STATE["scan_count"], 234 "detection_count": STATE["detection_count"], 235 "current_band": STATE["current_band"], 236 "dual_band": STATE["dual_band"], 237 "hopping": STATE["hopping"], 238 "last_detection_ts": STATE["last_detection_ts"], 239 "last_scan": list(STATE["last_scan"]), 240 "band_hits": bh, 241 "hop_timeline": hop, 242 "history": list(STATE["history"]), 243 "detections": list(STATE["detections"]), 244 "usrp_status": STATE["usrp_status"], 245 "worker_restarts": STATE["worker_restarts"], 246 "last_worker_error": STATE["last_worker_error"], 247 "config": {"bands": BAND_ORDER, 248 "score_threshold": SCORE_THRESHOLD}, 249 } 250 body = json.dumps(payload, default=str).encode() 251 self.send_response(200); self._cors() 252 self.send_header("Content-Type", "application/json") 253 self.send_header("Content-Length", str(len(body))) 254 self.end_headers(); self.wfile.write(body) 255 else: 256 self.send_response(404); self._cors(); self.end_headers() 257 except Exception: 258 try: 259 self.send_response(500); self._cors(); self.end_headers() 260 self.wfile.write(b'{"error":"internal"}') 261 except Exception: pass 262 traceback.print_exc() 263 264def start_http_server(): 265 srv = ThreadingHTTPServer(("0.0.0.0", HTTP_PORT), APIHandler) 266 print(f"[HTTP] API: http://localhost:{HTTP_PORT}/api/status", flush=True) 267 srv.serve_forever() 268 269# ----- band history kept in parent, shared across worker restarts ----- 270band_history = {b: deque(maxlen=HISTORY_LEN) for b in scan_freqs} 271detection_count = 0 272 273def process_scan_done(results): 274 """Cross-scan verification — same logic as original.""" 275 global detection_count 276 277 hit_bands = [r["band"] for r in results if r.get("is_ocusync")] 278 for r in results: 279 band_history[r["band"]].append(bool(r.get("is_ocusync"))) 280 281 hopping_bands = [b for b, h in band_history.items() 282 if any(h) and not all(h) and len(h) >= 3] 283 persistent_bands = [b for b, h in band_history.items() 284 if len(h) >= 3 and all(h)] 285 strong_hit = len(hit_bands) >= 1 286 hop_confirmed = len(hopping_bands) >= 2 287 dual_band = (any(b.startswith("2.4") for b in hit_bands) and 288 any(b.startswith("5.") for b in hit_bands)) 289 confirmed = strong_hit and (hop_confirmed or dual_band) 290 291 print(f"hits={hit_bands} hop={hopping_bands} persist={persistent_bands}", 292 flush=True) 293 294 with _lock: 295 STATE["current_band"] = "" 296 STATE["dual_band"] = bool(dual_band) 297 STATE["hopping"] = bool(hop_confirmed) 298 STATE["last_scan"] = results 299 STATE["history"].append({"ts": time.time(), "results": results}) 300 if confirmed: 301 detection_count += 1 302 STATE["detection_count"] = detection_count 303 STATE["last_detection_ts"] = time.time() 304 STATE["detections"].appendleft({ 305 "id": detection_count, 306 "time": datetime.now().strftime("%H:%M:%S"), 307 "bands": list(hit_bands), 308 "dual_band": bool(dual_band), 309 "hopping": bool(hop_confirmed), 310 "high_confidence": bool(dual_band and hop_confirmed), 311 }) 312 print(f"*** DETECTION #{detection_count} " 313 f"hop={hop_confirmed} dual={dual_band} ***", flush=True) 314 315def process_band_result(band_name, res, ts): 316 """Update band_hits and hop_timeline on OCU hit.""" 317 if not res.get("is_ocusync"): return 318 snr = float(res.get("snr", 0)) 319 with _lock: 320 prev = STATE["band_hits"].get(band_name, {"hit_count": 0}) 321 STATE["band_hits"][band_name] = { 322 "last_hit_ts": ts, "last_snr": snr, 323 "hit_count": int(prev["hit_count"]) + 1, 324 } 325 STATE["hop_timeline"].append({ 326 "ts": ts, "band": band_name, 327 "group": band_group(band_name), "snr": snr, 328 }) 329 330def spawn_worker(): 331 q = mp.Queue(maxsize=200) 332 p = mp.Process(target=scanner_worker, args=(q,), daemon=True) 333 p.start() 334 print(f"[parent] spawned worker pid={p.pid}", flush=True) 335 return p, q 336 337def main(): 338 print("=" * 95) 339 print("DJI Mini 2 OcuSync Detector (isolated-subprocess edition)") 340 print(f"Gain={GAIN}dB BW={SAMP_RATE/1e6:.0f}MHz " 341 f"Bands={len(scan_freqs)} Threshold={SCORE_THRESHOLD}") 342 print("=" * 95, flush=True) 343 344 threading.Thread(target=start_http_server, daemon=True).start() 345 346 proc, q = spawn_worker() 347 with _lock: STATE["usrp_status"] = "starting" 348 349 shutting_down = False 350 def shutdown(*_): 351 nonlocal shutting_down 352 shutting_down = True 353 try: proc.terminate() 354 except Exception: pass 355 signal.signal(signal.SIGINT, shutdown) 356 signal.signal(signal.SIGTERM, shutdown) 357 358 backoff = 2 359 try: 360 while not shutting_down: 361 # Pull messages 362 try: 363 msg = q.get(timeout=0.5) 364 except _queue.Empty: 365 msg = None 366 367 if msg is not None: 368 mt = msg.get("type") 369 if mt == "ready": 370 with _lock: STATE["usrp_status"] = "ok" 371 backoff = 2 372 print(f"[parent] worker ready (pid={msg.get('pid')})", 373 flush=True) 374 elif mt == "scan_start": 375 with _lock: 376 STATE["scan_count"] = msg["scan_count"] 377 print(f"--- Scan #{msg['scan_count']} at {msg['time']} ---", 378 flush=True) 379 elif mt == "scanning": 380 with _lock: STATE["current_band"] = msg["band"] 381 elif mt == "band_result": 382 r = msg["result"] 383 process_band_result(r["band"], r, msg["ts"]) 384 # short live print 385 if r.get("verdict") == "OCUSYNC": 386 print(f" {r['band']:<14} OCUSYNC SNR={r['snr']:.1f} " 387 f"reasons={r.get('reasons')}", flush=True) 388 elif mt == "scan_done": 389 process_scan_done(msg["results"]) 390 elif mt == "fatal": 391 err = msg.get("msg", "") 392 with _lock: 393 STATE["usrp_status"] = "reconnecting" 394 STATE["last_worker_error"] = err[:300] 395 print(f"[parent] worker reported fatal: {err}", flush=True) 396 397 # Monitor worker liveness 398 if not proc.is_alive(): 399 code = proc.exitcode 400 print(f"[parent] worker died (exitcode={code}). " 401 f"Restarting in {backoff}s...", flush=True) 402 with _lock: 403 STATE["usrp_status"] = "reconnecting" 404 STATE["worker_restarts"] += 1 405 if not STATE["last_worker_error"]: 406 STATE["last_worker_error"] = f"process exit code {code}" 407 # drain remaining msgs 408 try: 409 while True: q.get_nowait() 410 except _queue.Empty: pass 411 try: proc.join(timeout=1) 412 except Exception: pass 413 time.sleep(backoff) 414 backoff = min(backoff * 2, 20) 415 proc, q = spawn_worker() 416 with _lock: STATE["usrp_status"] = "starting" 417 418 except KeyboardInterrupt: 419 pass 420 finally: 421 print("\nShutting down...", flush=True) 422 try: proc.terminate(); proc.join(timeout=3) 423 except Exception: pass 424 425if __name__ == "__main__": 426 # B210/UHD uses native threads + libusb that don't play well with fork() 427 mp.set_start_method("spawn", force=True) 428 main() 429
《树莓派4b + USRP B210 搭建反无人机(反无)系统( HTML + CDN )》 是转载文章,点击查看原文。