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

作者:MC数据局日期:2026/4/23

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✅ 可做双天线测向(干涉/相位差)
ADC12-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-droneGNU 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/s170 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 必备硬件清单

设备要求注意事项
树莓派 4B8GB 版本强烈推荐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 permissionsudev 规则没加见 3.1 节最后一步
一直打印 O (overflow)采样率过高 / 线材差 / Pi 过热降到 5 MHz;换短 USB3 线;加风扇
LIBUSB_ERROR_IOUSB 供电抖动换官方电源;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  &nbsp;
102        <span class="text-purple-400"></span> 5.8G  &nbsp;
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 )》 是转载文章,点击查看原文


相关推荐


Flink技术实践-FlinkSQL Join技术全解
大大大大晴天️2026/4/15

一、背景介绍 在离线批处理场景中,编写一个 Join SQL 是再平常不过的操作——两张有限的数据集,在某个键上关联,输出结果。但当你把这套 SQL 语义移植到实时流处理场景时,一切都变了。 特性批处理 Join流处理 Join数据特征有限、静态、全量数据集无限、动态、无界数据流执行模式一次性全量匹配,结果固定持续计算,结果随新数据实时更新状态管理无需长期状态,计算完成即释放必须维护历史状态以匹配未来数据时间维度无时间概念,基于完整数据集强依赖事件时间 / 处理时间处理乱序与延迟计算成本可预


当代码不再为人而写:Claude Code 零注释背后的 Harness 逻辑
mCell2026/4/7

前几天 Claude Code 因为 sourcemap 没关,导致源码被公开。这件事在技术圈引起的讨论密度很高,因为这种真正跑在生产环境里的闭源通用 Agent 产品,它的内部实现本身就是一份高价值的学习材料。 我看了一些解析文章。有讲它设计模式的,有分析它安全边界的,也有拆解 Prompt 架构的。 但有一个细节我反复确认了一下: Claude Code 内部要求,不要写任何注释。 第一反应是反直觉。 注释难道不是为了理解代码吗?我从写代码以来接受的教育就是:复杂逻辑要写注释,接口参数要写注


C# 基于OpenCv的视觉工作流-章43-轮廓匹配
sali-tec2026/3/29

C# 基于OpenCv的视觉工作流-章43-轮廓匹配 本章目标: 一、匹配原理; 二、模板创建; 三、模板匹配; 本章与章41模板匹配基本相似,在章42基础上,先对图像进行边缘检测,提取轮廓,以轮廓制作模板,匹配时也先对原图进行边缘检测,提取轮廓,最后再进行匹配。整体不同处在于先对图像进行预处理,好处在于匹配适应性更高,对光线明暗不同的图像也能进行更好的匹配。 一、匹配原理 章41已介绍,不再详述; 二、模板创建 边缘检测、轮廓提取在前文章节已介绍,不再详述; 三、模板匹配 参考章42;


OpenCodeUI 让你随时随地 AI Coding
三金得鑫2026/3/21

Hi,大家好,我是三金~ 自从用了 OpenCode + OMO 之后,写起代码来如沐春风,特别得劲!(除了比较烧 token) 但是 TUI 用久了之后吧,又有了一点别的想法: 能不能远程链接?让我随时随地都能 AI Coding。 Web 界面要“看着顺眼、点起来顺手” 所以当我在 L 站看到有佬友开源 OpenCodeUI 的时候,第一反应就是:许愿许成功了? OpenCodeUI 是 OpenCode 的第三方 Web 前端界面。它和 OpenCode 的客户端有点像,整体风格偏简约


电商企微机器人:从自动欢迎语到订单转化,打造私域闭环
2501_941982052026/3/13

能力介绍 电商私域机器人不仅是客服工具,更是 24 小时在线的虚拟导购。通过 API 联动电商平台的商品库与促销引擎,机器人可以根据用户的咨询轨迹自动发送商品卡片、优惠券及限时秒杀信息。它支持精准的关键词触发与定时任务,帮助企业在不增加人工成本的前提下,提升私域社群的活跃度与复购率。 10分钟接入 Demo 首句自动响应:配置好友申请回调,用户通过后秒级发送包含“新人礼包”的欢迎语。 关键词转单:设置机器人监控特定关键词(如“怎么买”、“多少钱”),自动回复带参数的商品小程序路径。


redis stream用作消息队列极速入门
ChesterZhang2026/3/5

背景 最近做了几个需求都用了redis stream用作消息队列,感觉redis stream相当大轻量化,易于上手,且功能强大,为此特意实现了了一个极简但实用的 redis stream 的示例 redis stream 的三个概念 stream, consumer group , consumer 要想学会如何使用 redis stream, 最重要的就是理解 stream, consumer group , consumer 三者的关系。 简单来说: stream 为消息流, 类似于传


React Native 开发环境准备
zh_xuan2026/2/24

一、环境准备 我的环境: 二、建立独立RN工程 1、初始化创建工程 npx react-native init RNApp --version 0.73.4 --skip-install 这个命令提示: ��️ The `init` command is deprecated. E:\android\projects\RNDemo4>cd RNApp - Switch to npx @react-native-community/cli init f


【C++】模拟实现 红黑树(RBTree)
yuuki2332332026/2/16

前言: 在掌握 AVL 树的严格平衡机制后,我们发现其虽能将树高严格控制在 O(logN),但「高度差≤1」的强约束也带来了明显代价:插入 / 删除操作中频繁的旋转(最多两次双旋)大幅增加了写操作的开销,且每个节点需额外存储平衡因子和父指针,空间利用率较低。 为解决这一问题,红黑树(Red-Black Tree)作为一种近似平衡的二叉搜索树应运而生 —— 它放弃了 AVL 树 “严格平衡” 的要求,转而通过「节点颜色标记 + 5 条核心规则」实现 “黑高一致” 的弱平衡,将任意根到叶子的路径


Git常用操作指令
stu_kk2026/2/7

最近给公司小伙伴安排了一下git培训,写了个常用指令,记录一下 一、配置与初始化(首次使用/新建仓库) 指令 功能说明 git config --global user.name "你的姓名" 配置全局用户名(会显示在提交记录中) git config --global user.email "你的公司邮箱" 配置全局用户邮箱 `git config --list 查看配置


Prometheus+Grafana构建云原生分布式监控系统(十)_prometheus的服务发现机制(一)
牛奶咖啡132026/1/29

Prometheus+Grafana构建云原生分布式监控系统(九)_pushgateway的使用https://blog.csdn.net/xiaochenXIHUA/article/details/157392956 一、prometheus的服务发现机制  1.1、prometheus的服务发现机制概述         prometheus是基于拉(pull)模式抓取监控数据,首先要能够发现需要监控的目标对象target,那么prometheus如何获监控目标呢?有两种方式【静态手动配

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 XYZ博客