This commit is contained in:
2026-09-13 13:30:21 +08:00
commit a6bbd520cf
2744 changed files with 1598795 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
#include "capture/capture.h"
#include <stdexcept>
#include "capture/udp_capture.h"
#if defined(FPC_HAVE_WFB_CAPTURE) && !defined(_WIN32)
#include "capture/wfb_capture.h"
#endif
#if defined(FPC_HAVE_WFB_USB)
#include "capture/wfb_usb_capture.h"
#endif
namespace fpv {
CapturePtr createCapture(const CaptureConfig& cfg, Stream& stream)
{
switch (cfg.type) {
case CaptureType::Udp:
return std::make_unique<UdpCapture>(cfg, stream);
case CaptureType::Wfb:
#if defined(FPC_HAVE_WFB_USB)
if (cfg.usbPid != 0) {
return std::make_unique<WfbUsbCapture>(cfg, stream);
}
#endif
#if defined(FPC_HAVE_WFB_CAPTURE) && !defined(_WIN32)
return std::make_unique<WfbCapture>(cfg, stream);
#elif defined(FPC_HAVE_WFB_USB)
throw std::runtime_error("wfb 采集需要 usb_pid(USB) 或 iface(pcap)");
#else
throw std::runtime_error("当前平台不支持 wfb 采集,请使用 udp 类型或改用 Linux");
#endif
}
throw std::runtime_error("未知采集类型");
}
} // namespace fpv
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <memory>
#include "config/config.h"
#include "status/status.h"
namespace fpv {
class Stream;
// 采集器接口。
// 不同采集方式(wfb-ng 无线采集 / UDP 输入)实现同一接口,向上层屏蔽差异,
// 便于后续增加更多采集来源或做图形化。
class Capture {
public:
virtual ~Capture() = default;
// 启动采集,内部通常创建后台线程。
virtual void start() = 0;
// 停止采集并回收资源。
virtual void stop() = 0;
// 返回当前状态快照(供日志/HTTP 状态页使用)。
virtual StreamStatus status() const = 0;
};
using CapturePtr = std::unique_ptr<Capture>;
// 根据配置创建采集器。stream 用于接收解密后的 RTP。
CapturePtr createCapture(const CaptureConfig& cfg, Stream& stream);
} // namespace fpv
+105
View File
@@ -0,0 +1,105 @@
#include "capture/radio.h"
#include <spdlog/spdlog.h>
#ifndef _WIN32
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <string>
#include <vector>
namespace fpv {
namespace {
// 执行一条外部命令,返回其退出码;启动失败返回 -1。
// 直接 fork/execvp,不经过 shell,避免命令注入。
int runCmd(const std::vector<std::string>& args)
{
std::vector<char*> argv;
argv.reserve(args.size() + 1);
for (const auto& a : args) {
argv.push_back(const_cast<char*>(a.c_str()));
}
argv.push_back(nullptr);
const pid_t pid = fork();
if (pid < 0) {
return -1;
}
if (pid == 0) {
// 子进程:静默执行
freopen("/dev/null", "w", stdout);
freopen("/dev/null", "w", stderr);
execvp(argv[0], argv.data());
_exit(127);
}
int status = 0;
if (waitpid(pid, &status, 0) < 0) {
return -1;
}
if (WIFEXITED(status)) {
return WEXITSTATUS(status);
}
return -1;
}
// 执行命令并把失败记录下来(部分命令允许失败,如 nmcli)。
bool runOptional(const std::vector<std::string>& args, const std::string& what)
{
if (runCmd(args) == 0) {
return true;
}
spdlog::debug("{} 执行未成功(可忽略): {}", what, args.empty() ? "" : args[0]);
return false;
}
} // namespace
bool setupRadio(const CaptureConfig& cfg)
{
const std::string& iface = cfg.iface;
runOptional({"nmcli", "dev", "set", iface, "managed", "no"}, "nmcli");
if (!cfg.region.empty()) {
runOptional({"iw", "reg", "set", cfg.region}, "iw reg");
}
runOptional({"ip", "link", "set", iface, "down"}, "ip down");
if (runCmd({"iw", "dev", iface, "set", "type", "monitor"}) != 0) {
spdlog::error("[{}] 设置 monitor 模式失败", cfg.name);
return false;
}
runOptional({"ip", "link", "set", iface, "up"}, "ip up");
const std::string ch = std::to_string(cfg.channel);
if (runCmd({"iw", "dev", iface, "set", "channel", ch, cfg.bandwidth}) != 0) {
spdlog::error("[{}] 锁定信道 {} {} 失败", cfg.name, cfg.channel, cfg.bandwidth);
return false;
}
spdlog::info("[{}] 网卡 {} 已进入 monitor,信道 {} {}", cfg.name, iface, cfg.channel,
cfg.bandwidth);
return true;
}
} // namespace fpv
#else // _WIN32
namespace fpv {
bool setupRadio(const CaptureConfig& cfg)
{
(void)cfg;
spdlog::warn("Windows 暂不支持 wfb 无线采集");
return false;
}
} // namespace fpv
#endif
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "config/config.h"
namespace fpv {
// 把接收网卡设置为 monitor 模式并锁定到指定信道。
// 目前仅 Linux 可用;成功返回 true。Windows 下返回 false。
bool setupRadio(const CaptureConfig& cfg);
} // namespace fpv
+108
View File
@@ -0,0 +1,108 @@
#pragma once
// 本文件把 wfb-ng 的解密聚合器接入我们的转发流。
// 通过继承 wfb-ng 的 Aggregator,在它解出每个视频 RTP 包后直接回调 send_to_socket,
// 从而省去“外部 wfb_rx 进程 + 本地 UDP 回环”的一跳,降低延迟。
#include <atomic>
#include <cstdint>
#include <spdlog/spdlog.h>
#include "relay/stream.h"
#include "rx.hpp"
namespace fpv {
// wfb-ng 聚合器适配器。
// 解密后的载荷直接交给 Stream 扇出;同时把内部计数快照到原子变量,供状态监控读取。
class RelayAggregator : public Aggregator {
public:
RelayAggregator(Stream& stream, const std::string& keypair, std::uint64_t epoch,
std::uint32_t channelId)
: Aggregator(keypair, epoch, channelId), stream_(stream)
{
}
// 处理每个收到的 wfb 包:调用基类完成 FEC/解密,然后刷新状态快照。
void process_packet(const std::uint8_t* buf, std::size_t size, std::uint8_t wlanIdx,
const std::uint8_t* antenna, const std::int8_t* rssi,
const std::int8_t* noise, std::uint16_t freq, std::uint8_t mcsIndex,
std::uint8_t bandwidth, sockaddr_in* sockaddr) override
{
Aggregator::process_packet(buf, size, wlanIdx, antenna, rssi, noise, freq, mcsIndex,
bandwidth, sockaddr);
// 基类计数会被 dump_stats 周期性清零,这里按增量累加到自己的累计值
accumulate(all_, count_p_all, lastAll_);
accumulate(data_, count_p_data, lastData_);
accumulate(session_, count_p_session, lastSession_);
accumulate(decErr_, count_p_dec_err, lastDecErr_);
accumulate(lost_, count_p_lost, lastLost_);
accumulate(outgoing_, count_p_outgoing, lastOut_);
// 记录当前最强天线信号(用于状态显示)
int8_t best = SCHAR_MIN;
for (int i = 0; i < RX_ANT_MAX; ++i) {
if (rssi[i] != SCHAR_MIN && rssi[i] > best) {
best = rssi[i];
}
}
if (best != SCHAR_MIN) {
rssi_.store(best, std::memory_order_relaxed);
}
}
// 周期性统计,输出到日志并清空基类计数,避免刷屏。
void dump_stats() override
{
spdlog::info("[{}] 收包={} 数据={} 会话={} 解密错误={} 丢包={} 转发={}",
stream_.name(), count_p_all, count_p_data, count_p_session,
count_p_dec_err, count_p_lost, count_p_outgoing);
clear_stats();
}
std::uint64_t statAll() const { return all_.load(std::memory_order_relaxed); }
std::uint64_t statData() const { return data_.load(std::memory_order_relaxed); }
std::uint64_t statSession() const { return session_.load(std::memory_order_relaxed); }
std::uint64_t statDecErr() const { return decErr_.load(std::memory_order_relaxed); }
std::uint64_t statLost() const { return lost_.load(std::memory_order_relaxed); }
std::uint64_t statOutgoing() const { return outgoing_.load(std::memory_order_relaxed); }
int statRssi() const { return rssi_.load(std::memory_order_relaxed); }
protected:
// wfb-ng 解出的视频 RTP 包回调入口。
void send_to_socket(const std::uint8_t* payload, std::uint16_t packetSize) override
{
stream_.onRtp(payload, packetSize);
}
private:
// 把基类计数器(会被清零)的增量累加到累计值。
static void accumulate(std::atomic<std::uint64_t>& total, std::uint32_t cur,
std::uint32_t& last)
{
if (cur >= last) {
total.fetch_add(cur - last, std::memory_order_relaxed);
} else {
total.fetch_add(cur, std::memory_order_relaxed); // 检测到清零,直接累加
}
last = cur;
}
Stream& stream_;
std::atomic<std::uint64_t> all_ {0};
std::atomic<std::uint64_t> data_ {0};
std::atomic<std::uint64_t> session_ {0};
std::atomic<std::uint64_t> decErr_ {0};
std::atomic<std::uint64_t> lost_ {0};
std::atomic<std::uint64_t> outgoing_ {0};
std::atomic<int> rssi_ {0};
std::uint32_t lastAll_ = 0;
std::uint32_t lastData_ = 0;
std::uint32_t lastSession_ = 0;
std::uint32_t lastDecErr_ = 0;
std::uint32_t lastLost_ = 0;
std::uint32_t lastOut_ = 0;
};
} // namespace fpv
+105
View File
@@ -0,0 +1,105 @@
#include "capture/udp_capture.h"
#include <cstdint>
#include <cstring>
#include <stdexcept>
#include <string>
#include <vector>
#include <spdlog/spdlog.h>
#include "common/net_compat.h"
#include "relay/stream.h"
namespace fpv {
UdpCapture::UdpCapture(const CaptureConfig& cfg, Stream& stream) : cfg_(cfg), stream_(stream) {}
UdpCapture::~UdpCapture()
{
stop();
}
void UdpCapture::start()
{
const std::size_t pos = cfg_.listen.find_last_of(':');
if (pos == std::string::npos) {
throw std::runtime_error("udp.listen 格式应为 ip:port: " + cfg_.listen);
}
const std::string host = cfg_.listen.substr(0, pos);
const std::string port = cfg_.listen.substr(pos + 1);
addrinfo hints {};
// 用 IPv4:Windows 默认 IPV6_V6ONLY=1,绑 "::" 会导致 IPv4 收不到。
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_DGRAM;
hints.ai_flags = AI_PASSIVE;
addrinfo* res = nullptr;
const char* hostPtr = host.empty() ? nullptr : host.c_str();
if (getaddrinfo(hostPtr, port.c_str(), &hints, &res) != 0 || res == nullptr) {
throw std::runtime_error("解析 udp.listen 失败: " + cfg_.listen);
}
fd_ = static_cast<int>(::socket(res->ai_family, SOCK_DGRAM, 0));
if (fd_ < 0) {
freeaddrinfo(res);
throw std::runtime_error("创建 UDP socket 失败: " + cfg_.listen);
}
if (::bind(fd_, res->ai_addr, static_cast<socklen_t>(res->ai_addrlen)) < 0) {
freeaddrinfo(res);
throw std::runtime_error("绑定 UDP 端口失败: " + cfg_.listen);
}
freeaddrinfo(res);
running_ = true;
thread_ = std::thread([this] { run(); });
spdlog::info("[{}] udp 采集启动: listen={}", cfg_.name, cfg_.listen);
}
void UdpCapture::run()
{
std::vector<std::uint8_t> buf(65535);
while (running_.load()) {
pollfd pfd {};
pfd.fd = static_cast<decltype(pfd.fd)>(fd_);
pfd.events = POLLIN;
const int rc = netPoll(&pfd, 1, 200);
if (rc <= 0) {
continue;
}
const auto n = ::recv(fd_, reinterpret_cast<char*>(buf.data()),
static_cast<int>(buf.size()), 0);
if (n <= 0) {
if (!running_.load()) break;
continue;
}
stream_.onRtp(buf.data(), static_cast<std::size_t>(n));
}
}
void UdpCapture::stop()
{
if (!running_.exchange(false)) {
if (thread_.joinable()) thread_.join();
return;
}
netClose(fd_);
fd_ = -1;
if (thread_.joinable()) {
thread_.join();
}
}
StreamStatus UdpCapture::status() const
{
StreamStatus s;
s.name = cfg_.name;
s.codec = cfg_.codec;
s.online = running_.load();
s.recv = stream_.recvCount();
s.forwarded = stream_.forwardCount();
s.recording = stream_.recording();
return s;
}
} // namespace fpv
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <atomic>
#include <thread>
#include "capture/capture.h"
namespace fpv {
class Stream;
// UDP 采集器。
// 从本地或远端 UDP 端口读取已经解好的 RTP,用于跨平台转发与测试。
class UdpCapture : public Capture {
public:
UdpCapture(const CaptureConfig& cfg, Stream& stream);
~UdpCapture() override;
void start() override;
void stop() override;
StreamStatus status() const override;
private:
void run();
CaptureConfig cfg_;
Stream& stream_;
int fd_ = -1;
std::thread thread_;
std::atomic<bool> running_ {false};
};
} // namespace fpv
+189
View File
@@ -0,0 +1,189 @@
#include "capture/wfb_capture.h"
#include <algorithm>
#include <cerrno>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <exception>
#include <string>
#include <vector>
#include <poll.h>
#include <spdlog/spdlog.h>
#include "capture/radio.h"
namespace fpv {
WfbCapture::WfbCapture(const CaptureConfig& cfg, Stream& stream) : cfg_(cfg), stream_(stream) {}
WfbCapture::~WfbCapture()
{
stop();
}
std::uint32_t WfbCapture::channelId() const
{
return (cfg_.linkId << 8) + cfg_.radioPort;
}
void WfbCapture::start()
{
mavStarted_ = false; // 允许热重载后重新启动遥测线程
running_ = true;
thread_ = std::thread([this] { run(); });
}
StreamStatus WfbCapture::status() const
{
StreamStatus s;
s.name = cfg_.name;
s.codec = cfg_.codec;
s.recv = stream_.recvCount();
s.forwarded = stream_.forwardCount();
s.recording = stream_.recording();
if (auto* relay = relay_.load()) {
s.all = relay->statAll();
s.data = relay->statData();
s.session = relay->statSession();
s.decErr = relay->statDecErr();
s.lost = relay->statLost();
s.rssi = relay->statRssi();
s.online = s.session > 0;
}
return s;
}
// 采集轮询循环:带停止检查,可被 stop() 及时打断。
void WfbCapture::captureLoop(Receiver& rx, BaseAggregator* agg, int logIntervalMs)
{
std::uint64_t logTs = get_time_ms();
while (running_.load()) {
std::uint64_t now = get_time_ms();
const std::uint64_t flushTs = agg->get_flush_ts();
const std::uint64_t deadline = std::min(logTs, flushTs);
pollfd pfd {};
pfd.fd = rx.getfd();
pfd.events = POLLIN;
// 超时上限 200ms,保证 stop() 能快速打断
std::uint64_t wait = (deadline > now) ? (deadline - now) : 0;
if (wait > 200) {
wait = 200;
}
const int rc = ::poll(&pfd, 1, static_cast<int>(wait));
if (rc < 0) {
if (errno == EINTR || errno == EAGAIN) {
continue;
}
throw std::runtime_error("poll error");
}
now = get_time_ms();
if (now >= logTs) {
agg->dump_stats();
logTs = now + logIntervalMs;
}
if (now >= flushTs) {
agg->flush();
}
if (rc > 0) {
if (pfd.revents & (POLLERR | POLLNVAL)) {
throw std::runtime_error("socket error");
}
if (pfd.revents & POLLIN) {
rx.loop_iter();
}
}
}
}
void WfbCapture::run()
{
// 缺卡/驱动未就绪时自动重试,最多 5 次后停止(避免无限重试刷屏)。
int fails = 0;
while (running_.load() && fails < 5) {
if (!setupRadio(cfg_)) {
++fails;
if (fails >= 5) {
spdlog::error("[{}] 网卡 {} 连续 {} 次未就绪,停止重试", cfg_.name, cfg_.iface,
fails);
break;
}
spdlog::warn("[{}] 网卡 {} 未就绪,2 秒后重试({}/5)", cfg_.name, cfg_.iface, fails);
std::this_thread::sleep_for(std::chrono::seconds(2));
continue;
}
std::unique_ptr<BaseAggregator> agg =
std::make_unique<RelayAggregator>(stream_, cfg_.key, cfg_.epoch, channelId());
relay_.store(static_cast<RelayAggregator*>(agg.get()));
// 网卡就绪后再启动遥测下行线程,避免与主采集竞争配置网卡
if (cfg_.mavlink.enabled && !mavStarted_.exchange(true)) {
mavThread_ = std::thread([this] { runMavlink(); });
}
try {
Receiver rx(cfg_.iface.c_str(), 0, channelId(), agg.get(), 4 * 1024 * 1024);
spdlog::info("[{}] wfb 采集启动: iface={} channel_id=0x{:08x}", cfg_.name, cfg_.iface,
channelId());
captureLoop(rx, agg.get(), 5000);
relay_.store(nullptr);
return; // 正常停止
} catch (const std::exception& e) {
++fails;
if (fails >= 5) {
spdlog::error("[{}] wfb 采集连续 {} 次异常,停止重试: {}", cfg_.name, fails,
e.what());
break;
}
spdlog::error("[{}] wfb 采集异常: {},2 秒后重试({}/5)", cfg_.name, e.what(), fails);
}
relay_.store(nullptr);
if (running_.load()) {
std::this_thread::sleep_for(std::chrono::seconds(2));
}
}
}
void WfbCapture::runMavlink()
{
// 解析转发目标 ip:port
std::string ip = "127.0.0.1";
int port = 14550;
const std::size_t pos = cfg_.mavlink.target.find_last_of(':');
if (pos != std::string::npos) {
ip = cfg_.mavlink.target.substr(0, pos);
port = std::atoi(cfg_.mavlink.target.substr(pos + 1).c_str());
}
const std::uint32_t chId = (cfg_.linkId << 8) + cfg_.mavlink.radioPort;
while (running_.load()) {
try {
std::unique_ptr<BaseAggregator> agg =
std::make_unique<AggregatorUDPv4>(ip, port, cfg_.key, cfg_.epoch, chId, 0);
Receiver rx(cfg_.iface.c_str(), 0, chId, agg.get(), 4 * 1024 * 1024);
spdlog::info("[{}] 遥测下行启动: radio_port=0x{:02x} -> {}:{}", cfg_.name,
cfg_.mavlink.radioPort, ip, port);
captureLoop(rx, agg.get(), 5000);
return;
} catch (const std::exception& e) {
spdlog::error("[{}] 遥测下行异常: {},2 秒后重试", cfg_.name, e.what());
std::this_thread::sleep_for(std::chrono::seconds(2));
}
}
}
void WfbCapture::stop()
{
running_ = false;
if (thread_.joinable()) {
thread_.join();
}
if (mavThread_.joinable()) {
mavThread_.join();
}
}
} // namespace fpv
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <atomic>
#include <memory>
#include <thread>
#include "capture/capture.h"
#include "capture/relay_aggregator.h"
namespace fpv {
class Stream;
// wfb-ng 无线采集器(仅 Linux)。
// 复用 wfb-ng 的 radio_loop 在一个独立线程里收包,解密后的 RTP 由 RelayAggregator
// 直接回调到 Stream,全程无进程间通信。
class WfbCapture : public Capture {
public:
WfbCapture(const CaptureConfig& cfg, Stream& stream);
~WfbCapture() override;
void start() override;
void stop() override;
StreamStatus status() const override;
private:
void run();
void runMavlink();
// 采集轮询循环(可被 running_ 停止),供视频与遥测复用
void captureLoop(Receiver& rx, BaseAggregator* agg, int logIntervalMs);
// (link_id << 8) + radio_port,与天空端一致
std::uint32_t channelId() const;
CaptureConfig cfg_;
Stream& stream_;
std::atomic<RelayAggregator*> relay_ {nullptr}; // 指向当前聚合器,便于读取统计
std::thread thread_;
std::thread mavThread_;
std::atomic<bool> mavStarted_ {false};
std::atomic<bool> running_ {false};
};
} // namespace fpv
+206
View File
@@ -0,0 +1,206 @@
#include "capture/wfb_uplink.h"
#include <string>
#include <spdlog/spdlog.h>
// ============================================================================
// USB(devourer) 版:RTL8812AU 用户态原始注入
// ============================================================================
#if defined(FPC_HAVE_WFB_USB)
#include <exception>
#include "capture/wfb_usb_device.h"
#include "capture/wfb_usb_tx.h"
namespace fpv {
WfbUplink::WfbUplink(const UplinkConfig& cfg) : cfg_(cfg) {}
WfbUplink::~WfbUplink()
{
stop();
}
void WfbUplink::start()
{
UsbWfbParams p;
p.vid = cfg_.usbVid;
p.pid = cfg_.usbPid;
p.channel = static_cast<std::uint8_t>(cfg_.channel);
p.bandwidth = cfg_.bandwidth;
p.key = cfg_.key;
p.region = cfg_.region;
try {
dev_ = acquireUsbWfbDevice(p);
} catch (const std::exception& e) {
spdlog::error("[uplink] USB 上行打开设备失败: {}", e.what());
return;
}
running_ = true;
thread_ = std::thread([this] { run(); });
}
void WfbUplink::run()
{
if (!dev_ || !dev_->ensureInitWrite() || dev_->radio() == nullptr) {
spdlog::error("[uplink] USB 上行初始化失败");
running_ = false;
return;
}
UsbUplinkParams up;
up.radio = dev_->radio();
up.radioMutex = &dev_->mutex();
up.key = cfg_.key;
up.linkId = cfg_.linkId;
up.radioPort = cfg_.radioPort;
up.epoch = cfg_.epoch;
up.mcsIndex = cfg_.mcsIndex;
up.bandwidth = cfg_.bandwidth;
up.fecK = cfg_.fecK;
up.fecN = cfg_.fecN;
up.udpPort = cfg_.udpPort;
runUsbUplink(up, running_);
running_ = false;
}
void WfbUplink::stop()
{
running_ = false;
if (thread_.joinable()) {
thread_.join();
}
dev_.reset();
}
} // namespace fpv
// ============================================================================
// Linux pcap 版:内嵌 wfb-ng 的 wfb_tx(AF_PACKET)
// ============================================================================
#elif !defined(_WIN32)
#include <exception>
#include <stdexcept>
#include <vector>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <net/if.h>
#include <linux/if_ether.h>
#include <linux/if_packet.h>
#include "capture/radio.h"
// wfb-ng 的 tx.hpp 依赖这些前置声明/头文件
using namespace std;
#include "wifibroadcast.hpp"
#include "zfex.h"
#include "tx.hpp"
// wfb-ng 提供的本地 UDP→注入 主循环(已用 FPC_WFB_TX_NO_MAIN 去掉其自带 main)。
extern void local_loop_udp(int argc, char* const* argv, int optind, int rcv_buf, int log_interval,
int udp_port, int debug_port, int k, int n, const std::string& keypair,
int fec_timeout, std::uint64_t epoch, std::uint32_t channel_id,
std::uint32_t fec_delay, bool use_qdisc, std::uint32_t fwmark,
radiotap_header_t& radiotap_header, std::uint8_t frame_type,
int control_port, bool mirror, int snd_buf_size,
std::uint32_t inject_retries, std::uint32_t inject_retry_delay);
// wfb_tx 的全局停止标志
extern std::atomic<bool> g_wfb_tx_stop;
namespace fpv {
WfbUplink::WfbUplink(const UplinkConfig& cfg) : cfg_(cfg) {}
WfbUplink::~WfbUplink()
{
stop();
}
void WfbUplink::start()
{
// 复用射频设置:把网卡切到 monitor 并锁到与下行相同的信道
CaptureConfig rc;
rc.name = "uplink";
rc.iface = cfg_.iface;
rc.channel = cfg_.channel;
rc.region = cfg_.region;
rc.bandwidth = (cfg_.bandwidth == 40) ? "HT40+" : "HT20";
setupRadio(rc);
running_ = true;
thread_ = std::thread([this] { run(); });
}
void WfbUplink::run()
{
try {
std::string prog = "fpv-relay";
std::string iface = cfg_.iface;
char* argv[] = {prog.data(), iface.data(), nullptr};
radiotap_header_t rt = init_radiotap_header(0, false, false,
static_cast<std::uint8_t>(cfg_.bandwidth),
static_cast<std::uint8_t>(cfg_.mcsIndex),
false, 1);
const std::uint32_t channelId = (cfg_.linkId << 8) + cfg_.radioPort;
spdlog::info("[uplink] 启动: iface={} channel_id=0x{:08x} udp={} mcs={} fec={}/{}",
cfg_.iface, channelId, cfg_.udpPort, cfg_.mcsIndex, cfg_.fecK, cfg_.fecN);
g_wfb_tx_stop = false;
local_loop_udp(2, argv, 1, 0, 5000, cfg_.udpPort, 0, cfg_.fecK, cfg_.fecN, cfg_.key, 0,
cfg_.epoch, channelId, cfg_.fecDelay, false, 0, rt, 0x08, 0, false, 0, 0, 0);
} catch (const std::exception& e) {
spdlog::error("[uplink] 异常: {}", e.what());
}
running_ = false;
}
void WfbUplink::stop()
{
g_wfb_tx_stop = true;
running_ = false;
if (thread_.joinable()) {
thread_.join();
}
}
} // namespace fpv
// ============================================================================
// 其它(Windows 未启用 USB):空实现
// ============================================================================
#else
namespace fpv {
WfbUplink::WfbUplink(const UplinkConfig& cfg) : cfg_(cfg) {}
WfbUplink::~WfbUplink()
{
stop();
}
void WfbUplink::start()
{
spdlog::warn("[uplink] 当前平台不支持 wfb 上行,已忽略 (iface={})", cfg_.iface);
running_ = false;
}
void WfbUplink::stop()
{
running_ = false;
if (thread_.joinable()) {
thread_.join();
}
}
} // namespace fpv
#endif
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <atomic>
#include <memory>
#include <thread>
#include "config/config.h"
namespace fpv {
class UsbWfbDevice;
// 上行链路:把本机收到的 mavlink/遥控(UDP)发往天空端。
// - Linux pcap 版:内嵌 wfb-ng 的 local_loop_udp 经 monitor 网卡注入;
// - USB(devourer) 版:FEC+加密后经 RTL8812AU 原始注入(与采集共享同一句柄)。
class WfbUplink {
public:
explicit WfbUplink(const UplinkConfig& cfg);
~WfbUplink();
void start();
void stop();
bool running() const { return running_.load(); }
const UplinkConfig& config() const { return cfg_; }
private:
void run();
UplinkConfig cfg_;
std::shared_ptr<UsbWfbDevice> dev_;
std::thread thread_;
std::atomic<bool> running_ {false};
};
} // namespace fpv
+178
View File
@@ -0,0 +1,178 @@
#include "capture/wfb_usb_capture.h"
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <stdexcept>
#include <string>
#include <spdlog/spdlog.h>
#include "IRadio.h"
#include "RxPacket.h"
#include "capture/relay_aggregator.h"
#include "capture/wfb_usb_device.h"
#include "relay/stream.h"
#include "wifibroadcast.hpp"
#include "rx.hpp"
namespace fpv {
namespace {
std::uint32_t channelIdOf(std::uint32_t linkId, std::uint8_t radioPort)
{
return (linkId << 8) + radioPort;
}
// wfb 帧的收发地址里带 "WB"(0x57,0x42) + 4 字节大端 channel_id。
bool matchesChannel(const std::uint8_t* d, std::size_t n, const std::uint8_t* cid)
{
return n >= 22 && d[10] == 0x57 && d[11] == 0x42 && d[16] == 0x57 && d[17] == 0x42 &&
d[12] == cid[0] && d[13] == cid[1] && d[14] == cid[2] && d[15] == cid[3] &&
d[18] == cid[0] && d[19] == cid[1] && d[20] == cid[2] && d[21] == cid[3];
}
void putBe32(std::uint8_t* out, std::uint32_t v)
{
out[0] = static_cast<std::uint8_t>(v >> 24);
out[1] = static_cast<std::uint8_t>(v >> 16);
out[2] = static_cast<std::uint8_t>(v >> 8);
out[3] = static_cast<std::uint8_t>(v);
}
} // namespace
WfbUsbCapture::WfbUsbCapture(const CaptureConfig& cfg, Stream& stream)
: cfg_(cfg), stream_(stream)
{
}
WfbUsbCapture::~WfbUsbCapture()
{
stop();
}
void WfbUsbCapture::start()
{
if (running_.load()) {
return;
}
UsbWfbParams p;
p.vid = cfg_.usbVid;
p.pid = cfg_.usbPid;
p.channel = static_cast<std::uint8_t>(cfg_.channel);
p.bandwidth = (cfg_.bandwidth.find("40") != std::string::npos) ? 40 : 20;
p.key = cfg_.key;
p.region = cfg_.region;
dev_ = acquireUsbWfbDevice(p);
videoAgg_ = std::make_unique<RelayAggregator>(stream_, cfg_.key, cfg_.epoch,
channelIdOf(cfg_.linkId, cfg_.radioPort));
if (cfg_.mavlink.enabled) {
std::string ip = "127.0.0.1";
int port = 14550;
const std::size_t pos = cfg_.mavlink.target.find_last_of(':');
if (pos != std::string::npos) {
ip = cfg_.mavlink.target.substr(0, pos);
port = std::atoi(cfg_.mavlink.target.substr(pos + 1).c_str());
}
const std::uint32_t mavCh = channelIdOf(cfg_.linkId, cfg_.mavlink.radioPort);
mavAgg_ = std::make_unique<AggregatorUDPv4>(ip, port, cfg_.key, cfg_.epoch, mavCh, 0);
spdlog::info("[{}] USB 遥测下行: radio_port=0x{:02x} -> {}:{}", cfg_.name,
cfg_.mavlink.radioPort, ip, port);
}
running_ = true;
thread_ = std::thread([this] { run(); });
spdlog::info("[{}] USB wfb 采集启动: {:04x}:{:04x} ch={}", cfg_.name, cfg_.usbVid, cfg_.usbPid,
cfg_.channel);
}
void WfbUsbCapture::run()
{
if (!dev_ || !dev_->ensureInitWrite() || dev_->radio() == nullptr) {
spdlog::error("[{}] USB 采集初始化失败", cfg_.name);
running_ = false;
return;
}
std::uint8_t videoCid[4];
putBe32(videoCid, channelIdOf(cfg_.linkId, cfg_.radioPort));
std::uint8_t mavCid[4];
putBe32(mavCid, channelIdOf(cfg_.linkId, cfg_.mavlink.radioPort));
auto callback = [this, videoCid, mavCid](const Packet& packet) {
const std::uint8_t* d = packet.Data.data();
const std::size_t n = packet.Data.size();
const auto& a = packet.RxAtrib;
std::size_t fcs = a.fcs_present ? 4u : 0u;
if (n < sizeof(ieee80211_header) + fcs) {
return;
}
const std::uint8_t* payload = d + sizeof(ieee80211_header);
const std::size_t plen = n - sizeof(ieee80211_header) - fcs;
int8_t rssi[RX_ANT_MAX];
int8_t noise[RX_ANT_MAX];
std::uint8_t antenna[RX_ANT_MAX];
for (int i = 0; i < RX_ANT_MAX; ++i) {
rssi[i] = a.rssi[i];
noise[i] = SCHAR_MAX;
antenna[i] = 0xff;
}
try {
if (matchesChannel(d, n, videoCid) && videoAgg_) {
videoAgg_->process_packet(payload, plen, 0, antenna, rssi, noise, 0, 0, 0, nullptr);
} else if (cfg_.mavlink.enabled && matchesChannel(d, n, mavCid) && mavAgg_) {
mavAgg_->process_packet(payload, plen, 0, antenna, rssi, noise, 0, 0, 0, nullptr);
}
} catch (const std::exception& e) {
spdlog::error("[{}] USB 处理帧异常: {}", cfg_.name, e.what());
}
};
dev_->radio()->StartRxLoop(callback);
running_ = false;
}
void WfbUsbCapture::stop()
{
running_ = false;
if (dev_ && dev_->radio() != nullptr) {
dev_->radio()->StopRxLoop();
}
if (thread_.joinable()) {
thread_.join();
}
videoAgg_.reset();
mavAgg_.reset();
dev_.reset();
}
StreamStatus WfbUsbCapture::status() const
{
StreamStatus s;
s.name = cfg_.name;
s.codec = cfg_.codec;
s.recv = stream_.recvCount();
s.forwarded = stream_.forwardCount();
s.recording = stream_.recording();
if (videoAgg_) {
s.all = videoAgg_->statAll();
s.data = videoAgg_->statData();
s.session = videoAgg_->statSession();
s.decErr = videoAgg_->statDecErr();
s.lost = videoAgg_->statLost();
s.rssi = videoAgg_->statRssi();
s.online = s.session > 0;
}
return s;
}
} // namespace fpv
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <atomic>
#include <memory>
#include <thread>
#include "capture/capture.h"
class Aggregator; // wfb-ng 的聚合器基类(全局命名空间)
namespace fpv {
class Stream;
class UsbWfbDevice;
class RelayAggregator;
using UsbWfbDevicePtr = std::shared_ptr<UsbWfbDevice>;
// devourer(USB) 无线采集器。
// 打开 RTL8812AU → InitWrite(TX+RX) → StartRxLoop;回调里按 channel_id 分发:
// - 视频流(radio_port) → RelayAggregator → Stream(RTP 转发/录制/RTSP)
// - 遥测下行(radio_port 0x10) → AggregatorUDPv4 → 本机 QGC
class WfbUsbCapture : public Capture {
public:
WfbUsbCapture(const CaptureConfig& cfg, Stream& stream);
~WfbUsbCapture() override;
void start() override;
void stop() override;
StreamStatus status() const override;
private:
void run();
CaptureConfig cfg_;
Stream& stream_;
UsbWfbDevicePtr dev_;
std::thread thread_;
std::atomic<bool> running_ {false};
std::unique_ptr<RelayAggregator> videoAgg_;
std::unique_ptr<Aggregator> mavAgg_;
};
} // namespace fpv
+214
View File
@@ -0,0 +1,214 @@
#include "capture/wfb_usb_device.h"
#include <spdlog/spdlog.h>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <map>
#include <stdexcept>
#include <string>
#include <thread>
#include <libusb-1.0/libusb.h>
#include <sodium.h>
#include "DeviceConfig.h"
#include "IRadio.h"
#include "SelectedChannel.h"
#include "UsbDeviceLock.h"
#include "UsbOpen.h"
#include "WiFiDriver.h"
#include "logger.h"
namespace fpv {
namespace {
std::mutex g_registryMutex;
std::map<std::string, std::weak_ptr<UsbWfbDevice>> g_registry;
std::string registryKey(const UsbWfbParams& p)
{
return std::to_string(p.vid) + ":" + std::to_string(p.pid);
}
std::string defaultLockDir(const std::string& configured)
{
if (!configured.empty()) {
return configured;
}
std::error_code ec;
const auto tmp = std::filesystem::temp_directory_path(ec);
if (!ec) {
return tmp.string();
}
return ".";
}
} // namespace
UsbWfbDevice::UsbWfbDevice(const UsbWfbParams& p) : params_(p) {}
UsbWfbDevice::~UsbWfbDevice()
{
close();
}
bool UsbWfbDevice::open()
{
if (sodium_init() < 0) {
spdlog::error("[usb] libsodium 初始化失败");
return false;
}
libusb_context* ctx = nullptr;
if (libusb_init(&ctx) < 0) {
spdlog::error("[usb] libusb_init 失败");
return false;
}
usbCtx_ = ctx;
libusb_device** list = nullptr;
const ssize_t count = libusb_get_device_list(ctx, &list);
libusb_device_handle* handle = nullptr;
for (ssize_t i = 0; i < count; ++i) {
libusb_device_descriptor desc {};
if (libusb_get_device_descriptor(list[i], &desc) != 0) {
continue;
}
if (desc.idVendor == static_cast<uint16_t>(params_.vid) &&
desc.idProduct == static_cast<uint16_t>(params_.pid)) {
if (libusb_open(list[i], &handle) == 0) {
break;
}
spdlog::error("[usb] 打开 {:04x}:{:04x} 失败(驱动未替换为 WinUSB?)", params_.vid, params_.pid);
}
}
if (list != nullptr) {
libusb_free_device_list(list, 1);
}
if (handle == nullptr) {
spdlog::error("[usb] 未找到设备 {:04x}:{:04x}", params_.vid, params_.pid);
return false;
}
auto logger = std::make_shared<Logger>();
std::shared_ptr<devourer::UsbDeviceLock> lock;
const std::string lockDir = defaultLockDir(params_.lockDir);
// 每次打开都先复位设备(do_reset=true):把可能还停在 monitor/RX 的热芯片复位成冷状态,
// 这样 InitWrite 才能成功,也就支持“停止后再次启动”而无需拔插。
const int rc =
devourer::claim_interface_reset_reopen(ctx, handle, logger, true, lock, lockDir);
if (rc != 0) {
spdlog::error("[usb] claim/reset 设备失败: {} (驱动未替换为 WinUSB? 或被占用)", rc);
return false;
}
usbHandle_ = handle;
lock_ = lock;
devourer::DeviceConfig cfg;
// TX+RX 同句柄带起;Jaguar1 忽略此位,Jaguar3 需要。
cfg.rx.enable_with_tx = true;
cfg.usb.lock_dir = lockDir;
// 关键:不要让 Stop() 做 Jaguar1 的 card-disable 断电——在 Windows/WinUSB 上
// 断电会让设备从总线掉线,必须拔插才能恢复。保持上电即可重复开关。
cfg.tuning.teardown_power_down = false;
WiFiDriver driver(logger);
std::unique_ptr<IRadio> radio = driver.CreateRadio(handle, ctx, lock, cfg);
if (!radio) {
spdlog::error("[usb] devourer 不识别该芯片(非受支持的 Realtek?)");
return false;
}
radio_ = radio.release();
spdlog::info("[usb] 设备已打开 {:04x}:{:04x}", params_.vid, params_.pid);
return true;
}
bool UsbWfbDevice::ensureInitWrite()
{
std::lock_guard<std::mutex> lock(mutex_);
if (initDone_) {
return initOk_;
}
initDone_ = true;
const ChannelWidth_t width = (params_.bandwidth >= 40) ? CHANNEL_WIDTH_40 : CHANNEL_WIDTH_20;
SelectedChannel ch {};
ch.Channel = params_.channel;
ch.ChannelOffset = 0;
ch.ChannelWidth = width;
// USB bring-up 偶发传输错误(rtw_read: iostream error):失败就释放设备、重开(带 reset)再试。
constexpr int kMaxAttempts = 5;
for (int attempt = 1; attempt <= kMaxAttempts && !initOk_; ++attempt) {
if (radio_ == nullptr && !open()) {
spdlog::warn("[usb] 打开设备失败(第 {}/{} 次)", attempt, kMaxAttempts);
}
if (radio_ != nullptr) {
try {
radio_->InitWrite(ch);
initOk_ = true;
spdlog::info("[usb] InitWrite 完成: ch={} bw={}MHz (第 {} 次)", params_.channel,
params_.bandwidth, attempt);
} catch (const std::exception& e) {
spdlog::warn("[usb] InitWrite 第 {}/{} 次失败: {}", attempt, kMaxAttempts, e.what());
closeLocked();
}
}
if (!initOk_ && attempt < kMaxAttempts) {
std::this_thread::sleep_for(std::chrono::milliseconds(700));
}
}
if (!initOk_) {
spdlog::error("[usb] InitWrite 连续 {} 次失败,放弃", kMaxAttempts);
}
return initOk_;
}
void UsbWfbDevice::close()
{
std::lock_guard<std::mutex> lock(mutex_);
closeLocked();
}
void UsbWfbDevice::closeLocked()
{
if (radio_ != nullptr) {
try {
radio_->Stop();
} catch (...) {
}
delete radio_;
radio_ = nullptr;
}
if (usbHandle_ != nullptr) {
libusb_release_interface(static_cast<libusb_device_handle*>(usbHandle_), 0);
libusb_close(static_cast<libusb_device_handle*>(usbHandle_));
usbHandle_ = nullptr;
}
if (usbCtx_ != nullptr) {
libusb_exit(static_cast<libusb_context*>(usbCtx_));
usbCtx_ = nullptr;
}
lock_.reset();
}
UsbWfbDevicePtr acquireUsbWfbDevice(const UsbWfbParams& p)
{
const std::string key = registryKey(p);
std::lock_guard<std::mutex> lock(g_registryMutex);
auto it = g_registry.find(key);
if (it != g_registry.end()) {
if (auto existing = it->second.lock()) {
return existing;
}
}
auto dev = std::shared_ptr<UsbWfbDevice>(new UsbWfbDevice(p));
g_registry[key] = dev;
return dev;
}
} // namespace fpv
+70
View File
@@ -0,0 +1,70 @@
#pragma once
// USB(devourer) 无线设备共享管理。
// 一块 RTL8812AU 由 devourer 在用户态接管;RX(采集) 与 TX(上行) 必须复用同一个
// libusb 句柄与同一个已 InitWrite 的芯片,因此这里按 vid:pid 做引用计数共享。
// 本头不引入 devourer 头文件,保持 C++17 可用(实现文件为 C++20)。
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
class IRadio; // devourer 的无线接口(全局命名空间)
namespace fpv {
// 打开/复用一块 USB 网卡的参数。
struct UsbWfbParams {
int vid = 0x0bda;
int pid = 0x881a;
std::uint8_t channel = 161; // 射频信道
int bandwidth = 20; // 20 / 40 (MHz)
std::string key; // gs.key 路径
std::string region; // 监管域(可选)
std::string lockDir; // devourer 适配器锁目录(空=系统临时目录)
};
class UsbWfbDevice {
public:
~UsbWfbDevice();
UsbWfbDevice(const UsbWfbDevice&) = delete;
UsbWfbDevice& operator=(const UsbWfbDevice&) = delete;
// devourer 无线接口;可能为 nullptr(打开失败)。
IRadio* radio() const { return radio_; }
// 控制平面串行化(SetMonitorChannel/send_packet 等)。
std::mutex& mutex() { return mutex_; }
const UsbWfbParams& params() const { return params_; }
// 只做一次 TX+RX 带起(InitWrite),多个使用者共享。
// 成功返回 true;重复调用返回上次结果。
bool ensureInitWrite();
private:
friend std::shared_ptr<UsbWfbDevice> acquireUsbWfbDevice(const UsbWfbParams&);
explicit UsbWfbDevice(const UsbWfbParams& p);
bool open();
void close();
void closeLocked(); // 不加锁版本,供 ensureInitWrite 内部重试用
UsbWfbParams params_;
void* usbCtx_ = nullptr; // libusb_context*
void* usbHandle_ = nullptr; // libusb_device_handle*
std::shared_ptr<void> lock_; // devourer::UsbDeviceLock
IRadio* radio_ = nullptr;
bool initDone_ = false;
bool initOk_ = false;
std::mutex mutex_;
};
using UsbWfbDevicePtr = std::shared_ptr<UsbWfbDevice>;
// 获取(或创建)与参数匹配的共享设备。失败抛出 std::runtime_error。
UsbWfbDevicePtr acquireUsbWfbDevice(const UsbWfbParams& p);
} // namespace fpv
+315
View File
@@ -0,0 +1,315 @@
#include "capture/wfb_usb_tx.h"
#include <spdlog/spdlog.h>
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <memory>
#include <stdexcept>
#include <vector>
#include "IRadio.h"
#include "common/net_compat.h"
#include "wifibroadcast.hpp"
#include "zfex.h"
namespace fpv {
namespace {
struct FecDeleter {
void operator()(fec_t* p) const
{
if (p != nullptr) {
fec_free(p);
}
}
};
// wfb-ng 发送端:FEC 编码 + chacha20poly1305 加密 + 会话密钥。
// 移植自 PixelPilot 的 TxFrame::Transmitter/UsbTransmitter。
class UsbWfbTransmitter {
public:
UsbWfbTransmitter(IRadio* dev, std::mutex* mtx, int k, int n, const std::string& keypair,
std::uint64_t epoch, std::uint32_t channelId, std::uint8_t mcs, int bandwidth)
: dev_(dev),
mtx_(mtx),
fecPtr_(nullptr, FecDeleter {}),
fecK_(k),
fecN_(n),
blockIndex_(0),
fragmentIndex_(0),
maxPacketSize_(0),
epoch_(epoch),
channelId_(channelId),
ieeeSeq_(0)
{
fec_t* raw = nullptr;
if (fec_new(static_cast<uint16_t>(fecK_), static_cast<uint16_t>(fecN_), &raw) != ZFEX_SC_OK ||
raw == nullptr) {
throw std::runtime_error("fec_new() 失败");
}
fecPtr_.reset(raw);
for (int i = 0; i < fecN_; ++i) {
std::uint8_t* p = new std::uint8_t[MAX_FEC_PAYLOAD];
std::memset(p, 0, MAX_FEC_PAYLOAD);
block_.push_back(p);
}
FILE* fp = std::fopen(keypair.c_str(), "rb");
if (fp == nullptr) {
throw std::runtime_error("无法打开密钥文件: " + keypair);
}
if (std::fread(txSecretKey_, crypto_box_SECRETKEYBYTES, 1, fp) != 1) {
std::fclose(fp);
throw std::runtime_error("读取 tx 私钥失败: " + keypair);
}
if (std::fread(rxPublicKey_, crypto_box_PUBLICKEYBYTES, 1, fp) != 1) {
std::fclose(fp);
throw std::runtime_error("读取 rx 公钥失败: " + keypair);
}
std::fclose(fp);
buildRadiotap(mcs, bandwidth);
makeSessionKey();
}
~UsbWfbTransmitter()
{
for (auto* p : block_) {
delete[] p;
}
}
bool sendPacket(const std::uint8_t* buf, std::size_t size, std::uint8_t flags)
{
if (fragmentIndex_ == 0 && (flags & WFB_PACKET_FEC_ONLY)) {
return false;
}
if (size > MAX_PAYLOAD_SIZE) {
throw std::runtime_error("sendPacket: 超过 MAX_PAYLOAD_SIZE");
}
auto* hdr = reinterpret_cast<wpacket_hdr_t*>(block_[fragmentIndex_]);
hdr->flags = flags;
hdr->packet_size = wfb_htobe16(static_cast<uint16_t>(size));
std::memcpy(block_[fragmentIndex_] + sizeof(wpacket_hdr_t), buf, size);
const std::size_t totalHdr = sizeof(wpacket_hdr_t);
if ((totalHdr + size) < MAX_FEC_PAYLOAD) {
std::memset(block_[fragmentIndex_] + totalHdr + size, 0,
MAX_FEC_PAYLOAD - (totalHdr + size));
}
sendBlockFragment(totalHdr + size);
maxPacketSize_ = std::max(maxPacketSize_, totalHdr + size);
++fragmentIndex_;
if (fragmentIndex_ < static_cast<uint8_t>(fecK_)) {
return true;
}
fec_encode_simd(fecPtr_.get(), reinterpret_cast<const gf* const*>(block_.data()),
reinterpret_cast<gf* const*>(block_.data()) + fecK_,
ZFEX_ROUND_UP_SIMD(maxPacketSize_));
while (fragmentIndex_ < static_cast<uint8_t>(fecN_)) {
sendBlockFragment(maxPacketSize_);
++fragmentIndex_;
}
++blockIndex_;
fragmentIndex_ = 0;
maxPacketSize_ = 0;
if (blockIndex_ > MAX_BLOCK_IDX) {
makeSessionKey();
sendSessionKey();
blockIndex_ = 0;
}
return true;
}
void sendSessionKey() { injectPacket(sessionKeyPacket_, sizeof(sessionKeyPacket_)); }
private:
void buildRadiotap(std::uint8_t mcs, int bandwidth)
{
rt_.assign(radiotap_header_ht, radiotap_header_ht + sizeof(radiotap_header_ht));
std::uint8_t flags =
(bandwidth >= 40) ? IEEE80211_RADIOTAP_MCS_BW_40 : IEEE80211_RADIOTAP_MCS_BW_20;
rt_[MCS_FLAGS_OFF] = flags;
rt_[MCS_IDX_OFF] = mcs;
}
void sendBlockFragment(std::size_t packetSize)
{
std::vector<std::uint8_t> cipher(sizeof(wblock_hdr_t) + MAX_FEC_PAYLOAD +
crypto_aead_chacha20poly1305_ABYTES);
std::memset(cipher.data(), 0, cipher.size());
auto* blockHdr = reinterpret_cast<wblock_hdr_t*>(cipher.data());
blockHdr->packet_type = WFB_PACKET_DATA;
blockHdr->data_nonce =
wfb_htobe64(((blockIndex_ & BLOCK_IDX_MASK) << 8) + fragmentIndex_);
unsigned long long cipherLen = 0;
const int rc = crypto_aead_chacha20poly1305_encrypt(
cipher.data() + sizeof(wblock_hdr_t), &cipherLen, block_[fragmentIndex_],
packetSize, reinterpret_cast<const std::uint8_t*>(blockHdr), sizeof(wblock_hdr_t),
nullptr, reinterpret_cast<const std::uint8_t*>(&blockHdr->data_nonce), sessionKey_);
if (rc != 0) {
throw std::runtime_error("加密失败");
}
injectPacket(cipher.data(), sizeof(wblock_hdr_t) + cipherLen);
}
void makeSessionKey()
{
randombytes_buf(sessionKey_, sizeof(sessionKey_));
auto* hdr = reinterpret_cast<wsession_hdr_t*>(sessionKeyPacket_);
hdr->packet_type = WFB_PACKET_SESSION;
randombytes_buf(hdr->session_nonce, sizeof(hdr->session_nonce));
wsession_data_t sessionData = {};
sessionData.epoch = wfb_htobe64(epoch_);
sessionData.channel_id = wfb_htobe32(channelId_);
sessionData.fec_type = WFB_FEC_VDM_RS;
sessionData.k = static_cast<uint8_t>(fecK_);
sessionData.n = static_cast<uint8_t>(fecN_);
std::memcpy(sessionData.session_key, sessionKey_, sizeof(sessionKey_));
if (crypto_box_easy(sessionKeyPacket_ + sizeof(wsession_hdr_t),
reinterpret_cast<const std::uint8_t*>(&sessionData),
sizeof(sessionData), hdr->session_nonce, rxPublicKey_,
txSecretKey_) != 0) {
throw std::runtime_error("会话密钥封装失败");
}
}
void injectPacket(const std::uint8_t* buf, std::size_t size)
{
if (size > MAX_FORWARDER_PACKET_SIZE) {
throw std::runtime_error("injectPacket: 包过大");
}
std::uint8_t ieeeHdr[sizeof(ieee80211_header)];
std::memcpy(ieeeHdr, ieee80211_header, sizeof(ieeeHdr));
ieeeHdr[0] = FRAME_TYPE_DATA;
const std::uint32_t cidBe = wfb_htobe32(channelId_);
std::memcpy(ieeeHdr + SRC_MAC_THIRD_BYTE, &cidBe, sizeof(cidBe));
std::memcpy(ieeeHdr + DST_MAC_THIRD_BYTE, &cidBe, sizeof(cidBe));
ieeeHdr[FRAME_SEQ_LB] = static_cast<uint8_t>(ieeeSeq_ & 0xff);
ieeeHdr[FRAME_SEQ_HB] = static_cast<uint8_t>((ieeeSeq_ >> 8) & 0xff);
ieeeSeq_ = static_cast<uint16_t>(ieeeSeq_ + 16);
std::vector<std::uint8_t> frame;
frame.reserve(rt_.size() + sizeof(ieeeHdr) + size);
frame.insert(frame.end(), rt_.begin(), rt_.end());
frame.insert(frame.end(), ieeeHdr, ieeeHdr + sizeof(ieeeHdr));
frame.insert(frame.end(), buf, buf + size);
bool ok = false;
{
std::lock_guard<std::mutex> lock(*mtx_);
ok = dev_->send_packet(frame.data(), frame.size());
}
if (!ok) {
spdlog::warn("[uplink] send_packet 返回失败");
}
}
IRadio* dev_;
std::mutex* mtx_;
std::unique_ptr<fec_t, FecDeleter> fecPtr_;
const int fecK_;
const int fecN_;
std::uint64_t blockIndex_;
std::uint8_t fragmentIndex_;
std::vector<std::uint8_t*> block_;
std::size_t maxPacketSize_;
const std::uint64_t epoch_;
const std::uint32_t channelId_;
std::uint16_t ieeeSeq_;
std::vector<std::uint8_t> rt_;
std::uint8_t txSecretKey_[crypto_box_SECRETKEYBYTES];
std::uint8_t rxPublicKey_[crypto_box_PUBLICKEYBYTES];
std::uint8_t sessionKey_[crypto_aead_chacha20poly1305_KEYBYTES];
std::uint8_t sessionKeyPacket_[sizeof(wsession_hdr_t) + sizeof(wsession_data_t) +
crypto_box_MACBYTES];
};
} // namespace
void runUsbUplink(const UsbUplinkParams& p, std::atomic<bool>& running)
{
if (p.radio == nullptr || p.radioMutex == nullptr) {
spdlog::error("[uplink] USB 上行缺少设备");
return;
}
try {
const std::uint32_t channelId = (p.linkId << 8) + p.radioPort;
UsbWfbTransmitter tx(p.radio, p.radioMutex, p.fecK, p.fecN, p.key, p.epoch, channelId,
static_cast<uint8_t>(p.mcsIndex), p.bandwidth);
const int fd = static_cast<int>(::socket(AF_INET, SOCK_DGRAM, 0));
if (fd < 0) {
throw std::runtime_error("创建上行 UDP socket 失败");
}
sockaddr_in addr {};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(static_cast<uint16_t>(p.udpPort));
if (::bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
netClose(fd);
throw std::runtime_error("绑定上行 UDP 端口失败: " + std::to_string(p.udpPort));
}
spdlog::info("[uplink] USB 上行启动: udp={} channel_id=0x{:08x} mcs={} fec={}/{}", p.udpPort,
channelId, p.mcsIndex, p.fecK, p.fecN);
tx.sendSessionKey();
std::vector<std::uint8_t> buf(MAX_PAYLOAD_SIZE + 1);
std::uint64_t lastKeyTs = 0;
while (running.load()) {
pollfd pfd {};
pfd.fd = static_cast<decltype(pfd.fd)>(fd);
pfd.events = POLLIN;
if (netPoll(&pfd, 1, 200) <= 0) {
continue;
}
const int n = static_cast<int>(::recv(fd, reinterpret_cast<char*>(buf.data()),
static_cast<int>(buf.size()), 0));
if (n <= 0) {
continue;
}
const std::uint64_t now = get_time_ms();
if (now - lastKeyTs >= SESSION_KEY_ANNOUNCE_MSEC) {
tx.sendSessionKey();
lastKeyTs = now;
}
try {
tx.sendPacket(buf.data(), static_cast<std::size_t>(n), 0);
} catch (const std::exception& e) {
spdlog::warn("[uplink] 发送失败: {}", e.what());
}
}
netClose(fd);
} catch (const std::exception& e) {
spdlog::error("[uplink] 异常: {}", e.what());
}
}
} // namespace fpv
+32
View File
@@ -0,0 +1,32 @@
#pragma once
// devourer(USB) 上行链路:本地 UDP(mavlink/遥控) → wfb-ng FEC+加密 → USB 注入。
// 端口自 PixelPilot 的 TxFrame(UsbTransmitter),去掉 Android/AF_PACKET 依赖。
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
class IRadio;
namespace fpv {
struct UsbUplinkParams {
IRadio* radio = nullptr;
std::mutex* radioMutex = nullptr;
std::string key;
std::uint32_t linkId = 0;
std::uint8_t radioPort = 0;
std::uint64_t epoch = 0;
int mcsIndex = 1;
int bandwidth = 20;
int fecK = 2;
int fecN = 4;
int udpPort = 14551;
};
// 阻塞运行上行发送循环,直到 running 置 false。
void runUsbUplink(const UsbUplinkParams& p, std::atomic<bool>& running);
} // namespace fpv
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <spdlog/spdlog.h>
#include <spdlog/sinks/stdout_color_sinks.h>
namespace fpv {
// 初始化全局日志。
// verbose 为 true 时输出 debug 级别日志,否则输出 info 及以上。
inline void initLog(bool verbose)
{
auto sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
auto logger = std::make_shared<spdlog::logger>("fpv-relay", sink);
logger->set_pattern("[%H:%M:%S.%e] [%^%l%$] %v");
logger->set_level(verbose ? spdlog::level::debug : spdlog::level::info);
logger->flush_on(spdlog::level::info);
spdlog::set_default_logger(logger);
}
} // namespace fpv
+85
View File
@@ -0,0 +1,85 @@
#pragma once
// 跨平台网络兼容层。
// 统一 Windows(Winsock2) 与 POSIX 的 socket/poll 差异,供采集、转发、RTSP、HTTP 复用。
// 只在这里引入平台头文件,其它源文件统一包含本头,避免 winsock2.h 与 windows.h 的顺序问题。
#ifdef _WIN32
// 保证 Winsock2 先于 windows.h;提高目标版本以启用 WSAPoll(Vista+)。
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0601
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#include <cstring>
// MSVC 不自带 socklen_t;与 MinGW/部分 SDK 的 int 定义一致,重复 typedef 合法。
typedef int socklen_t;
namespace fpv {
// 关闭 socket。
inline void netClose(int fd)
{
if (fd >= 0) {
::closesocket(static_cast<SOCKET>(fd));
}
}
// poll 封装:Windows 用 WSAPoll。
inline int netPoll(pollfd* fds, unsigned long nfds, int timeoutMs)
{
return ::WSAPoll(fds, nfds, timeoutMs);
}
} // namespace fpv
// 半关闭/非阻塞收包/大小写比较的跨平台替代。
#define FPC_SHUT_RDWR SD_BOTH
#define FPC_MSG_DONTWAIT 0
#define fpc_strncasecmp _strnicmp
#else // POSIX
#include <arpa/inet.h>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstring>
namespace fpv {
// 关闭 socket。
inline void netClose(int fd)
{
if (fd >= 0) {
::close(fd);
}
}
// poll 封装:POSIX 用 poll。
inline int netPoll(pollfd* fds, unsigned long nfds, int timeoutMs)
{
return ::poll(fds, static_cast<nfds_t>(nfds), timeoutMs);
}
} // namespace fpv
#define FPC_SHUT_RDWR SHUT_RDWR
#define FPC_MSG_DONTWAIT MSG_DONTWAIT
#define fpc_strncasecmp strncasecmp
#endif
+240
View File
@@ -0,0 +1,240 @@
#include "config/config.h"
#include <fstream>
#include <stdexcept>
#include <unordered_set>
#include <yaml-cpp/yaml.h>
namespace fpv {
namespace {
// 解析单个采集条目。
// 依据 type 字段决定读取 wfb 还是 udp 相关字段,并做必要的默认值处理。
CaptureConfig parseCapture(const YAML::Node& node, std::size_t index)
{
CaptureConfig c;
if (!node["name"] || !node["name"].IsScalar()) {
throw std::runtime_error("captures[" + std::to_string(index) + "].name 缺失");
}
c.name = node["name"].as<std::string>();
std::string type = node["type"] ? node["type"].as<std::string>() : "wfb";
if (type == "wfb") {
c.type = CaptureType::Wfb;
} else if (type == "udp") {
c.type = CaptureType::Udp;
} else {
throw std::runtime_error("captures[" + std::to_string(index) + "].type 非法: " + type);
}
if (node["iface"]) c.iface = node["iface"].as<std::string>();
if (node["key"]) c.key = node["key"].as<std::string>();
if (node["link_id"]) c.linkId = node["link_id"].as<std::uint32_t>();
if (node["radio_port"]) c.radioPort = static_cast<std::uint8_t>(node["radio_port"].as<int>());
if (node["epoch"]) c.epoch = node["epoch"].as<std::uint64_t>();
if (node["channel"]) c.channel = node["channel"].as<int>();
if (node["bandwidth"]) c.bandwidth = node["bandwidth"].as<std::string>();
if (node["region"]) c.region = node["region"].as<std::string>();
if (node["usb_vid"]) c.usbVid = node["usb_vid"].as<int>();
if (node["usb_pid"]) c.usbPid = node["usb_pid"].as<int>();
if (node["listen"]) c.listen = node["listen"].as<std::string>();
if (node["codec"]) c.codec = node["codec"].as<std::string>();
if (node["forward"] && node["forward"].IsSequence()) {
for (const auto& t : node["forward"]) {
c.forward.push_back(t.as<std::string>());
}
}
if (node["record"]) {
const YAML::Node r = node["record"];
if (r["enabled"]) c.record.enabled = r["enabled"].as<bool>();
if (r["dir"]) c.record.dir = r["dir"].as<std::string>();
if (r["segment_seconds"]) c.record.segmentSeconds = r["segment_seconds"].as<int>();
if (r["segment_mb"]) c.record.segmentMB = r["segment_mb"].as<int>();
}
if (node["mavlink"]) {
const YAML::Node m = node["mavlink"];
if (m["enabled"]) c.mavlink.enabled = m["enabled"].as<bool>();
if (m["radio_port"]) c.mavlink.radioPort = static_cast<std::uint8_t>(m["radio_port"].as<int>());
if (m["target"]) c.mavlink.target = m["target"].as<std::string>();
}
if (c.type == CaptureType::Wfb && c.iface.empty() && c.usbPid == 0) {
throw std::runtime_error("captures[" + std::to_string(index) +
"].iface 缺失(wfb 类型需 iface 或 usb_pid)");
}
if (c.type == CaptureType::Udp && c.listen.empty()) {
throw std::runtime_error("captures[" + std::to_string(index) + "].listen 缺失(udp 类型必填)");
}
if (c.codec != "h265" && c.codec != "h264") {
throw std::runtime_error("captures[" + std::to_string(index) + "].codec 非法: " + c.codec);
}
return c;
}
} // namespace
Config loadConfig(const std::string& path)
{
YAML::Node root;
try {
root = YAML::LoadFile(path);
} catch (const std::exception& e) {
throw std::runtime_error(std::string("读取配置文件失败: ") + e.what());
}
Config cfg;
const YAML::Node captures = root["captures"];
if (!captures || !captures.IsSequence()) {
throw std::runtime_error("配置缺少 captures 列表");
}
if (root["status"]) {
const YAML::Node st = root["status"];
if (st["enabled"]) cfg.status.enabled = st["enabled"].as<bool>();
if (st["address"]) cfg.status.address = st["address"].as<std::string>();
}
if (root["rtsp"]) {
const YAML::Node rt = root["rtsp"];
if (rt["enabled"]) cfg.rtsp.enabled = rt["enabled"].as<bool>();
if (rt["address"]) cfg.rtsp.address = rt["address"].as<std::string>();
if (rt["udp_base"]) cfg.rtsp.udpBase = rt["udp_base"].as<int>();
}
std::unordered_set<std::string> names;
for (std::size_t i = 0; i < captures.size(); ++i) {
CaptureConfig c = parseCapture(captures[i], i);
if (!names.insert(c.name).second) {
throw std::runtime_error("采集名称重复: " + c.name);
}
cfg.captures.push_back(std::move(c));
}
if (cfg.captures.empty()) {
throw std::runtime_error("captures 为空");
}
// 上行链路(可选)
if (root["uplink"] && root["uplink"].IsSequence()) {
for (const auto& u : root["uplink"]) {
UplinkConfig uc;
if (u["enabled"]) uc.enabled = u["enabled"].as<bool>();
if (u["iface"]) uc.iface = u["iface"].as<std::string>();
if (u["key"]) uc.key = u["key"].as<std::string>();
if (u["link_id"]) uc.linkId = u["link_id"].as<std::uint32_t>();
if (u["radio_port"]) uc.radioPort = static_cast<std::uint8_t>(u["radio_port"].as<int>());
if (u["epoch"]) uc.epoch = u["epoch"].as<std::uint64_t>();
if (u["mcs_index"]) uc.mcsIndex = u["mcs_index"].as<int>();
if (u["bandwidth"]) uc.bandwidth = u["bandwidth"].as<int>();
if (u["channel"]) uc.channel = u["channel"].as<int>();
if (u["region"]) uc.region = u["region"].as<std::string>();
if (u["fec_k"]) uc.fecK = u["fec_k"].as<int>();
if (u["fec_n"]) uc.fecN = u["fec_n"].as<int>();
if (u["udp_port"]) uc.udpPort = u["udp_port"].as<int>();
if (u["fec_delay"]) uc.fecDelay = u["fec_delay"].as<std::uint32_t>();
if (u["usb_vid"]) uc.usbVid = u["usb_vid"].as<int>();
if (u["usb_pid"]) uc.usbPid = u["usb_pid"].as<int>();
if (uc.enabled) {
if ((uc.iface.empty() && uc.usbPid == 0) || uc.key.empty()) {
throw std::runtime_error("uplink 需要 key,以及 iface 或 usb_pid");
}
cfg.uplinks.push_back(uc);
}
}
}
return cfg;
}
void saveConfig(const Config& cfg, const std::string& path)
{
YAML::Node root;
YAML::Node st;
st["enabled"] = cfg.status.enabled;
st["address"] = cfg.status.address;
root["status"] = st;
YAML::Node rt;
rt["enabled"] = cfg.rtsp.enabled;
rt["address"] = cfg.rtsp.address;
rt["udp_base"] = cfg.rtsp.udpBase;
root["rtsp"] = rt;
YAML::Node ul(YAML::NodeType::Sequence);
for (const auto& u : cfg.uplinks) {
YAML::Node n;
n["enabled"] = true;
n["iface"] = u.iface;
n["key"] = u.key;
n["link_id"] = u.linkId;
n["radio_port"] = static_cast<int>(u.radioPort);
n["epoch"] = u.epoch;
n["mcs_index"] = u.mcsIndex;
n["bandwidth"] = u.bandwidth;
n["channel"] = u.channel;
n["region"] = u.region;
n["fec_k"] = u.fecK;
n["fec_n"] = u.fecN;
n["udp_port"] = u.udpPort;
n["fec_delay"] = u.fecDelay;
if (u.usbPid != 0) {
n["usb_vid"] = u.usbVid;
n["usb_pid"] = u.usbPid;
}
ul.push_back(n);
}
if (!cfg.uplinks.empty()) {
root["uplink"] = ul;
}
YAML::Node caps(YAML::NodeType::Sequence);
for (const auto& c : cfg.captures) {
YAML::Node n;
n["name"] = c.name;
n["type"] = (c.type == CaptureType::Udp) ? "udp" : "wfb";
if (!c.iface.empty()) n["iface"] = c.iface;
if (!c.listen.empty()) n["listen"] = c.listen;
if (!c.key.empty()) n["key"] = c.key;
n["link_id"] = c.linkId;
n["radio_port"] = static_cast<int>(c.radioPort);
n["epoch"] = c.epoch;
n["channel"] = c.channel;
n["bandwidth"] = c.bandwidth;
n["region"] = c.region;
if (c.usbPid != 0) {
n["usb_vid"] = c.usbVid;
n["usb_pid"] = c.usbPid;
}
n["codec"] = c.codec;
YAML::Node fwd(YAML::NodeType::Sequence);
for (const auto& f : c.forward) {
fwd.push_back(f);
}
n["forward"] = fwd;
YAML::Node rec;
rec["enabled"] = c.record.enabled;
rec["dir"] = c.record.dir;
rec["segment_seconds"] = c.record.segmentSeconds;
rec["segment_mb"] = c.record.segmentMB;
n["record"] = rec;
YAML::Node mav;
mav["enabled"] = c.mavlink.enabled;
mav["radio_port"] = static_cast<int>(c.mavlink.radioPort);
mav["target"] = c.mavlink.target;
n["mavlink"] = mav;
caps.push_back(n);
}
root["captures"] = caps;
std::ofstream f(path);
if (!f) {
throw std::runtime_error("无法写入配置文件: " + path);
}
f << root;
}
} // namespace fpv
+109
View File
@@ -0,0 +1,109 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace fpv {
// 采集类型。
// Wfb 表示通过 wfb-ng 直接驱动 RTL8812AU(monitor) 收包并解密;
// Udp 表示从本地/远端 UDP 端口读取已经解好的 RTP,用于测试或跨平台转发。
enum class CaptureType {
Wfb,
Udp,
};
// DVR 录制配置。
struct RecordConfig {
bool enabled = false;
std::string dir = "records"; // 录制根目录(其下按路名建子目录)
int segmentSeconds = 300; // 单个分片时长(秒)
int segmentMB = 0; // 单个分片大小上限(MB,0=不限制)
};
// 遥测下行(mavlink)配置:接收天空端另一个数据流并转发给本机 QGC。
struct MavlinkConfig {
bool enabled = false;
std::uint8_t radioPort = 0x10; // 天空端 mavlink 下行端口(常见 0x10)
std::string target = "127.0.0.1:14550"; // 转发目标(QGC 监听)
};
// 单路图传的采集与转发配置。
// 一路对应一块接收卡(或一个 UDP 输入),name 作为该路的唯一标识。
struct CaptureConfig {
std::string name;
CaptureType type = CaptureType::Wfb;
// 以下为 Wfb 类型参数
std::string iface; // 网卡名,如 wfb0
std::string key; // wfb-ng 密钥文件(gs.key)
std::uint32_t linkId = 0;
std::uint8_t radioPort = 0;
std::uint64_t epoch = 0;
int channel = 161; // 射频信道
std::string bandwidth = "HT20"; // HT20 / HT40+ / HT40-
std::string region = "BO"; // 监管域国家码
// USB(devourer) 采集:网卡 USB VID/PID(0 = 非 USB,使用 iface)
int usbVid = 0x0bda;
int usbPid = 0;
// 以下为 Udp 类型参数
std::string listen; // 监听地址,如 127.0.0.1:5600
std::string codec = "h265"; // h265 / h264
std::vector<std::string> forward; // 裸 RTP/UDP 转发目标 ip:port
RecordConfig record; // DVR 录制(可选)
MavlinkConfig mavlink; // 遥测下行(可选)
};
// HTTP 状态页配置。
struct StatusConfig {
bool enabled = false;
std::string address = ":8080";
};
// RTSP 兼容输出配置。
struct RtspConfig {
bool enabled = false;
std::string address = ":8554";
int udpBase = 8000; // >0 时启用 UDP 传输(RTP=udpBase, RTCP=udpBase+1)
};
// 上行链路配置(电脑→无人机,mavlink/遥控)。
struct UplinkConfig {
bool enabled = false;
std::string iface; // 发射网卡(monitor)
std::string key; // gs.key
std::uint32_t linkId = 0;
std::uint8_t radioPort = 0;
std::uint64_t epoch = 0;
int mcsIndex = 1;
int bandwidth = 20; // 20/40
int channel = 161;
std::string region = "BO";
int fecK = 8;
int fecN = 12;
int udpPort = 14551; // 本地监听 mavlink 的 UDP 端口
std::uint32_t fecDelay = 0; // us
int usbVid = 0x0bda; // USB(devourer) 上行:网卡 VID/PID(0 = 非 USB)
int usbPid = 0;
};
// 程序总配置。
struct Config {
std::vector<CaptureConfig> captures;
std::vector<UplinkConfig> uplinks;
StatusConfig status;
RtspConfig rtsp;
};
// 从 YAML 文件读取配置。
// 读取失败或字段非法时抛出 std::runtime_error。
Config loadConfig(const std::string& path);
// 把配置写回 YAML 文件(供设置界面保存)。
void saveConfig(const Config& cfg, const std::string& path);
} // namespace fpv
+641
View File
@@ -0,0 +1,641 @@
#include "gui/backend.h"
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMetaObject>
#include <QProcess>
#include <QSettings>
#include <QTimer>
#include <QUrl>
#include <QVariantMap>
#include <algorithm>
#include <functional>
#include <spdlog/spdlog.h>
#include <spdlog/sinks/base_sink.h>
#include <mutex>
#include "rtsp/rtsp_server.h"
namespace {
// 自定义 spdlog sink:把日志行通过队列投递到 Qt 主线程。
class QtLogSink : public spdlog::sinks::base_sink<std::mutex> {
public:
using Callback = std::function<void(const QString&)>;
explicit QtLogSink(Callback cb) : cb_(std::move(cb)) {}
protected:
void sink_it_(const spdlog::details::log_msg& msg) override
{
spdlog::memory_buf_t buf;
formatter_->format(msg, buf);
cb_(QString::fromUtf8(buf.data(), static_cast<int>(buf.size())).trimmed());
}
void flush_() override {}
private:
Callback cb_;
};
} // namespace
namespace {
// 简单 JSON 字符串转义。
std::string jsonEscape(const std::string& in)
{
std::string out;
for (char c : in) {
if (c == '"' || c == '\\') out.push_back('\\');
out.push_back(c);
}
return out;
}
} // namespace
Backend::Backend(QObject* parent) : QObject(parent)
{
QSettings settings;
darkMode_ = settings.value("darkMode", true).toBool();
timer_ = new QTimer(this);
timer_->setInterval(500);
connect(timer_, &QTimer::timeout, this, &Backend::refresh);
// 把 spdlog 日志接到界面
auto sink = std::make_shared<QtLogSink>([this](const QString& line) {
QMetaObject::invokeMethod(
this, [this, line] { appendLog(line); }, Qt::QueuedConnection);
});
sink->set_pattern("[%H:%M:%S] [%l] %v");
spdlog::default_logger()->sinks().push_back(sink);
}
Backend::~Backend()
{
stop();
}
void Backend::setDarkMode(bool dark)
{
if (darkMode_ == dark) {
return;
}
darkMode_ = dark;
QSettings settings;
settings.setValue("darkMode", dark);
emit darkModeChanged();
}
void Backend::setRecording(const QString& name, bool on)
{
const std::string target = name.toStdString();
for (auto& s : streams_) {
if (s->name() == target) {
s->setRecording(on);
appendLog(QString("[%1] %2录制").arg(name, on ? "开始" : "停止"));
refresh();
return;
}
}
appendLog(QString("[%1] 未找到该路,无法切换录制").arg(name));
}
void Backend::openConfigInEditor()
{
if (configPath_.isEmpty()) {
setStatusText("未选择配置");
return;
}
QDesktopServices::openUrl(QUrl::fromLocalFile(configPath_));
}
QString Backend::readConfig() const
{
if (configPath_.isEmpty()) {
return QString();
}
QFile f(configPath_);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
return QString();
}
return QString::fromUtf8(f.readAll());
}
bool Backend::saveConfig(const QString& text)
{
if (configPath_.isEmpty()) {
setStatusText("未选择配置");
return false;
}
QFile f(configPath_);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
appendLog(QString("[配置] 保存失败: %1").arg(configPath_));
return false;
}
f.write(text.toUtf8());
f.close();
appendLog(QString("[配置] 已保存: %1 (点“加载”生效)").arg(configPath_));
setStatusText("配置已保存,点“加载”生效");
return true;
}
QVariantMap Backend::settings() const
{
QVariantMap m;
QVariantMap st;
st["enabled"] = cfg_.status.enabled;
st["address"] = QString::fromStdString(cfg_.status.address);
m["status"] = st;
QVariantMap rt;
rt["enabled"] = cfg_.rtsp.enabled;
rt["address"] = QString::fromStdString(cfg_.rtsp.address);
rt["udp_base"] = cfg_.rtsp.udpBase;
m["rtsp"] = rt;
QVariantList caps;
for (const auto& c : cfg_.captures) {
QVariantMap n;
n["name"] = QString::fromStdString(c.name);
n["type"] = (c.type == fpv::CaptureType::Udp) ? "udp" : "wfb";
n["iface"] = QString::fromStdString(c.iface);
n["usb_vid"] = c.usbVid;
n["usb_pid"] = c.usbPid;
n["listen"] = QString::fromStdString(c.listen);
n["key"] = QString::fromStdString(c.key);
n["link_id"] = static_cast<qulonglong>(c.linkId);
n["radio_port"] = static_cast<int>(c.radioPort);
n["epoch"] = static_cast<qulonglong>(c.epoch);
n["channel"] = c.channel;
n["bandwidth"] = QString::fromStdString(c.bandwidth);
n["region"] = QString::fromStdString(c.region);
n["codec"] = QString::fromStdString(c.codec);
QStringList fwd;
for (const auto& f : c.forward) {
fwd << QString::fromStdString(f);
}
n["forward"] = fwd.join(", ");
QVariantMap rec;
rec["enabled"] = c.record.enabled;
rec["dir"] = QString::fromStdString(c.record.dir);
rec["segment_seconds"] = c.record.segmentSeconds;
rec["segment_mb"] = c.record.segmentMB;
n["record"] = rec;
QVariantMap mav;
mav["enabled"] = c.mavlink.enabled;
mav["radio_port"] = static_cast<int>(c.mavlink.radioPort);
mav["target"] = QString::fromStdString(c.mavlink.target);
n["mavlink"] = mav;
caps.append(n);
}
m["captures"] = caps;
QVariantList ups;
for (const auto& u : cfg_.uplinks) {
QVariantMap n;
n["iface"] = QString::fromStdString(u.iface);
n["key"] = QString::fromStdString(u.key);
n["link_id"] = static_cast<qulonglong>(u.linkId);
n["radio_port"] = static_cast<int>(u.radioPort);
n["epoch"] = static_cast<qulonglong>(u.epoch);
n["mcs_index"] = u.mcsIndex;
n["bandwidth"] = u.bandwidth;
n["channel"] = u.channel;
n["region"] = QString::fromStdString(u.region);
n["fec_k"] = u.fecK;
n["fec_n"] = u.fecN;
n["udp_port"] = u.udpPort;
n["fec_delay"] = static_cast<uint>(u.fecDelay);
n["usb_vid"] = u.usbVid;
n["usb_pid"] = u.usbPid;
ups.append(n);
}
m["uplinks"] = ups;
return m;
}
bool Backend::applySettings(const QVariantMap& s)
{
if (configPath_.isEmpty()) {
setStatusText("未选择配置");
return false;
}
try {
fpv::Config c;
const QVariantMap st = s.value("status").toMap();
c.status.enabled = st.value("enabled").toBool();
c.status.address = st.value("address").toString().toStdString();
const QVariantMap rt = s.value("rtsp").toMap();
c.rtsp.enabled = rt.value("enabled").toBool();
c.rtsp.address = rt.value("address").toString().toStdString();
c.rtsp.udpBase = rt.value("udp_base").toInt();
const QVariantList caps = s.value("captures").toList();
for (const auto& cv : caps) {
const QVariantMap n = cv.toMap();
fpv::CaptureConfig cc;
cc.name = n.value("name").toString().toStdString();
cc.type = (n.value("type").toString() == "udp") ? fpv::CaptureType::Udp
: fpv::CaptureType::Wfb;
cc.iface = n.value("iface").toString().toStdString();
cc.usbVid = n.value("usb_vid").toInt();
cc.usbPid = n.value("usb_pid").toInt();
cc.listen = n.value("listen").toString().toStdString();
cc.key = n.value("key").toString().toStdString();
cc.linkId = n.value("link_id").toUInt();
cc.radioPort = static_cast<std::uint8_t>(n.value("radio_port").toInt());
cc.epoch = n.value("epoch").toULongLong();
cc.channel = n.value("channel").toInt();
cc.bandwidth = n.value("bandwidth").toString().toStdString();
cc.region = n.value("region").toString().toStdString();
cc.codec = n.value("codec").toString().toStdString();
const QString fwd = n.value("forward").toString();
for (const QString& part : fwd.split(',', Qt::SkipEmptyParts)) {
const QString t = part.trimmed();
if (!t.isEmpty()) {
cc.forward.push_back(t.toStdString());
}
}
const QVariantMap rec = n.value("record").toMap();
cc.record.enabled = rec.value("enabled").toBool();
cc.record.dir = rec.value("dir").toString().toStdString();
cc.record.segmentSeconds = rec.value("segment_seconds").toInt();
cc.record.segmentMB = rec.value("segment_mb").toInt();
const QVariantMap mav = n.value("mavlink").toMap();
cc.mavlink.enabled = mav.value("enabled").toBool();
cc.mavlink.radioPort = static_cast<std::uint8_t>(mav.value("radio_port").toInt());
cc.mavlink.target = mav.value("target").toString().toStdString();
c.captures.push_back(cc);
}
const QVariantList ups = s.value("uplinks").toList();
for (const auto& uv : ups) {
const QVariantMap n = uv.toMap();
fpv::UplinkConfig u;
u.enabled = true;
u.iface = n.value("iface").toString().toStdString();
u.key = n.value("key").toString().toStdString();
u.linkId = n.value("link_id").toUInt();
u.radioPort = static_cast<std::uint8_t>(n.value("radio_port").toInt());
u.epoch = n.value("epoch").toULongLong();
u.mcsIndex = n.value("mcs_index").toInt();
u.bandwidth = n.value("bandwidth").toInt();
u.channel = n.value("channel").toInt();
u.region = n.value("region").toString().toStdString();
u.fecK = n.value("fec_k").toInt();
u.fecN = n.value("fec_n").toInt();
u.udpPort = n.value("udp_port").toInt();
u.fecDelay = n.value("fec_delay").toUInt();
u.usbVid = n.value("usb_vid").toInt();
u.usbPid = n.value("usb_pid").toInt();
if ((!u.iface.empty() || u.usbPid != 0) && !u.key.empty()) {
c.uplinks.push_back(u);
}
}
if (c.captures.empty()) {
appendLog("[设置] 至少保留一路采集,未保存");
return false;
}
for (const auto& cc : c.captures) {
if (cc.name.empty()) {
appendLog("[设置] 采集名称不能为空,未保存");
return false;
}
}
fpv::saveConfig(c, configPath_.toStdString());
cfg_ = c;
appendLog(QString("[设置] 已保存到 %1 (点“停止→启动”生效)").arg(configPath_));
setStatusText("设置已保存,点“停止→启动”生效");
return true;
} catch (const std::exception& e) {
appendLog(QString("[设置] 保存失败: %1").arg(e.what()));
return false;
}
}
QString Backend::recordingsDir()
{
if (!cfg_.captures.empty()) {
return QString::fromStdString(cfg_.captures.front().record.dir);
}
return "records";
}
QVariantList Backend::listRecordings()
{
QVariantList out;
for (const auto& c : cfg_.captures) {
const QString base = QString::fromStdString(c.record.dir);
const QString dirPath = base + "/" + QString::fromStdString(c.name);
QDir dir(dirPath);
if (!dir.exists()) {
continue;
}
const QStringList filters {"*.h265", "*.h264", "*.mp4", "*.ts"};
const QFileInfoList files = dir.entryInfoList(filters, QDir::Files, QDir::Time);
for (const QFileInfo& fi : files) {
QVariantMap m;
m["name"] = fi.fileName();
m["path"] = fi.absoluteFilePath();
m["drone"] = QString::fromStdString(c.name);
m["sizeMB"] = QString::number(fi.size() / 1024.0 / 1024.0, 'f', 1);
m["time"] = fi.lastModified().toString("MM-dd HH:mm:ss");
out.append(m);
}
}
return out;
}
bool Backend::transcodeToMp4(const QString& path)
{
if (path.isEmpty()) {
return false;
}
QString out = path;
const int dot = out.lastIndexOf('.');
if (dot > 0) {
out = out.left(dot);
}
out += ".mp4";
if (QFileInfo::exists(out)) {
appendLog(QString("[转封装] 已存在: %1").arg(out));
return true;
}
const bool ok = QProcess::startDetached("ffmpeg",
{"-y", "-i", path, "-c", "copy", out});
appendLog(QString("[转封装] %1 -> %2 %3").arg(path, out, ok ? "已启动" : "启动失败"));
return ok;
}
void Backend::setConfigPath(const QString& path)
{
if (configPath_ == path) {
return;
}
configPath_ = path;
emit configPathChanged();
}
void Backend::setStatusText(const QString& text)
{
if (statusText_ == text) {
return;
}
statusText_ = text;
emit statusTextChanged();
}
void Backend::setUplinkText(const QString& text)
{
if (uplinkText_ == text) {
return;
}
uplinkText_ = text;
emit uplinkTextChanged();
}
bool Backend::loadConfig(const QString& path)
{
try {
cfg_ = fpv::loadConfig(path.toStdString());
configPath_ = path;
emit configPathChanged();
setStatusText(QString("已加载配置:%1 路").arg(cfg_.captures.size()));
appendLog(QString("[配置] 加载 %1,共 %2 路").arg(path).arg(cfg_.captures.size()));
return true;
} catch (const std::exception& e) {
setStatusText(QString("配置错误:%1").arg(e.what()));
appendLog(QString("[配置] 错误:%1").arg(e.what()));
return false;
}
}
bool Backend::loadConfigUrl(const QUrl& url)
{
const QString local = url.isLocalFile() ? url.toLocalFile() : url.toString();
return loadConfig(local);
}
bool Backend::start()
{
if (running_) {
return true;
}
if (cfg_.captures.empty()) {
setStatusText("请先加载配置");
return false;
}
streams_.clear();
captures_.clear();
previewPorts_.clear();
int index = 0;
for (const auto& c : cfg_.captures) {
// 额外转发一份到本地预览端口,供界面实时预览
fpv::CaptureConfig cc = c;
const int previewPort = 6200 + index;
cc.forward.push_back("ts://127.0.0.1:" + std::to_string(previewPort));
previewPorts_.push_back(previewPort);
auto stream = std::make_unique<fpv::Stream>(cc.name, cc.codec, cc.forward, cc.record);
try {
captures_.push_back(fpv::createCapture(cc, *stream));
} catch (const std::exception& e) {
appendLog(QString("[%1] 创建采集失败:%2").arg(QString::fromStdString(c.name),
e.what()));
++index;
continue;
}
streams_.push_back(std::move(stream));
++index;
}
// GUI 预览用:始终启动本地 RTSP 输出,供 ffmpeg 拉流解码(即使 config 未开启 RTSP)。
{
const auto colon = cfg_.rtsp.address.find_last_of(':');
if (colon != std::string::npos) {
const int p = std::atoi(cfg_.rtsp.address.substr(colon + 1).c_str());
if (p > 0) {
rtspPort_ = p;
}
}
rtsp_ = std::make_unique<fpv::RtspServer>(cfg_.rtsp.address, cfg_.rtsp.udpBase);
for (const auto& s : streams_) {
rtsp_->addStream(s.get());
}
try {
rtsp_->start();
} catch (const std::exception& e) {
appendLog(QString("[RTSP] 启动失败:%1").arg(e.what()));
}
}
for (std::size_t i = 0; i < captures_.size(); ++i) {
try {
captures_[i]->start();
} catch (const std::exception& e) {
appendLog(QString("采集启动失败:%1").arg(e.what()));
}
}
// 启动上行链路(电脑→无人机)
uplinks_.clear();
for (const auto& u : cfg_.uplinks) {
auto up = std::make_unique<fpv::WfbUplink>(u);
up->start();
uplinks_.push_back(std::move(up));
}
if (!uplinks_.empty()) {
setUplinkText(QString("上行已启动 %1 路 (mavlink UDP %2)")
.arg(uplinks_.size())
.arg(cfg_.uplinks.front().udpPort));
}
// 启动 HTTP 状态页(GUI 也支持 /status 与 /metrics)
if (cfg_.status.enabled) {
auto jsonProvider = [this]() {
std::string j = "{\"captures\":[";
for (std::size_t i = 0; i < captures_.size(); ++i) {
const fpv::StreamStatus s = captures_[i]->status();
if (i > 0) j += ",";
j += "{\"name\":\"" + jsonEscape(s.name) + "\"";
j += ",\"codec\":\"" + jsonEscape(s.codec) + "\"";
j += ",\"online\":";
j += (s.online ? "true" : "false");
j += ",\"recording\":";
j += (s.recording ? "true" : "false");
j += ",\"recv\":" + std::to_string(s.recv);
j += ",\"forwarded\":" + std::to_string(s.forwarded);
j += ",\"session\":" + std::to_string(s.session);
j += ",\"decErr\":" + std::to_string(s.decErr);
j += ",\"lost\":" + std::to_string(s.lost);
j += ",\"rssi\":" + std::to_string(s.rssi);
j += "}";
}
j += "]}";
return j;
};
auto metricsProvider = [this]() {
std::string m;
for (const auto& cap : captures_) {
const fpv::StreamStatus s = cap->status();
const std::string n = jsonEscape(s.name);
m += "fpv_relay_online{capture=\"" + n + "\"} " +
std::to_string(s.online ? 1 : 0) + "\n";
m += "fpv_relay_recv{capture=\"" + n + "\"} " + std::to_string(s.recv) + "\n";
m += "fpv_relay_forwarded{capture=\"" + n + "\"} " +
std::to_string(s.forwarded) + "\n";
m += "fpv_relay_session{capture=\"" + n + "\"} " + std::to_string(s.session) + "\n";
m += "fpv_relay_dec_err{capture=\"" + n + "\"} " + std::to_string(s.decErr) + "\n";
m += "fpv_relay_lost{capture=\"" + n + "\"} " + std::to_string(s.lost) + "\n";
m += "fpv_relay_rssi{capture=\"" + n + "\"} " + std::to_string(s.rssi) + "\n";
}
return m;
};
status_ = std::make_unique<fpv::StatusServer>(cfg_.status.address, jsonProvider,
metricsProvider);
try {
status_->start();
} catch (const std::exception& e) {
appendLog(QString("[状态页] 启动失败:%1").arg(e.what()));
}
}
running_ = true;
emit runningChanged();
timer_->start();
setStatusText(QString("运行中:%1 路").arg(captures_.size()));
refresh();
return true;
}
void Backend::stop()
{
if (!running_) {
return;
}
// 先停刷新定时器并置位,避免 stop 期间 refresh 访问正在释放的对象
timer_->stop();
running_ = false;
for (auto& cap : captures_) {
cap->stop();
}
for (auto& up : uplinks_) {
up->stop();
}
if (rtsp_) {
rtsp_->stop();
rtsp_.reset();
}
if (status_) {
status_->stop();
status_.reset();
}
// 线程都已 join,可以安全释放并支持再次启动
captures_.clear();
streams_.clear();
uplinks_.clear();
emit runningChanged();
setStatusText("已停止");
setUplinkText("");
appendLog("[系统] 已停止");
}
void Backend::clearLogs()
{
logs_.clear();
emit logsChanged();
}
void Backend::appendLog(const QString& line)
{
logs_.append(line);
if (logs_.size() > 2000) {
logs_.remove(0, logs_.size() - 2000);
}
emit logsChanged();
}
void Backend::refresh()
{
if (!running_) {
return;
}
QVariantList list;
std::size_t i = 0;
for (const auto& cap : captures_) {
const fpv::StreamStatus s = cap->status();
QVariantMap m;
m["name"] = QString::fromStdString(s.name);
m["codec"] = QString::fromStdString(s.codec);
m["online"] = s.online;
m["recv"] = static_cast<qulonglong>(s.recv);
m["forwarded"] = static_cast<qulonglong>(s.forwarded);
m["session"] = static_cast<qulonglong>(s.session);
m["decErr"] = static_cast<qulonglong>(s.decErr);
m["lost"] = static_cast<qulonglong>(s.lost);
m["rssi"] = s.rssi;
m["recording"] = s.recording;
m["previewPort"] = (i < previewPorts_.size()) ? previewPorts_[i] : 0;
m["previewUrl"] = QString("rtsp://127.0.0.1:%1/%2")
.arg(rtspPort_)
.arg(QString::fromStdString(s.name));
list.append(m);
++i;
}
drones_ = list;
emit dronesChanged();
}
+110
View File
@@ -0,0 +1,110 @@
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QVariantList>
#include <memory>
#include <vector>
#include "capture/capture.h"
#include "capture/wfb_uplink.h"
#include "config/config.h"
#include "relay/stream.h"
#include "status/http_server.h"
namespace fpv {
class RtspServer;
}
class QTimer;
// GUI 后端:把 C++ 采集/转发核心封装成 QML 可用的对象。
// 负责加载配置、启动/停止、定时刷新每路状态,并把日志转发到界面。
class Backend : public QObject {
Q_OBJECT
Q_PROPERTY(QString configPath READ configPath WRITE setConfigPath NOTIFY configPathChanged)
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(QVariantList drones READ drones NOTIFY dronesChanged)
Q_PROPERTY(QStringList logs READ logs NOTIFY logsChanged)
Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged)
Q_PROPERTY(QString uplinkText READ uplinkText NOTIFY uplinkTextChanged)
Q_PROPERTY(bool darkMode READ darkMode WRITE setDarkMode NOTIFY darkModeChanged)
public:
explicit Backend(QObject* parent = nullptr);
~Backend() override;
QString configPath() const { return configPath_; }
void setConfigPath(const QString& path);
bool running() const { return running_; }
QVariantList drones() const { return drones_; }
QStringList logs() const { return logs_; }
QString statusText() const { return statusText_; }
QString uplinkText() const { return uplinkText_; }
bool darkMode() const { return darkMode_; }
void setDarkMode(bool dark);
// 加载配置,成功返回 true。
Q_INVOKABLE bool loadConfig(const QString& path);
// 从文件对话框的 URL 加载配置(file:// 会转成本地路径)。
Q_INVOKABLE bool loadConfigUrl(const QUrl& url);
// 启动采集与转发,成功返回 true。
Q_INVOKABLE bool start();
// 停止采集与转发。
Q_INVOKABLE void stop();
// 清空日志。
Q_INVOKABLE void clearLogs();
// 动态开关某路录制。
Q_INVOKABLE void setRecording(const QString& name, bool on);
// 用系统默认程序打开配置文件。
Q_INVOKABLE void openConfigInEditor();
// 读取/保存配置文本(内置编辑器用)。
Q_INVOKABLE QString readConfig() const;
Q_INVOKABLE bool saveConfig(const QString& text);
// 结构化设置:读取当前配置为 QVariantMap / 应用并写回 YAML。
Q_INVOKABLE QVariantMap settings() const;
Q_INVOKABLE bool applySettings(const QVariantMap& s);
// 列出所有录制文件(供回放)。
Q_INVOKABLE QVariantList listRecordings();
// 录制根目录。
Q_INVOKABLE QString recordingsDir();
// 把裸 H265 录像转封装成 MP4(调用 ffmpeg)。
Q_INVOKABLE bool transcodeToMp4(const QString& path);
signals:
void configPathChanged();
void runningChanged();
void dronesChanged();
void logsChanged();
void statusTextChanged();
void uplinkTextChanged();
void darkModeChanged();
private slots:
void refresh();
private:
void appendLog(const QString& line);
void setStatusText(const QString& text);
void setUplinkText(const QString& text);
QString configPath_;
bool running_ = false;
bool darkMode_ = true;
fpv::Config cfg_;
std::vector<std::unique_ptr<fpv::Stream>> streams_;
std::vector<fpv::CapturePtr> captures_;
std::vector<std::unique_ptr<fpv::WfbUplink>> uplinks_;
std::unique_ptr<fpv::RtspServer> rtsp_;
std::unique_ptr<fpv::StatusServer> status_;
int rtspPort_ = 8554; // 本地 RTSP 端口(GUI 预览用 ffmpeg 拉流)
std::vector<int> previewPorts_; // 每路的本地预览 UDP 端口
QTimer* timer_ = nullptr;
QVariantList drones_;
QStringList logs_;
QString statusText_;
QString uplinkText_;
};
+60
View File
@@ -0,0 +1,60 @@
#ifdef _WIN32
// 必须在任何可能引入 windows.h 的头文件(Qt 等)之前包含 winsock2.h
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#endif
#include <QApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickStyle>
#include <QtQml/qqml.h>
#include "gui/backend.h"
#include "gui/video_item.h"
#if defined(FLUENTUI_BUILD_STATIC_LIB) && (QT_VERSION > QT_VERSION_CHECK(6, 2, 0))
#include <QtQml/qqmlextensionplugin.h>
Q_IMPORT_QML_PLUGIN(FluentUIPlugin)
#endif
// GUI 程序入口。
// 使用 FluentUI(Fluent 风格) + Qt Quick 构建界面;Backend 把核心功能暴露给 QML。
int main(int argc, char** argv)
{
#ifdef _WIN32
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);
#endif
qputenv("QT_QUICK_CONTROLS_STYLE", "Basic");
QApplication app(argc, argv);
QApplication::setApplicationName("fpv-relay");
QApplication::setOrganizationName("fpv-relay");
Backend backend;
qmlRegisterType<VideoItem>("FpvRelay.Video", 1, 0, "VideoItem");
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("backend", &backend);
QObject::connect(
&engine, &QQmlApplicationEngine::objectCreationFailed, &app,
[]() { QCoreApplication::exit(-1); }, Qt::QueuedConnection);
engine.loadFromModule("FpvRelay", "App");
if (engine.rootObjects().isEmpty()) {
return -1;
}
const int rc = app.exec();
#ifdef _WIN32
WSACleanup();
#endif
return rc;
}
+523
View File
@@ -0,0 +1,523 @@
import QtQuick
import QtQuick.Window
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Dialogs
import FluentUI 1.0
import FpvRelay.Video 1.0
// 主窗口:FluentUI 风格,支持多路状态、录制开关、单路/多路实时预览。
ApplicationWindow {
id: win
title: "FPV Relay 控制台"
width: 1220
height: 820
minimumWidth: 1000
minimumHeight: 640
visible: true
color: FluTheme.windowBackgroundColor
// 根据名称查该路信号强度(供预览 OSD 用)
function rssiOf(n) {
const a = backend.drones
for (let i = 0; i < a.length; ++i) {
if (a[i].name === n)
return a[i].rssi
}
return 0
}
function openSettings() {
const w = settingsComp.createObject(win.contentItem, {})
if (w)
w.show()
}
function openCfgEdit() {
const w = cfgEditComp.createObject(win.contentItem, {})
if (w)
w.show()
}
function openRecordings() {
const w = recComp.createObject(win.contentItem, {
items: backend.listRecordings()
})
if (w)
w.show()
}
function openMultiView() {
const arr = backend.drones.filter(function (d) {
return !!d.previewUrl
})
if (arr.length === 0)
return
const w = multiviewComp.createObject(win.contentItem, {
items: arr
})
if (w)
w.show()
}
Component.onCompleted: {
FluTheme.darkMode = backend.darkMode ? FluThemeType.Dark : FluThemeType.Light
FluTheme.animationEnabled = true
}
Connections {
target: backend
function onDarkModeChanged() {
FluTheme.darkMode = backend.darkMode ? FluThemeType.Dark : FluThemeType.Light
}
}
FileDialog {
id: fileDialog
title: "选择配置文件"
nameFilters: ["配置文件 (*.yaml *.yml)", "所有文件 (*)"]
onAccepted: backend.loadConfigUrl(fileDialog.selectedFile)
}
// 单路预览子窗口
Component {
id: previewComp
Window {
id: pv
property string url: ""
property string dname: ""
width: 900
height: 540
visible: true
color: "black"
title: dname + " 预览"
onClosing: (close) => {
close.accepted = true
Qt.callLater(function () {
pv.destroy()
})
}
VideoItem {
id: vi
anchors.fill: parent
url: pv.url
active: true
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
onDoubleClicked: pv.visibility = (pv.visibility === Window.FullScreen)
? Window.Windowed : Window.FullScreen
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 10
width: infoRow.width + 18
height: 28
radius: 6
color: "#99000000"
Row {
id: infoRow
anchors.centerIn: parent
spacing: 12
Text {
text: vi.fps + " fps"
color: "#dddddd"
font.pixelSize: 13
}
Text {
text: "信号 " + win.rssiOf(pv.dname) + " dBm"
color: "#dddddd"
font.pixelSize: 13
}
}
}
}
}
// 多路预览子窗口
Component {
id: multiviewComp
Window {
id: mv
property var items: []
property int cols: Math.max(1, Math.ceil(Math.sqrt(items.length)))
width: 1100
height: 700
visible: true
color: "black"
title: "多路预览"
onClosing: (close) => {
close.accepted = true
Qt.callLater(function () {
mv.destroy()
})
}
GridView {
anchors.fill: parent
anchors.margins: 6
cellWidth: width / mv.cols
cellHeight: cellWidth * 9 / 16 + 22
model: mv.items
clip: true
delegate: Column {
width: GridView.view.cellWidth
height: GridView.view.cellHeight
Text {
text: modelData.name
color: "#dddddd"
font.pixelSize: 13
height: 20
leftPadding: 4
}
Item {
width: parent.width
height: parent.height - 20
VideoItem {
id: tvi
anchors.fill: parent
url: modelData.previewUrl
active: true
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 6
width: tinfo.width + 12
height: 22
radius: 5
color: "#99000000"
Row {
id: tinfo
anchors.centerIn: parent
spacing: 8
Text {
text: tvi.fps + "fps"
color: "#dddddd"
font.pixelSize: 11
}
}
}
}
}
}
}
}
// 设置窗口
Component {
id: settingsComp
Settings {}
}
// 内置配置编辑器
Component {
id: cfgEditComp
Window {
id: ce
width: 780
height: 580
visible: true
title: "编辑配置"
onClosing: (close) => {
close.accepted = true
ce.destroy()
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
TextArea {
id: cfgText
text: backend.readConfig()
font.family: "monospace"
wrapMode: TextEdit.NoWrap
}
}
RowLayout {
Item { Layout.fillWidth: true }
FluButton {
text: "保存"
onClicked: backend.saveConfig(cfgText.text)
}
FluButton {
text: "关闭"
onClicked: ce.destroy()
}
}
}
}
}
// 本地录像回放窗口
Component {
id: filePlayComp
Window {
id: fp
property string file: ""
width: 900
height: 540
visible: true
color: "black"
title: "回放 " + file
onClosing: (close) => {
close.accepted = true
Qt.callLater(function () {
fp.destroy()
})
}
VideoItem {
anchors.fill: parent
file: fp.file
active: true
}
}
}
// 录像列表窗口
Component {
id: recComp
Window {
id: rw
property var items: []
width: 780
height: 540
visible: true
title: "录像"
onClosing: (close) => {
close.accepted = true
rw.destroy()
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
RowLayout {
FluText {
text: "目录: " + backend.recordingsDir()
color: FluTheme.fontSecondaryColor
}
Item { Layout.fillWidth: true }
FluButton {
text: "刷新"
onClicked: rw.items = backend.listRecordings()
}
}
ListView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: rw.items
delegate: RowLayout {
width: ListView.view.width
spacing: 8
FluText {
text: modelData.drone + " / " + modelData.name
Layout.fillWidth: true
elide: Text.ElideMiddle
color: FluTheme.fontPrimaryColor
}
FluText {
text: modelData.sizeMB + " MB"
color: FluTheme.fontSecondaryColor
}
FluText {
text: modelData.time
color: FluTheme.fontSecondaryColor
}
FluButton {
text: "播放"
onClicked: {
const w = filePlayComp.createObject(win.contentItem, {
file: modelData.path
})
if (w)
w.show()
}
}
FluButton {
text: "转MP4"
onClicked: backend.transcodeToMp4(modelData.path)
}
}
}
}
}
}
// 窗口级共享右键菜单(避免随卡片刷新被销毁)
FluMenu {
id: ctxMenu
property var drone: ({})
FluMenuItem {
text: "预览实时画面"
enabled: !!ctxMenu.drone.previewUrl
onTriggered: {
const d = ctxMenu.drone
const w = previewComp.createObject(win.contentItem, {
url: d.previewUrl,
dname: d.name
})
if (w)
w.show()
}
}
FluMenuItem {
text: ctxMenu.drone.recording === true ? "停止录制" : "开始录制"
enabled: backend.running
onTriggered: backend.setRecording(ctxMenu.drone.name, !(ctxMenu.drone.recording === true))
}
}
// “更多”菜单
FluMenu {
id: moreMenu
FluMenuItem {
text: "设置"
onTriggered: openSettings()
}
FluMenuItem {
text: "编辑配置(YAML)"
onTriggered: openCfgEdit()
}
FluMenuItem {
text: "录像"
onTriggered: openRecordings()
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 12
// 顶部工具条
FluFrame {
Layout.fillWidth: true
Layout.preferredHeight: 56
radius: 8
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 8
FluText {
text: "FPV Relay"
font.bold: true
font.pixelSize: 20
color: FluTheme.fontPrimaryColor
}
FluButton { text: "选择配置"; onClicked: fileDialog.open() }
FluButton { text: "加载"; onClicked: backend.loadConfig(backend.configPath) }
FluButton {
text: "启动"
enabled: !backend.running
onClicked: backend.start()
}
FluButton {
text: "停止"
enabled: backend.running
onClicked: backend.stop()
}
FluButton {
text: "多路预览"
enabled: backend.running
onClicked: openMultiView()
}
FluButton {
text: "更多"
onClicked: moreMenu.popup()
}
FluText {
text: backend.statusText
color: FluTheme.primaryColor
Layout.fillWidth: true
}
FluText {
text: backend.uplinkText
color: "#58a6ff"
visible: backend.uplinkText.length > 0
}
FluButton {
text: backend.darkMode ? "浅色" : "深色"
onClicked: backend.darkMode = !backend.darkMode
}
}
}
// 多路状态卡片
GridView {
id: grid
Layout.fillWidth: true
Layout.fillHeight: true
cellWidth: 320
cellHeight: 230
model: backend.drones
clip: true
delegate: DroneCard {
width: 300
height: 210
drone: modelData
onMenuRequested: (drone, x, y) => {
ctxMenu.drone = drone
ctxMenu.popup(win.contentItem, x, y)
}
}
FluText {
anchors.centerIn: parent
visible: backend.drones.length === 0
text: backend.running ? "暂无数据…" : "加载配置并点击“启动”"
color: FluTheme.fontSecondaryColor
font.pixelSize: 16
}
}
// 日志
FluFrame {
Layout.fillWidth: true
Layout.preferredHeight: 200
radius: 8
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 6
RowLayout {
FluText {
text: "日志"
font.bold: true
color: FluTheme.fontPrimaryColor
}
Item { Layout.fillWidth: true }
FluButton { text: "清空"; onClicked: backend.clearLogs() }
}
ListView {
id: logView
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: backend.logs
delegate: Text {
text: modelData
color: FluTheme.fontSecondaryColor
font.family: "monospace"
font.pixelSize: 12
}
ScrollBar.vertical: ScrollBar {}
onCountChanged: positionViewAtEnd()
}
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import FluentUI 1.0
// 单路无人机状态卡片(基于 FluentUI 的 FluFrame/FluText)。
FluFrame {
id: card
property var drone: ({})
property bool online: drone.online === true
signal menuRequested(var drone, real x, real y)
width: 300
height: 210
radius: 8
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: (mouse) => {
const p = card.mapToItem(null, mouse.x, mouse.y)
card.menuRequested(card.drone, p.x, p.y)
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 8
RowLayout {
spacing: 8
Rectangle {
width: 12
height: 12
radius: 6
color: card.online ? "#2ecc71" : "#e74c3c"
SequentialAnimation on opacity {
running: card.online
loops: Animation.Infinite
NumberAnimation { to: 0.35; duration: 600 }
NumberAnimation { to: 1.0; duration: 600 }
}
}
FluText {
text: card.drone.name || ""
font.bold: true
font.pixelSize: 18
color: FluTheme.fontPrimaryColor
Layout.fillWidth: true
elide: Text.ElideRight
}
FluText {
text: (card.drone.codec || "").toUpperCase()
color: FluTheme.fontSecondaryColor
font.pixelSize: 12
}
FluText {
visible: card.drone.recording === true
text: "● REC"
color: "#e74c3c"
font.pixelSize: 12
font.bold: true
}
}
GridLayout {
columns: 2
columnSpacing: 24
rowSpacing: 4
FluText { text: "收包"; color: FluTheme.fontSecondaryColor; font.pixelSize: 13 }
FluText { text: card.drone.recv || 0; color: FluTheme.fontPrimaryColor; font.bold: true; font.pixelSize: 13 }
FluText { text: "转发"; color: FluTheme.fontSecondaryColor; font.pixelSize: 13 }
FluText { text: card.drone.forwarded || 0; color: FluTheme.fontPrimaryColor; font.bold: true; font.pixelSize: 13 }
FluText { text: "会话"; color: FluTheme.fontSecondaryColor; font.pixelSize: 13 }
FluText { text: card.drone.session || 0; color: FluTheme.fontPrimaryColor; font.pixelSize: 13 }
FluText { text: "解密错误"; color: FluTheme.fontSecondaryColor; font.pixelSize: 13 }
FluText {
text: card.drone.decErr || 0
color: (card.drone.decErr || 0) > 0 ? "#f39c12" : FluTheme.fontPrimaryColor
font.pixelSize: 13
}
FluText { text: "丢包"; color: FluTheme.fontSecondaryColor; font.pixelSize: 13 }
FluText {
text: card.drone.lost || 0
color: (card.drone.lost || 0) > 0 ? "#f39c12" : FluTheme.fontPrimaryColor
font.pixelSize: 13
}
FluText { text: "信号"; color: FluTheme.fontSecondaryColor; font.pixelSize: 13 }
FluText {
text: (card.drone.rssi || 0) === 0 ? "--" : (card.drone.rssi + " dBm")
color: (card.drone.rssi || 0) === 0 ? FluTheme.fontSecondaryColor
: ((card.drone.rssi < -70) ? "#f39c12"
: "#2ecc71")
font.pixelSize: 13
}
}
FluText {
text: card.online ? "在线" : "离线 / 等待信号"
color: card.online ? "#2ecc71" : FluTheme.fontSecondaryColor
font.pixelSize: 12
}
}
}
+367
View File
@@ -0,0 +1,367 @@
import QtQuick
import QtQuick.Window
import QtQuick.Controls
import QtQuick.Layouts
import FluentUI 1.0
// 设置界面:结构化编辑全部配置项,保存后写回 YAML(重启程序生效)。
Window {
id: sw
width: 900
height: 720
visible: true
title: "设置"
color: FluTheme.windowBackgroundColor
property var s: ({})
property var caps: []
property var ups: []
Component.onCompleted: {
s = backend.settings()
caps = s.captures || []
ups = s.uplinks || []
}
// 一行“标签 + 文本框”
component FieldText: RowLayout {
property string label
property string value
signal edited(string v)
Layout.fillWidth: true
FluText {
text: parent.label
color: FluTheme.fontSecondaryColor
Layout.preferredWidth: 130
}
FluTextBox {
Layout.fillWidth: true
text: parent.value
onTextEdited: parent.edited(text)
}
}
// 一行“标签 + 开关”
component FieldSwitch: RowLayout {
property string label
property bool value
signal toggled(bool v)
Layout.fillWidth: true
FluText {
text: parent.label
color: FluTheme.fontSecondaryColor
Layout.preferredWidth: 130
}
FluToggleSwitch {
checked: parent.value
onCheckedChanged: parent.toggled(checked)
}
}
// 区块标题
component SectionTitle: FluText {
Layout.topMargin: 8
color: FluTheme.primaryColor
font.bold: true
font.pixelSize: 16
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ColumnLayout {
width: sw.width - 40
spacing: 6
SectionTitle { text: "全局" }
FieldSwitch {
label: "状态页"
value: s.status ? s.status.enabled : false
onToggled: s.status.enabled = v
}
FieldText {
label: "状态页地址"
value: s.status ? s.status.address : ""
onEdited: s.status.address = v
}
FieldSwitch {
label: "RTSP"
value: s.rtsp ? s.rtsp.enabled : false
onToggled: s.rtsp.enabled = v
}
FieldText {
label: "RTSP 地址"
value: s.rtsp ? s.rtsp.address : ""
onEdited: s.rtsp.address = v
}
FieldText {
label: "RTSP UDP 基端口"
value: s.rtsp ? String(s.rtsp.udp_base) : "8000"
onEdited: s.rtsp.udp_base = parseInt(v) || 0
}
SectionTitle { text: "采集路" }
Repeater {
model: caps
delegate: FluFrame {
Layout.fillWidth: true
radius: 8
implicitHeight: capCol.implicitHeight + 20
ColumnLayout {
id: capCol
anchors.fill: parent
anchors.margins: 10
spacing: 4
RowLayout {
FluText {
text: "第 " + (index + 1) + " 路"
color: FluTheme.fontPrimaryColor
font.bold: true
Layout.fillWidth: true
}
FluButton {
text: "删除"
onClicked: {
caps.splice(index, 1)
caps = caps.slice()
}
}
}
FieldText {
label: "名称"
value: modelData.name
onEdited: modelData.name = v
}
RowLayout {
Layout.fillWidth: true
FluText { text: "类型"; color: FluTheme.fontSecondaryColor; Layout.preferredWidth: 130 }
FluComboBox {
Layout.fillWidth: true
model: ["wfb", "udp"]
currentIndex: modelData.type === "udp" ? 1 : 0
onActivated: modelData.type = model[currentIndex]
}
}
FieldText {
label: "网卡(iface)"
value: modelData.iface
onEdited: modelData.iface = v
}
FieldText {
label: "USB pid(0=非USB)"
value: modelData.usb_pid === undefined ? "0" : String(modelData.usb_pid)
onEdited: modelData.usb_pid = parseInt(v) || 0
}
FieldText {
label: "USB vid"
value: modelData.usb_vid === undefined ? "3034" : String(modelData.usb_vid)
onEdited: modelData.usb_vid = parseInt(v) || 0
}
FieldText {
label: "监听(udp)"
value: modelData.listen
onEdited: modelData.listen = v
}
FieldText {
label: "密钥 key"
value: modelData.key
onEdited: modelData.key = v
}
FieldText {
label: "link_id"
value: String(modelData.link_id)
onEdited: modelData.link_id = parseInt(v) || 0
}
FieldText {
label: "radio_port"
value: String(modelData.radio_port)
onEdited: modelData.radio_port = parseInt(v) || 0
}
FieldText {
label: "频道 channel"
value: String(modelData.channel)
onEdited: modelData.channel = parseInt(v) || 0
}
FieldText {
label: "带宽"
value: modelData.bandwidth
onEdited: modelData.bandwidth = v
}
FieldText {
label: "监管域"
value: modelData.region
onEdited: modelData.region = v
}
FieldText {
label: "编码"
value: modelData.codec
onEdited: modelData.codec = v
}
FieldText {
label: "转发目标(逗号分隔)"
value: modelData.forward
onEdited: modelData.forward = v
}
FieldSwitch {
label: "录制"
value: modelData.record.enabled
onToggled: modelData.record.enabled = v
}
FieldText {
label: "录制目录"
value: modelData.record.dir
onEdited: modelData.record.dir = v
}
FieldText {
label: "分片秒"
value: String(modelData.record.segment_seconds)
onEdited: modelData.record.segment_seconds = parseInt(v) || 0
}
FieldText {
label: "分片MB"
value: String(modelData.record.segment_mb)
onEdited: modelData.record.segment_mb = parseInt(v) || 0
}
FieldSwitch {
label: "遥测下行"
value: modelData.mavlink.enabled
onToggled: modelData.mavlink.enabled = v
}
FieldText {
label: "遥测 radio_port"
value: String(modelData.mavlink.radio_port)
onEdited: modelData.mavlink.radio_port = parseInt(v) || 0
}
FieldText {
label: "遥测转发目标"
value: modelData.mavlink.target
onEdited: modelData.mavlink.target = v
}
}
}
}
FluButton {
text: "添加一路"
onClicked: {
caps.push({
"name": "drone" + (caps.length + 1),
"type": "wfb",
"iface": "wfb0",
"listen": "",
"key": "/home/misaki/wfb-ng/gs.key",
"link_id": 7669206,
"radio_port": 0,
"epoch": 0,
"channel": 161,
"bandwidth": "HT20",
"region": "BO",
"codec": "h265",
"forward": "",
"record": {
"enabled": false,
"dir": "records",
"segment_seconds": 300,
"segment_mb": 0
},
"mavlink": {
"enabled": false,
"radio_port": 16,
"target": "127.0.0.1:14550"
}
})
caps = caps.slice()
}
}
SectionTitle { text: "上行链路" }
Repeater {
model: ups
delegate: FluFrame {
Layout.fillWidth: true
radius: 8
implicitHeight: upCol.implicitHeight + 20
ColumnLayout {
id: upCol
anchors.fill: parent
anchors.margins: 10
spacing: 4
RowLayout {
FluText {
text: "上行 " + (index + 1)
color: FluTheme.fontPrimaryColor
font.bold: true
Layout.fillWidth: true
}
FluButton {
text: "删除"
onClicked: {
ups.splice(index, 1)
ups = ups.slice()
}
}
}
FieldText { label: "网卡"; value: modelData.iface; onEdited: modelData.iface = v }
FieldText { label: "密钥"; value: modelData.key; onEdited: modelData.key = v }
FieldText { label: "link_id"; value: String(modelData.link_id); onEdited: modelData.link_id = parseInt(v) || 0 }
FieldText { label: "radio_port"; value: String(modelData.radio_port); onEdited: modelData.radio_port = parseInt(v) || 0 }
FieldText { label: "频道"; value: String(modelData.channel); onEdited: modelData.channel = parseInt(v) || 0 }
FieldText { label: "带宽"; value: String(modelData.bandwidth); onEdited: modelData.bandwidth = parseInt(v) || 0 }
FieldText { label: "MCS"; value: String(modelData.mcs_index); onEdited: modelData.mcs_index = parseInt(v) || 0 }
FieldText { label: "FEC k"; value: String(modelData.fec_k); onEdited: modelData.fec_k = parseInt(v) || 0 }
FieldText { label: "FEC n"; value: String(modelData.fec_n); onEdited: modelData.fec_n = parseInt(v) || 0 }
FieldText { label: "mavlink UDP 端口"; value: String(modelData.udp_port); onEdited: modelData.udp_port = parseInt(v) || 0 }
}
}
}
FluButton {
text: "添加上行"
onClicked: {
ups.push({
"iface": "wfb0",
"key": "/home/misaki/wfb-ng/gs.key",
"link_id": 7669206,
"radio_port": 160,
"epoch": 0,
"mcs_index": 1,
"bandwidth": 20,
"channel": 161,
"region": "BO",
"fec_k": 2,
"fec_n": 4,
"udp_port": 14551,
"fec_delay": 0
})
ups = ups.slice()
}
}
Item { Layout.preferredHeight: 10 }
}
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
FluButton {
text: "保存"
onClicked: {
s.captures = caps
s.uplinks = ups
backend.applySettings(s)
sw.destroy()
}
}
FluButton {
text: "取消"
onClicked: sw.destroy()
}
}
}
}
+258
View File
@@ -0,0 +1,258 @@
#include "gui/video_item.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QFileInfo>
#include <QPainter>
#include <QProcess>
#include <QTimer>
#include <spdlog/spdlog.h>
namespace {
bool isWs(char c)
{
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
}
} // namespace
VideoItem::VideoItem(QQuickItem* parent) : QQuickPaintedItem(parent)
{
retry_ = new QTimer(this);
retry_->setSingleShot(true);
retry_->setInterval(1000);
connect(retry_, &QTimer::timeout, this, [this] {
if (active_) {
start();
}
});
}
VideoItem::~VideoItem()
{
stop();
}
void VideoItem::setUrl(const QString& u)
{
if (url_ == u) return;
url_ = u;
emit urlChanged();
if (active_) restart();
}
void VideoItem::setFile(const QString& f)
{
if (file_ == f) return;
file_ = f;
emit fileChanged();
if (active_) restart();
}
void VideoItem::setActive(bool a)
{
if (active_ == a) return;
active_ = a;
emit activeChanged();
if (active_) {
start();
} else {
stop();
update(); // 正常停止时刷新(析构路径不会走这里)
}
}
QString VideoItem::ffmpegPath() const
{
const QString beside = QCoreApplication::applicationDirPath() + "/ffmpeg.exe";
if (QFileInfo::exists(beside)) {
return beside;
}
return "ffmpeg"; // 退回 PATH
}
void VideoItem::start()
{
if (proc_ != nullptr) {
return;
}
const bool fromFile = !file_.isEmpty();
const QString input = fromFile ? file_ : url_;
if (input.isEmpty()) {
return;
}
QStringList args;
args << "-hide_banner" << "-loglevel" << "error";
if (!fromFile) {
// 低延迟拉 RTSP:不缓冲、不探测、不重排、单线程解码(避免帧线程引入延迟)
args << "-rtsp_transport" << "tcp"
<< "-fflags" << "nobuffer" << "-flags" << "low_delay"
<< "-avioflags" << "direct"
<< "-probesize" << "32" << "-analyzeduration" << "0"
<< "-max_delay" << "0" << "-reorder_queue_size" << "0";
}
args << "-i" << input;
// 输出 PPM 帧序列到 stdout(带尺寸头,便于逐帧解析)
args << "-an" << "-threads" << "1" << "-fps_mode" << "passthrough" << "-flush_packets" << "1"
<< "-f" << "image2pipe" << "-vcodec" << "ppm" << "-";
buf_.clear();
width_ = 0;
height_ = 0;
frameCount_ = 0;
fpsTs_ = QDateTime::currentMSecsSinceEpoch();
auto* p = new QProcess(this);
proc_ = p;
p->setProcessChannelMode(QProcess::SeparateChannels);
connect(p, &QProcess::readyReadStandardOutput, this, [this] { onReadyRead(); });
connect(p, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
[this](int, QProcess::ExitStatus) {
if (proc_ != nullptr) {
proc_->deleteLater();
proc_ = nullptr;
}
if (active_) {
retry_->start(); // 流还没就绪(如 RTSP 404)时自动重试
}
});
p->start(ffmpegPath(), args);
spdlog::debug("[预览] ffmpeg 启动: {}", input.toStdString());
}
void VideoItem::stop()
{
retry_->stop();
if (proc_ != nullptr) {
QProcess* p = proc_;
proc_ = nullptr;
// 先断开所有回调,再把 QProcess 脱离父子关系,交给事件循环删除。
// 这样 VideoItem 析构时不会再同步析构 QProcess(避免二次释放/重入崩溃)。
disconnect(p, nullptr, this, nullptr);
p->setParent(nullptr);
p->kill();
p->deleteLater();
}
buf_.clear();
width_ = 0;
height_ = 0;
image_ = QImage();
// 注意:这里不调用 update()——stop() 可能在析构路径被调用,此时刷新场景图不安全。
}
void VideoItem::restart()
{
stop();
if (active_) {
start();
}
}
void VideoItem::onReadyRead()
{
if (proc_ == nullptr) {
return;
}
buf_.append(proc_->readAllStandardOutput());
parseFrames();
}
void VideoItem::parseFrames()
{
for (;;) {
if (width_ == 0 || height_ == 0) {
// 跳过前导空白,期望 "P6"
int i = 0;
while (i < buf_.size() && isWs(buf_[i])) ++i;
if (i > 0) {
buf_.remove(0, i);
}
if (buf_.size() < 2) {
return;
}
if (buf_[0] != 'P' || buf_[1] != '6') {
// 不同步:丢弃到下一个 'P'
const int p = buf_.indexOf('P', 1);
if (p < 0) {
buf_.clear();
} else {
buf_.remove(0, p);
}
continue;
}
// 解析 P6 后的三个 token: width height maxval
int pos = 2;
int toks[3] = {0, 0, 0};
int got = 0;
while (got < 3) {
while (pos < buf_.size() && isWs(buf_[pos])) ++pos;
if (pos >= buf_.size()) {
return; // 头未收全
}
if (buf_[pos] == '#') { // 注释行
while (pos < buf_.size() && buf_[pos] != '\n') ++pos;
continue;
}
int start = pos;
while (pos < buf_.size() && !isWs(buf_[pos])) ++pos;
if (pos >= buf_.size()) {
return; // token 未收全
}
toks[got++] = QByteArray(buf_.constData() + start, pos - start).toInt();
}
// PPM 头在 maxval 后只跟"一个"空白字符。只能跳一个,否则会把像素首字节
// (可能是 0x0a/0x20/0x09/0x0d)误吞,导致整帧错位(RGB 串色 + 画面滚动)。
if (pos < buf_.size()) {
++pos;
}
buf_.remove(0, pos);
width_ = toks[0];
height_ = toks[1];
if (width_ <= 0 || height_ <= 0) {
width_ = 0;
height_ = 0;
return;
}
}
const qint64 need = static_cast<qint64>(width_) * height_ * 3;
if (buf_.size() < need) {
return; // 帧未收全
}
const QImage img(reinterpret_cast<const uchar*>(buf_.constData()), width_, height_,
width_ * 3, QImage::Format_RGB888);
image_ = img.copy();
buf_.remove(0, static_cast<int>(need));
// 关键:消费完一帧后清空尺寸,下一帧必须重新解析 PPM 头;
// 否则会把下一帧的 "P6 w h 255" 头当成像素数据,导致逐帧偏移(画面滚动/RGB 串色)。
width_ = 0;
height_ = 0;
update();
++frameCount_;
const qint64 now = QDateTime::currentMSecsSinceEpoch();
if (now - fpsTs_ >= 1000) {
fps_ = static_cast<int>(frameCount_ * 1000 / (now - fpsTs_));
frameCount_ = 0;
fpsTs_ = now;
emit statsChanged();
}
}
}
void VideoItem::paint(QPainter* painter)
{
if (image_.isNull()) {
painter->fillRect(boundingRect(), Qt::black);
painter->setPen(Qt::gray);
painter->drawText(boundingRect(), Qt::AlignCenter, "等待视频…");
return;
}
const QRectF target = boundingRect();
const QSizeF scaled = image_.size().scaled(target.size().toSize(), Qt::KeepAspectRatio);
const QRectF dst((target.width() - scaled.width()) / 2,
(target.height() - scaled.height()) / 2, scaled.width(), scaled.height());
painter->fillRect(target, Qt::black);
painter->drawImage(dst, image_);
}
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <QImage>
#include <QQuickPaintedItem>
#include <QString>
#include <atomic>
#include <cstdint>
class QProcess;
class QTimer;
// 实时视频预览控件(基于 ffmpeg.exe 解码,H264/H265 通吃)。
// - 网络预览:url = "rtsp://127.0.0.1:<port>/<name>"(本程序内置 RTSP 输出)
// - 本地回放:file = 裸流/容器文件路径
// ffmpeg 输出 PPM 帧到 stdout,这里逐帧解析成 QImage 绘制。
class VideoItem : public QQuickPaintedItem {
Q_OBJECT
Q_PROPERTY(QString url READ url WRITE setUrl NOTIFY urlChanged)
Q_PROPERTY(QString file READ file WRITE setFile NOTIFY fileChanged)
Q_PROPERTY(bool active READ active WRITE setActive NOTIFY activeChanged)
Q_PROPERTY(int latencyMs READ latencyMs NOTIFY statsChanged)
Q_PROPERTY(int fps READ fps NOTIFY statsChanged)
public:
explicit VideoItem(QQuickItem* parent = nullptr);
~VideoItem() override;
void paint(QPainter* painter) override;
QString url() const { return url_; }
void setUrl(const QString& u);
QString file() const { return file_; }
void setFile(const QString& f);
bool active() const { return active_; }
void setActive(bool a);
int latencyMs() const { return latencyMs_; }
int fps() const { return fps_; }
signals:
void urlChanged();
void fileChanged();
void activeChanged();
void statsChanged();
private:
void start();
void stop();
void restart();
void onReadyRead();
void parseFrames();
QString ffmpegPath() const;
QString url_;
QString file_;
bool active_ = false;
int latencyMs_ = 0;
int fps_ = 0;
QProcess* proc_ = nullptr;
QTimer* retry_ = nullptr;
QByteArray buf_;
int width_ = 0;
int height_ = 0;
int frameCount_ = 0;
qint64 fpsTs_ = 0;
QImage image_;
};
+237
View File
@@ -0,0 +1,237 @@
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <memory>
#include <thread>
#include <vector>
#include <CLI/CLI.hpp>
#include <spdlog/spdlog.h>
#include "capture/capture.h"
#include "capture/wfb_uplink.h"
#include "common/log.h"
#include "config/config.h"
#include "relay/stream.h"
#include "rtsp/rtsp_server.h"
#include "status/http_server.h"
#ifdef _WIN32
#include <winsock2.h>
#endif
#if defined(FPC_HAVE_WFB_CAPTURE)
#include <sodium.h>
#endif
namespace {
std::atomic<bool> g_stop {false};
// 收到退出信号时置位,主循环据此收尾。
void onSignal(int)
{
g_stop = true;
}
// 简单 JSON 字符串转义。
std::string jsonEscape(const std::string& in)
{
std::string out;
out.reserve(in.size());
for (char c : in) {
if (c == '"' || c == '\\') {
out.push_back('\\');
}
out.push_back(c);
}
return out;
}
} // namespace
// 程序入口。
// 解析命令行、加载配置、创建每一路的采集与转发,然后进入运行循环,
// 周期性打印各路收包/转发统计,收到 Ctrl-C 后退出。
int main(int argc, char** argv)
{
#ifdef _WIN32
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);
#endif
CLI::App app {"fpv-relay (C++): 多路图传采集 + 低延迟局域网转发"};
std::string configPath = "config.yaml";
std::string statusAddr;
bool verbose = false;
int runSeconds = 0;
app.add_option("-c,--config", configPath, "YAML 配置文件路径");
app.add_option("-s,--status", statusAddr, "启用状态页并指定监听地址, 如 :8080");
app.add_flag("-v,--verbose", verbose, "输出调试日志");
app.add_option("--seconds", runSeconds, "运行 N 秒后自动退出(测试用,0=一直运行)");
CLI11_PARSE(app, argc, argv);
fpv::initLog(verbose);
#if defined(FPC_HAVE_WFB_CAPTURE)
// wfb-ng 解密依赖 libsodium,必须先初始化
if (sodium_init() < 0) {
spdlog::error("libsodium 初始化失败");
return 1;
}
#endif
fpv::Config cfg;
try {
cfg = fpv::loadConfig(configPath);
} catch (const std::exception& e) {
spdlog::error("配置错误: {}", e.what());
return 1;
}
// 为每一路创建数据流(含转发目标)与采集器
std::vector<std::unique_ptr<fpv::Stream>> streams;
std::vector<fpv::CapturePtr> captures;
streams.reserve(cfg.captures.size());
for (const auto& c : cfg.captures) {
auto stream = std::make_unique<fpv::Stream>(c.name, c.codec, c.forward, c.record);
spdlog::info("[{}] 转发目标 {} 个, codec={}, 录制={}", c.name, c.forward.size(), c.codec,
c.record.enabled ? "开" : "关");
try {
captures.push_back(fpv::createCapture(c, *stream));
} catch (const std::exception& e) {
spdlog::error("[{}] 创建采集失败: {}", c.name, e.what());
return 1;
}
streams.push_back(std::move(stream));
}
for (std::size_t i = 0; i < captures.size(); ++i) {
try {
captures[i]->start();
} catch (const std::exception& e) {
// 单路启动失败(如端口被占)不应影响其它路
spdlog::error("采集 {} 启动失败: {}", cfg.captures[i].name, e.what());
}
}
// 启动上行链路(电脑→无人机)
std::vector<std::unique_ptr<fpv::WfbUplink>> uplinks;
for (const auto& u : cfg.uplinks) {
auto up = std::make_unique<fpv::WfbUplink>(u);
up->start();
uplinks.push_back(std::move(up));
}
if (!uplinks.empty()) {
spdlog::info("上行链路已启动 {} 路 (mavlink UDP {})", uplinks.size(),
cfg.uplinks.front().udpPort);
}
// 可选:HTTP 状态页
if (!statusAddr.empty()) {
cfg.status.enabled = true;
cfg.status.address = statusAddr;
}
std::unique_ptr<fpv::StatusServer> statusServer;
if (cfg.status.enabled) {
auto jsonProvider = [&captures]() {
std::string j = "{\"captures\":[";
for (std::size_t i = 0; i < captures.size(); ++i) {
const fpv::StreamStatus s = captures[i]->status();
if (i > 0) j += ",";
j += "{\"name\":\"" + jsonEscape(s.name) + "\"";
j += ",\"codec\":\"" + jsonEscape(s.codec) + "\"";
j += ",\"online\":";
j += (s.online ? "true" : "false");
j += ",\"recv\":" + std::to_string(s.recv);
j += ",\"forwarded\":" + std::to_string(s.forwarded);
j += ",\"all\":" + std::to_string(s.all);
j += ",\"data\":" + std::to_string(s.data);
j += ",\"session\":" + std::to_string(s.session);
j += ",\"decErr\":" + std::to_string(s.decErr);
j += ",\"lost\":" + std::to_string(s.lost);
j += ",\"rssi\":" + std::to_string(s.rssi);
j += ",\"recording\":";
j += (s.recording ? "true" : "false");
j += "}";
}
j += "]}";
return j;
};
auto metricsProvider = [&captures]() {
std::string m;
for (const auto& cap : captures) {
const fpv::StreamStatus s = cap->status();
const std::string n = jsonEscape(s.name);
m += "fpv_relay_online{capture=\"" + n + "\"} " +
std::to_string(s.online ? 1 : 0) + "\n";
m += "fpv_relay_recv{capture=\"" + n + "\"} " + std::to_string(s.recv) + "\n";
m += "fpv_relay_forwarded{capture=\"" + n + "\"} " +
std::to_string(s.forwarded) + "\n";
m += "fpv_relay_session{capture=\"" + n + "\"} " + std::to_string(s.session) +
"\n";
m += "fpv_relay_dec_err{capture=\"" + n + "\"} " + std::to_string(s.decErr) +
"\n";
m += "fpv_relay_lost{capture=\"" + n + "\"} " + std::to_string(s.lost) + "\n";
m += "fpv_relay_rssi{capture=\"" + n + "\"} " + std::to_string(s.rssi) + "\n";
m += "fpv_relay_recording{capture=\"" + n + "\"} " +
std::to_string(s.recording ? 1 : 0) + "\n";
}
return m;
};
statusServer =
std::make_unique<fpv::StatusServer>(cfg.status.address, jsonProvider, metricsProvider);
statusServer->start();
}
// 可选:RTSP 兼容输出
std::unique_ptr<fpv::RtspServer> rtspServer;
if (cfg.rtsp.enabled) {
rtspServer = std::make_unique<fpv::RtspServer>(cfg.rtsp.address, cfg.rtsp.udpBase);
for (const auto& s : streams) {
rtspServer->addStream(s.get());
}
try {
rtspServer->start();
} catch (const std::exception& e) {
spdlog::error("RTSP 启动失败: {}", e.what());
}
}
std::signal(SIGINT, onSignal);
std::signal(SIGTERM, onSignal);
spdlog::info("fpv-relay 运行中,共 {} 路,Ctrl-C 退出", captures.size());
// 周期性统计;以 200ms 步进检查退出标志,Ctrl-C 能快速响应
const auto startTs = std::chrono::steady_clock::now();
int tick = 0;
while (!g_stop.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
if (runSeconds > 0 &&
std::chrono::steady_clock::now() - startTs >= std::chrono::seconds(runSeconds)) {
break;
}
if (++tick < 25) {
continue;
}
tick = 0;
for (const auto& s : streams) {
spdlog::info("[{}] recv={} forwarded={}", s->name(), s->recvCount(), s->forwardCount());
}
}
spdlog::info("正在退出...");
for (auto& cap : captures) {
cap->stop();
}
if (statusServer) {
statusServer->stop();
}
if (rtspServer) {
rtspServer->stop();
}
return 0;
}
+107
View File
@@ -0,0 +1,107 @@
#include "relay/dvr_sink.h"
#include <filesystem>
#include <system_error>
#include <spdlog/spdlog.h>
namespace fpv {
namespace {
// 生成 "YYYYmmdd_HHMMSS" 形式的时间戳。
std::string timeStamp(std::time_t t)
{
std::tm tm {};
#ifdef _WIN32
localtime_s(&tm, &t);
#else
localtime_r(&t, &tm);
#endif
char buf[32] = {0};
std::strftime(buf, sizeof(buf), "%Y%m%d_%H%M%S", &tm);
return buf;
}
} // namespace
DvrSink::DvrSink(std::string name, std::string codec, std::string dir, int segmentSeconds,
int segmentMB)
: name_(std::move(name)),
codec_(std::move(codec)),
dir_(std::move(dir)),
segmentSeconds_(segmentSeconds > 0 ? segmentSeconds : 300),
segmentMB_(segmentMB > 0 ? segmentMB : 0)
{
if (codec_ == "h265") {
depacketizer_ = std::make_unique<H265Depacketizer>(
[this](const std::uint8_t* nal, std::size_t size) { writeNal(nal, size); });
} else {
spdlog::warn("[{}] DVR 目前仅支持 h265,codec={} 不录制", name_, codec_);
}
}
DvrSink::~DvrSink()
{
closeSegment();
}
void DvrSink::openSegment()
{
std::error_code ec;
const std::filesystem::path dir = std::filesystem::path(dir_) / name_;
std::filesystem::create_directories(dir, ec);
segmentStart_ = std::time(nullptr);
segmentBytes_ = 0;
const std::string path = (dir / (name_ + "_" + timeStamp(segmentStart_) + ".h265")).string();
fp_ = std::fopen(path.c_str(), "wb");
if (fp_ == nullptr) {
spdlog::error("[{}] 无法创建录制文件: {}", name_, path);
return;
}
spdlog::info("[{}] 开始录制: {}", name_, path);
}
void DvrSink::closeSegment()
{
if (fp_ != nullptr) {
std::fclose(fp_);
fp_ = nullptr;
}
}
void DvrSink::writeNal(const std::uint8_t* nal, std::size_t size)
{
if (fp_ == nullptr) {
openSegment();
if (fp_ == nullptr) {
return;
}
}
// 按时间或大小分片
const bool byTime = (std::time(nullptr) - segmentStart_ >= segmentSeconds_);
const bool bySize = (segmentMB_ > 0 &&
segmentBytes_ >= static_cast<std::uint64_t>(segmentMB_) * 1024 * 1024);
if (byTime || bySize) {
closeSegment();
openSegment();
if (fp_ == nullptr) {
return;
}
}
static const std::uint8_t startCode[4] = {0x00, 0x00, 0x00, 0x01};
std::fwrite(startCode, 1, sizeof(startCode), fp_);
std::fwrite(nal, 1, size, fp_);
segmentBytes_ += sizeof(startCode) + size;
}
void DvrSink::onPacket(const RtpPacket& pkt)
{
if (depacketizer_ == nullptr || pkt.payload == nullptr) {
return;
}
depacketizer_->input(pkt.payload, pkt.payloadSize);
}
} // namespace fpv
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <cstdint>
#include <cstdio>
#include <ctime>
#include <memory>
#include <string>
#include "relay/h265.h"
#include "relay/sink.h"
namespace fpv {
// DVR 录制出口。
// 把该路的 H.265 载荷还原成 Annex-B 裸流写入文件,按时间分片,便于后续当数据集/复盘。
class DvrSink : public Sink {
public:
DvrSink(std::string name, std::string codec, std::string dir, int segmentSeconds,
int segmentMB);
~DvrSink() override;
void onPacket(const RtpPacket& pkt) override;
private:
// 打开一个新的分片文件。
void openSegment();
// 关闭当前文件。
void closeSegment();
// 写一个 NAL(自动加起始码)。
void writeNal(const std::uint8_t* nal, std::size_t size);
std::string name_;
std::string codec_;
std::string dir_;
int segmentSeconds_;
int segmentMB_;
std::uint64_t segmentBytes_ = 0;
std::FILE* fp_ = nullptr;
std::time_t segmentStart_ = 0;
std::unique_ptr<H265Depacketizer> depacketizer_;
};
} // namespace fpv
+105
View File
@@ -0,0 +1,105 @@
#include "relay/h265.h"
namespace fpv {
namespace {
constexpr std::uint8_t kAp = 48; // 聚合包
constexpr std::uint8_t kFu = 49; // 分片单元
constexpr std::uint8_t kVps = 32;
constexpr std::uint8_t kSps = 33;
constexpr std::uint8_t kPps = 34;
} // namespace
H265Depacketizer::H265Depacketizer(NalCallback cb) : cb_(std::move(cb)) {}
void H265Depacketizer::emitNal(const std::uint8_t* nal, std::size_t size)
{
if (size < 2) {
return;
}
switch ((nal[0] >> 1) & 0x3f) {
case kVps:
vps_.assign(nal, nal + size);
break;
case kSps:
sps_.assign(nal, nal + size);
break;
case kPps:
pps_.assign(nal, nal + size);
break;
default:
break;
}
if (cb_) {
cb_(nal, size);
}
}
void H265Depacketizer::input(const std::uint8_t* payload, std::size_t size)
{
if (payload == nullptr || size < 2) {
return;
}
const std::uint8_t nalType = (payload[0] >> 1) & 0x3f;
if (nalType == kAp) {
// 聚合包:头部 2 字节,其后可能有一个 DONL(2 字节),再是 [2 字节长度 + NAL] 列表。
// 先校验某一种解析是否“正好到结尾”,通过后再输出,避免把错误候选写出去。
for (int donl = 0; donl <= 1; ++donl) {
std::size_t off = 2 + (donl ? 2 : 0);
std::vector<std::size_t> offsets;
std::vector<std::size_t> lengths;
bool ok = true;
while (off + 2 <= size) {
const std::size_t len =
(static_cast<std::size_t>(payload[off]) << 8) | payload[off + 1];
off += 2;
if (len < 2 || off + len > size) {
ok = false;
break;
}
offsets.push_back(off);
lengths.push_back(len);
off += len;
}
if (ok && !offsets.empty() && off == size) {
for (std::size_t i = 0; i < offsets.size(); ++i) {
emitNal(payload + offsets[i], lengths[i]);
}
return;
}
}
return;
}
if (nalType == kFu) {
// 分片:payload[0..1]=载荷头, payload[2]=FU 头(bit7=S, bit6=E, 低6位=类型)
if (size < 4) {
return;
}
const bool start = (payload[2] & 0x80) != 0;
const bool end = (payload[2] & 0x40) != 0;
const std::uint8_t fuType = payload[2] & 0x3f;
if (start) {
fu_.clear();
const std::uint8_t h0 = static_cast<std::uint8_t>((payload[0] & 0x81) | (fuType << 1));
fu_.push_back(h0);
fu_.push_back(payload[1]);
}
if (!fu_.empty()) {
fu_.insert(fu_.end(), payload + 3, payload + size);
if (end) {
emitNal(fu_.data(), fu_.size());
fu_.clear();
}
}
return;
}
// 单 NAL 单元
emitNal(payload, size);
}
} // namespace fpv
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include <vector>
namespace fpv {
// H.265(HEVC) RTP 载荷解包器。
// 把 RFC 7798 的单 NAL / 聚合包(AP) / 分片单元(FU) 还原成完整的 NAL 单元,
// 通过回调交给上层(录制落盘或 RTSP 取参数集)。同时提取 VPS/SPS/PPS 供 SDP 使用。
class H265Depacketizer {
public:
using NalCallback = std::function<void(const std::uint8_t*, std::size_t)>;
explicit H265Depacketizer(NalCallback cb);
// 输入一个 RTP 载荷。
void input(const std::uint8_t* payload, std::size_t size);
bool hasParams() const { return !vps_.empty() && !sps_.empty() && !pps_.empty(); }
const std::vector<std::uint8_t>& vps() const { return vps_; }
const std::vector<std::uint8_t>& sps() const { return sps_; }
const std::vector<std::uint8_t>& pps() const { return pps_; }
private:
// 输出一个完整 NAL:记录参数集并回调。
void emitNal(const std::uint8_t* nal, std::size_t size);
NalCallback cb_;
std::vector<std::uint8_t> fu_; // FU 分片重组缓冲
std::vector<std::uint8_t> vps_;
std::vector<std::uint8_t> sps_;
std::vector<std::uint8_t> pps_;
};
} // namespace fpv
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace fpv {
// 解析后的 RTP 包视图。
// raw/rawSize 指向原始数据报(用于原样转发);payload 指向 RTP 载荷(用于录制/解析)。
struct RtpPacket {
const std::uint8_t* raw = nullptr;
std::size_t rawSize = 0;
const std::uint8_t* payload = nullptr;
std::size_t payloadSize = 0;
std::uint8_t payloadType = 0;
bool marker = false;
std::uint16_t sequence = 0;
std::uint32_t timestamp = 0;
std::uint32_t ssrc = 0;
};
// 解析 RTP 头部(不做扩展头内容的语义解释,仅按长度跳过)。
// 成功返回 true,失败(非 RTP/长度不足)返回 false。
inline bool parseRtp(const std::uint8_t* data, std::size_t size, RtpPacket& out)
{
if (data == nullptr || size < 12) {
return false;
}
if ((data[0] >> 6) != 2) { // RTP 版本必须为 2
return false;
}
const std::size_t cc = data[0] & 0x0f;
std::size_t header = 12 + cc * 4;
if (size < header) {
return false;
}
const bool hasExt = (data[0] & 0x10) != 0;
if (hasExt) {
if (size < header + 4) {
return false;
}
const std::uint16_t extWords =
static_cast<std::uint16_t>((data[header + 2] << 8) | data[header + 3]);
header += 4 + static_cast<std::size_t>(extWords) * 4;
if (size < header) {
return false;
}
}
out.raw = data;
out.rawSize = size;
out.payload = data + header;
out.payloadSize = size - header;
out.payloadType = data[1] & 0x7f;
out.marker = (data[1] & 0x80) != 0;
out.sequence = static_cast<std::uint16_t>((data[2] << 8) | data[3]);
out.timestamp = (static_cast<std::uint32_t>(data[4]) << 24) |
(static_cast<std::uint32_t>(data[5]) << 16) |
(static_cast<std::uint32_t>(data[6]) << 8) |
static_cast<std::uint32_t>(data[7]);
out.ssrc = (static_cast<std::uint32_t>(data[8]) << 24) |
(static_cast<std::uint32_t>(data[9]) << 16) |
(static_cast<std::uint32_t>(data[10]) << 8) |
static_cast<std::uint32_t>(data[11]);
return true;
}
} // namespace fpv
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include "relay/rtp.h"
namespace fpv {
// RTP 订阅者接口。
// RTSP 会话等作为订阅者,从 Stream 接收每个 RTP 包(需自行拷贝,回调返回后指针失效)。
class RtpSubscriber {
public:
virtual ~RtpSubscriber() = default;
virtual void onRtp(const RtpPacket& pkt) = 0;
};
} // namespace fpv
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "relay/rtp.h"
namespace fpv {
// 数据出口接口。
// 一路数据流可挂多个出口:裸 UDP 转发、DVR 录制、RTSP 服务等。
class Sink {
public:
virtual ~Sink() = default;
// 收到一个 RTP 包。
virtual void onPacket(const RtpPacket& pkt) = 0;
};
} // namespace fpv
+102
View File
@@ -0,0 +1,102 @@
#include "relay/stream.h"
#include <spdlog/spdlog.h>
#include "relay/dvr_sink.h"
#include "relay/rtp.h"
#include "relay/udp_sink.h"
namespace fpv {
Stream::Stream(std::string name, std::string codec, const std::vector<std::string>& targets,
const RecordConfig& record)
: name_(std::move(name)), codec_(std::move(codec)), recordCfg_(record)
{
for (const auto& t : targets) {
// "ts://ip:port" 表示该目标注入时间戳扩展(供预览延迟测量)
if (t.rfind("ts://", 0) == 0) {
sinks_.push_back(std::make_unique<UdpSink>(t.substr(5), true));
} else {
sinks_.push_back(std::make_unique<UdpSink>(t));
}
}
if (record.enabled) {
setRecording(true);
}
if (codec_ == "h265") {
// 仅用于提取参数集给 RTSP 的 SDP,不做落盘
paramExtractor_ = std::make_unique<H265Depacketizer>(nullptr);
}
}
void Stream::setRecording(bool on)
{
std::lock_guard<std::mutex> lock(recordMutex_);
if (on && !dvr_) {
dvr_ = std::make_unique<DvrSink>(name_, codec_, recordCfg_.dir,
recordCfg_.segmentSeconds, recordCfg_.segmentMB);
} else if (!on && dvr_) {
dvr_.reset();
}
}
bool Stream::recording() const
{
std::lock_guard<std::mutex> lock(recordMutex_);
return dvr_ != nullptr;
}
void Stream::addSubscriber(RtpSubscriber* sub)
{
std::lock_guard<std::mutex> lock(subMutex_);
subscribers_.push_back(sub);
}
void Stream::removeSubscriber(RtpSubscriber* sub)
{
std::lock_guard<std::mutex> lock(subMutex_);
for (auto it = subscribers_.begin(); it != subscribers_.end(); ++it) {
if (*it == sub) {
subscribers_.erase(it);
break;
}
}
}
void Stream::onRtp(const std::uint8_t* data, std::size_t size)
{
RtpPacket pkt;
if (!parseRtp(data, size, pkt)) {
return;
}
recv_.fetch_add(1, std::memory_order_relaxed);
if (paramExtractor_) {
paramExtractor_->input(pkt.payload, pkt.payloadSize);
}
for (auto& sink : sinks_) {
sink->onPacket(pkt);
}
{
std::lock_guard<std::mutex> lock(recordMutex_);
if (dvr_) {
dvr_->onPacket(pkt);
}
}
{
std::lock_guard<std::mutex> lock(subMutex_);
for (auto* sub : subscribers_) {
sub->onRtp(pkt);
}
if (!subscribers_.empty()) {
static std::atomic<std::uint64_t> delivered {0};
const auto n = delivered.fetch_add(1) + 1;
if (n % 500 == 0) {
spdlog::debug("[{}] 已向订阅者分发 {} 个包", name_, n);
}
}
}
forward_.fetch_add(1, std::memory_order_relaxed);
}
} // namespace fpv
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "config/config.h"
#include "relay/dvr_sink.h"
#include "relay/h265.h"
#include "relay/rtp_subscriber.h"
#include "relay/sink.h"
namespace fpv {
// 一路图传的数据流。
// 采集模块把 RTP 数据交给 onRtp,本类解析后分发给所有出口(转发/录制/RTSP),
// 并维护 H.265 参数集供 RTSP 的 SDP 使用。
class Stream {
public:
Stream(std::string name, std::string codec, const std::vector<std::string>& targets,
const RecordConfig& record);
// 收到一个 RTP 数据报(来自采集),解析后分发给各出口。
void onRtp(const std::uint8_t* data, std::size_t size);
const std::string& name() const { return name_; }
const std::string& codec() const { return codec_; }
// RTSP 订阅管理。
void addSubscriber(RtpSubscriber* sub);
void removeSubscriber(RtpSubscriber* sub);
// 动态开关录制。
void setRecording(bool on);
bool recording() const;
// H.265 参数集(VPS/SPS/PPS),供 SDP 使用。
const H265Depacketizer& params() const { return *paramExtractor_; }
std::uint64_t recvCount() const { return recv_.load(std::memory_order_relaxed); }
std::uint64_t forwardCount() const { return forward_.load(std::memory_order_relaxed); }
private:
std::string name_;
std::string codec_;
RecordConfig recordCfg_;
std::vector<std::unique_ptr<Sink>> sinks_;
std::unique_ptr<H265Depacketizer> paramExtractor_;
std::mutex subMutex_;
std::vector<RtpSubscriber*> subscribers_;
mutable std::mutex recordMutex_;
std::unique_ptr<DvrSink> dvr_;
std::atomic<std::uint64_t> recv_ {0};
std::atomic<std::uint64_t> forward_ {0};
};
} // namespace fpv
+108
View File
@@ -0,0 +1,108 @@
#include "relay/udp_sink.h"
#include <chrono>
#include <cstring>
#include <ctime>
#include <stdexcept>
#include <vector>
#include <spdlog/spdlog.h>
#include "common/net_compat.h"
namespace fpv {
UdpSink::UdpSink(const std::string& target, bool injectTimestamp)
: target_(target), injectTimestamp_(injectTimestamp)
{
const std::size_t pos = target.find_last_of(':');
if (pos == std::string::npos) {
throw std::runtime_error("转发目标格式应为 ip:port: " + target);
}
const std::string host = target.substr(0, pos);
const std::string port = target.substr(pos + 1);
addrinfo hints {};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_DGRAM;
addrinfo* res = nullptr;
if (getaddrinfo(host.c_str(), port.c_str(), &hints, &res) != 0 || res == nullptr) {
throw std::runtime_error("解析转发目标失败: " + target);
}
std::memcpy(&addr_, res->ai_addr, res->ai_addrlen);
addrLen_ = static_cast<socklen_t>(res->ai_addrlen);
const int family = res->ai_family;
freeaddrinfo(res);
fd_ = static_cast<int>(::socket(family, SOCK_DGRAM, 0));
if (fd_ < 0) {
throw std::runtime_error("创建转发 socket 失败: " + target);
}
// 非阻塞 + 加大发送缓冲,避免目标不可达时阻塞采集线程造成卡顿
const int sndBuf = 4 * 1024 * 1024;
setsockopt(fd_, SOL_SOCKET, SO_SNDBUF, reinterpret_cast<const char*>(&sndBuf), sizeof(sndBuf));
#ifdef _WIN32
u_long nonBlocking = 1;
ioctlsocket(fd_, FIONBIO, &nonBlocking);
#else
const int flags = fcntl(fd_, F_GETFL, 0);
fcntl(fd_, F_SETFL, flags | O_NONBLOCK);
#endif
}
UdpSink::~UdpSink()
{
netClose(fd_);
}
bool UdpSink::send(const void* data, std::size_t size)
{
const auto* bytes = static_cast<const std::uint8_t*>(data);
// 需要时在 RTP 头部注入时间戳扩展(RFC 8285 one-byte)后再发送
std::vector<std::uint8_t> modified;
if (injectTimestamp_ && size >= 12 && (bytes[0] >> 6) == 2) {
const std::size_t cc = bytes[0] & 0x0f;
const std::size_t header = 12 + cc * 4;
const bool hasExt = (bytes[0] & 0x10) != 0;
if (!hasExt && size >= header) {
// 扩展:ID=1, len-1=7(8 字节),数据为发送时刻(微秒, 大端)
std::uint8_t ext[12] = {0};
ext[0] = 0x17; // ID=1, 长度 8
const std::uint64_t us = static_cast<std::uint64_t>(
std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count());
for (int i = 0; i < 8; ++i) {
ext[1 + i] = static_cast<std::uint8_t>(us >> (56 - i * 8));
}
modified.reserve(size + sizeof(ext) + 4);
modified.insert(modified.end(), bytes, bytes + header);
modified[0] = static_cast<std::uint8_t>(modified[0] | 0x10); // 置 X 位
modified.push_back(0xBE); // profile 0xBEDE
modified.push_back(0xDE);
modified.push_back(0x00); // 扩展长度(32bit 字数)=3
modified.push_back(0x03);
modified.insert(modified.end(), ext, ext + sizeof(ext));
modified.insert(modified.end(), bytes + header, bytes + size);
bytes = modified.data();
size = modified.size();
}
}
const auto n = ::sendto(fd_, reinterpret_cast<const char*>(bytes), static_cast<int>(size), 0,
reinterpret_cast<struct sockaddr*>(&addr_), addrLen_);
if (n < 0) {
static thread_local std::uint64_t lastLog = 0;
const std::uint64_t now = static_cast<std::uint64_t>(time(nullptr));
if (now - lastLog >= 5) {
lastLog = now;
spdlog::warn("转发失败 {} (目标可能不在线)", target_);
}
return false;
}
return true;
}
} // namespace fpv
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include "common/net_compat.h"
#include "relay/sink.h"
namespace fpv {
// 一个裸 RTP/UDP 转发目标。
// 负责把原始 RTP 数据报原样发送到目标 ip:port;可选在 RTP 头部注入一个
// 标准头部扩展(RFC 8285 one-byte),携带发送时刻(微秒),供接收端计算延迟。
class UdpSink : public Sink {
public:
// target 形如 "192.168.1.50:5600";injectTimestamp 为 true 时注入时间戳扩展。
explicit UdpSink(const std::string& target, bool injectTimestamp = false);
~UdpSink() override;
UdpSink(const UdpSink&) = delete;
UdpSink& operator=(const UdpSink&) = delete;
// 原样发送一个 RTP 数据报,成功返回 true。
bool send(const void* data, std::size_t size);
// Sink 接口:把原始 RTP 原样转发。
void onPacket(const RtpPacket& pkt) override { send(pkt.raw, pkt.rawSize); }
const std::string& target() const { return target_; }
private:
std::string target_;
int fd_ = -1;
bool injectTimestamp_ = false;
struct sockaddr_storage addr_ {};
socklen_t addrLen_ = 0;
};
} // namespace fpv
+628
View File
@@ -0,0 +1,628 @@
#include "rtsp/rtsp_server.h"
#include <spdlog/spdlog.h>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <deque>
#include <map>
#include <mutex>
#include <random>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
#include "common/net_compat.h"
#include "relay/stream.h"
namespace fpv {
namespace {
constexpr std::uint8_t kRtspPayloadType = 96; // SDP 中声明的 H265 动态载荷类型
// 标准 Base64 编码(SDP fmtp 需要)。
std::string base64(const std::vector<std::uint8_t>& in)
{
static const char* tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
int val = 0;
int bits = -6;
for (std::uint8_t c : in) {
val = (val << 8) | c;
bits += 8;
while (bits >= 0) {
out.push_back(tbl[(val >> bits) & 0x3f]);
bits -= 6;
}
}
if (bits > -6) {
out.push_back(tbl[((val << 8) >> (bits + 8)) & 0x3f]);
}
while (out.size() % 4 != 0) {
out.push_back('=');
}
return out;
}
std::mt19937& rng()
{
static std::mt19937 gen(std::random_device {}());
return gen;
}
std::string makeSessionId()
{
std::uniform_int_distribution<std::uint32_t> d;
char buf[16] = {0};
std::snprintf(buf, sizeof(buf), "%08X", d(rng()));
return buf;
}
// 从 RTSP URL 中取出流名(主机后的第一段路径)。
// 例如 rtsp://host:8554/drone1/trackID=0 -> "drone1"。
std::string streamNameFromUrl(const std::string& url)
{
const std::size_t scheme = url.find("://");
const std::size_t hostStart = (scheme == std::string::npos) ? 0 : scheme + 3;
const std::size_t slash = url.find('/', hostStart);
if (slash == std::string::npos) {
return "";
}
const std::size_t end = url.find('/', slash + 1);
return url.substr(slash + 1, end == std::string::npos ? std::string::npos : end - slash - 1);
}
// 从请求中取出 CSeq 值(用于回包)。
std::string headerValue(const std::string& req, const std::string& key)
{
const std::string lowerKey = key + ":";
std::size_t pos = 0;
while (pos < req.size()) {
const std::size_t end = req.find("\r\n", pos);
const std::string line = req.substr(pos, end == std::string::npos ? std::string::npos
: end - pos);
if (line.size() > lowerKey.size() &&
fpc_strncasecmp(line.c_str(), lowerKey.c_str(), lowerKey.size()) == 0) {
std::size_t v = line.find(':') + 1;
while (v < line.size() && line[v] == ' ') ++v;
return line.substr(v);
}
if (end == std::string::npos) break;
pos = end + 2;
}
return "";
}
} // namespace
// RTSP 会话的订阅队列:从 Stream 收到的 RTP 需拷贝,避免引用失效。
class RtspSessionSubscriber : public RtpSubscriber {
public:
void onRtp(const RtpPacket& pkt) override
{
std::lock_guard<std::mutex> lock(mutex_);
if (queue_.size() >= kMaxQueue) {
queue_.pop_front();
}
queue_.emplace_back(pkt.raw, pkt.raw + pkt.rawSize);
++enqueued_;
}
bool pop(std::vector<std::uint8_t>& out)
{
std::lock_guard<std::mutex> lock(mutex_);
if (queue_.empty()) {
return false;
}
out = std::move(queue_.front());
queue_.pop_front();
++dequeued_;
return true;
}
std::uint64_t enqueued() const { return enqueued_; }
std::uint64_t dequeued() const { return dequeued_; }
void clear()
{
std::lock_guard<std::mutex> lock(mutex_);
queue_.clear();
}
private:
static constexpr std::size_t kMaxQueue = 512;
std::mutex mutex_;
std::deque<std::vector<std::uint8_t>> queue_;
std::atomic<std::uint64_t> enqueued_ {0};
std::atomic<std::uint64_t> dequeued_ {0};
};
struct RtspServer::Impl {
std::string address;
int udpBase = 0;
int listenFd = -1;
int udpRtpFd = -1;
int udpRtcpFd = -1;
std::atomic<bool> running {false};
std::thread acceptThread;
std::mutex streamsMutex;
std::map<std::string, Stream*> streams; // name -> Stream
std::mutex threadsMutex;
std::vector<std::thread> clientThreads;
Stream* findStream(const std::string& name)
{
std::lock_guard<std::mutex> lock(streamsMutex);
auto it = streams.find(name);
return it == streams.end() ? nullptr : it->second;
}
void acceptLoop()
{
while (running.load()) {
pollfd pfd {};
pfd.fd = static_cast<decltype(pfd.fd)>(listenFd);
pfd.events = POLLIN;
if (netPoll(&pfd, 1, 500) <= 0) {
continue;
}
const int client = static_cast<int>(::accept(listenFd, nullptr, nullptr));
if (client < 0) {
continue;
}
std::lock_guard<std::mutex> lock(threadsMutex);
clientThreads.emplace_back([this, client] { handleClient(client); });
}
}
// 向一个客户端连接发送完整 RTSP 响应。
static void sendResponse(int fd, int code, const std::string& cseq,
const std::string& extraHeaders, const std::string& body)
{
static const std::map<int, const char*> reason = {
{200, "OK"}, {400, "Bad Request"}, {404, "Not Found"}, {500, "Internal Server Error"}};
std::string r = "RTSP/1.0 " + std::to_string(code) + " " +
(reason.count(code) ? reason.at(code) : "OK") + "\r\n";
if (!cseq.empty()) {
r += "CSeq: " + cseq + "\r\n";
}
r += extraHeaders;
r += "Server: fpv-relay\r\n";
if (!body.empty()) {
r += "Content-Length: " + std::to_string(body.size()) + "\r\n";
}
r += "\r\n";
r += body;
::send(fd, r.data(), static_cast<int>(r.size()), 0);
}
// 构造 H265 的 SDP。
static std::string buildSdp(Stream* stream)
{
const H265Depacketizer& p = stream->params();
std::string sdp;
sdp += "v=0\r\n";
sdp += "o=- 0 0 IN IP4 0.0.0.0\r\n";
sdp += "s=fpv-relay\r\n";
sdp += "c=IN IP4 0.0.0.0\r\n";
sdp += "t=0 0\r\n";
sdp += "m=video 0 RTP/AVP " + std::to_string(kRtspPayloadType) + "\r\n";
sdp += "a=control:trackID=0\r\n";
sdp += "a=rtpmap:" + std::to_string(kRtspPayloadType) + " H265/90000\r\n";
sdp += "a=fmtp:" + std::to_string(kRtspPayloadType) + " sprop-vps=" + base64(p.vps()) +
";sprop-sps=" + base64(p.sps()) + ";sprop-pps=" + base64(p.pps()) + "\r\n";
return sdp;
}
// 读取一个 RTSP 请求(以 \r\n\r\n 结束),返回 false 表示连接断开。
// 使用持久缓冲,避免客户端一次发送多个请求时丢失后续请求。
static bool readNextRequest(int fd, std::string& buf, std::string& out)
{
for (;;) {
const std::size_t pos = buf.find("\r\n\r\n");
if (pos != std::string::npos) {
out = buf.substr(0, pos + 4);
buf.erase(0, pos + 4);
return true;
}
pollfd pfd {};
pfd.fd = static_cast<decltype(pfd.fd)>(fd);
pfd.events = POLLIN;
const int pr = netPoll(&pfd, 1, 1000);
if (pr <= 0) {
return false;
}
char tmp[4096];
const auto n = ::recv(fd, tmp, static_cast<int>(sizeof(tmp)), 0);
if (n <= 0) {
return false;
}
buf.append(tmp, static_cast<std::size_t>(n));
if (buf.size() > 65536) {
return false;
}
}
}
// 发送一个 RTP 包。dst 非空则走 UDP(发到客户端 RTP 端口),否则走 TCP interleaved。
static void sendRtp(int tcpFd, int udpFd, const sockaddr_in* dst, std::vector<std::uint8_t>& pkt,
std::uint32_t ssrc, std::uint16_t seq)
{
if (pkt.size() < 12) {
return;
}
pkt[1] = static_cast<std::uint8_t>((pkt[1] & 0x80) | kRtspPayloadType);
pkt[2] = static_cast<std::uint8_t>(seq >> 8);
pkt[3] = static_cast<std::uint8_t>(seq & 0xff);
pkt[8] = static_cast<std::uint8_t>(ssrc >> 24);
pkt[9] = static_cast<std::uint8_t>(ssrc >> 16);
pkt[10] = static_cast<std::uint8_t>(ssrc >> 8);
pkt[11] = static_cast<std::uint8_t>(ssrc);
if (dst != nullptr && udpFd >= 0) {
::sendto(udpFd, reinterpret_cast<const char*>(pkt.data()), static_cast<int>(pkt.size()),
0, reinterpret_cast<const sockaddr*>(dst), sizeof(*dst));
return;
}
std::uint8_t hdr[4];
hdr[0] = '$';
hdr[1] = 0; // RTP 通道
hdr[2] = static_cast<std::uint8_t>(pkt.size() >> 8);
hdr[3] = static_cast<std::uint8_t>(pkt.size() & 0xff);
::send(tcpFd, reinterpret_cast<const char*>(hdr), static_cast<int>(sizeof(hdr)), 0);
::send(tcpFd, reinterpret_cast<const char*>(pkt.data()), static_cast<int>(pkt.size()), 0);
}
// 发送一个 RTCP SR。dst 非空则走 UDP(发到客户端 RTCP 端口),否则走 TCP interleaved 通道 1。
static void sendSr(int tcpFd, int udpFd, const sockaddr_in* dst, std::uint32_t ssrc,
std::uint32_t rtpTs, std::uint32_t pktCount, std::uint32_t octetCount)
{
std::uint8_t sr[28] = {0};
sr[0] = 0x80;
sr[1] = 200;
sr[2] = 0;
sr[3] = 6;
sr[4] = ssrc >> 24;
sr[5] = ssrc >> 16;
sr[6] = ssrc >> 8;
sr[7] = ssrc;
// NTP 时间戳(秒 + 小数部分),客户端据此做同步
const auto since = std::chrono::system_clock::now().time_since_epoch();
const std::uint64_t sec =
static_cast<std::uint64_t>(
std::chrono::duration_cast<std::chrono::seconds>(since).count()) +
2208988800ULL;
const std::uint64_t frac =
static_cast<std::uint64_t>(
std::chrono::duration_cast<std::chrono::nanoseconds>(since).count() % 1000000000LL) *
(1ULL << 32) / 1000000000ULL;
const std::uint32_t ntpSec = static_cast<std::uint32_t>(sec);
const std::uint32_t ntpFrac = static_cast<std::uint32_t>(frac);
sr[8] = ntpSec >> 24;
sr[9] = ntpSec >> 16;
sr[10] = ntpSec >> 8;
sr[11] = ntpSec;
sr[12] = ntpFrac >> 24;
sr[13] = ntpFrac >> 16;
sr[14] = ntpFrac >> 8;
sr[15] = ntpFrac;
sr[16] = rtpTs >> 24;
sr[17] = rtpTs >> 16;
sr[18] = rtpTs >> 8;
sr[19] = rtpTs;
sr[20] = pktCount >> 24;
sr[21] = pktCount >> 16;
sr[22] = pktCount >> 8;
sr[23] = pktCount;
sr[24] = octetCount >> 24;
sr[25] = octetCount >> 16;
sr[26] = octetCount >> 8;
sr[27] = octetCount;
if (dst != nullptr && udpFd >= 0) {
::sendto(udpFd, reinterpret_cast<const char*>(sr), static_cast<int>(sizeof(sr)), 0,
reinterpret_cast<const sockaddr*>(dst), sizeof(*dst));
return;
}
std::uint8_t hdr[4] = {'$', 1, 0, static_cast<std::uint8_t>(sizeof(sr))};
::send(tcpFd, reinterpret_cast<const char*>(hdr), static_cast<int>(sizeof(hdr)), 0);
::send(tcpFd, reinterpret_cast<const char*>(sr), static_cast<int>(sizeof(sr)), 0);
}
void handleClient(int fd)
{
RtspSessionSubscriber subscriber;
Stream* stream = nullptr;
std::string sessionId;
bool playing = false;
bool subscribed = false; // 是否已加入 Stream 订阅表(与 playing 分开,断开时也要退订)
// UDP 传输相关
bool useUdp = false;
sockaddr_in clientRtp {};
sockaddr_in clientRtcp {};
{
sockaddr_in peer {};
socklen_t plen = sizeof(peer);
if (::getpeername(fd, reinterpret_cast<sockaddr*>(&peer), &plen) == 0) {
clientRtp.sin_family = AF_INET;
clientRtp.sin_addr = peer.sin_addr;
clientRtcp = clientRtp;
}
}
const int serverRtpPort = udpBase;
const int serverRtcpPort = udpBase + 1;
std::uniform_int_distribution<std::uint32_t> dist;
const std::uint32_t ssrc = dist(rng());
std::uint16_t seq = static_cast<std::uint16_t>(dist(rng()));
std::uint32_t lastRtpTs = 0;
std::uint32_t packetCount = 0;
std::uint32_t octetCount = 0;
std::uint64_t starve = 0;
auto lastSr = std::chrono::steady_clock::now() - std::chrono::seconds(10);
std::string reqBuf;
std::string req;
while (running.load() && readNextRequest(fd, reqBuf, req)) {
const std::size_t sp1 = req.find(' ');
const std::size_t sp2 = req.find(' ', sp1 + 1);
if (sp1 == std::string::npos || sp2 == std::string::npos) {
break;
}
const std::string method = req.substr(0, sp1);
std::string url = req.substr(sp1 + 1, sp2 - sp1 - 1);
const std::string cseq = headerValue(req, "CSeq");
// 取主机后的第一段路径作为流名(SETUP/PLAY 会带 /trackID=0 后缀)
const std::string name = streamNameFromUrl(url);
spdlog::debug("RTSP {} {}", method, url);
if (method == "OPTIONS") {
sendResponse(fd, 200, cseq,
"Public: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN\r\n", "");
} else if (method == "DESCRIBE") {
Stream* s = findStream(name);
if (s == nullptr || s->codec() != "h265" || !s->params().hasParams()) {
sendResponse(fd, 404, cseq, "", "");
} else {
const std::string sdp = buildSdp(s);
sendResponse(fd, 200, cseq,
"Content-Base: " + url + "/\r\n"
"Content-Type: application/sdp\r\n",
sdp);
}
} else if (method == "SETUP") {
if (sessionId.empty()) {
sessionId = makeSessionId();
}
const std::string transport = headerValue(req, "Transport");
spdlog::debug("SETUP Transport: {}", transport);
bool wantUdp = transport.find("TCP") == std::string::npos;
int clientRtpPort = 0;
int clientRtcpPort = 0;
const std::size_t cp = transport.find("client_port=");
if (cp != std::string::npos) {
int p1 = 0;
int p2 = 0;
std::sscanf(transport.c_str() + cp + 12, "%d-%d", &p1, &p2);
clientRtpPort = p1;
clientRtcpPort = p2;
}
if (wantUdp && udpBase > 0 && clientRtpPort > 0) {
useUdp = true;
clientRtp.sin_port = htons(static_cast<std::uint16_t>(clientRtpPort));
clientRtcp.sin_port = htons(static_cast<std::uint16_t>(clientRtcpPort));
sendResponse(fd, 200, cseq,
"Transport: RTP/AVP;unicast;client_port=" +
std::to_string(clientRtpPort) + "-" +
std::to_string(clientRtcpPort) +
";server_port=" + std::to_string(serverRtpPort) + "-" +
std::to_string(serverRtcpPort) + ";mode=\"PLAY\"\r\n" +
"Session: " + sessionId + "\r\n",
"");
} else {
useUdp = false;
sendResponse(fd, 200, cseq,
"Transport: RTP/AVP/TCP;unicast;interleaved=0-1;"
"mode=\"PLAY\"\r\n"
"Session: " + sessionId + "\r\n",
"");
}
} else if (method == "PLAY") {
Stream* s = findStream(name);
if (s == nullptr) {
sendResponse(fd, 404, cseq, "", "");
continue;
}
stream = s;
stream->addSubscriber(&subscriber);
subscribed = true;
playing = true;
spdlog::debug("RTSP PLAY 已订阅 {}", name);
sendResponse(fd, 200, cseq, "Session: " + sessionId + "\r\n", "");
} else if (method == "TEARDOWN") {
sendResponse(fd, 200, cseq, "Session: " + sessionId + "\r\n", "");
break;
} else {
sendResponse(fd, 400, cseq, "", "");
}
// PLAY 之后进入发送循环
std::uint64_t loops = 0;
while (playing && running.load()) {
if (++loops % 50 == 0) {
spdlog::debug("发送循环 {} 次 sent={} starve={}", loops, packetCount, starve);
}
pollfd pfd {};
pfd.fd = static_cast<decltype(pfd.fd)>(fd);
pfd.events = POLLIN;
// 超时 5ms:RTP 一到就尽快转发,避免 100ms 轮询带来的延迟
if (netPoll(&pfd, 1, 5) > 0 && (pfd.revents & (POLLIN | POLLHUP | POLLERR))) {
char tmp[512];
const auto n = ::recv(fd, tmp, static_cast<int>(sizeof(tmp)),
FPC_MSG_DONTWAIT);
if (n <= 0) {
spdlog::debug("会话 recv 返回 {},结束发送", n);
playing = false;
break;
}
// 忽略 RTCP 等上行数据(简化处理)
}
std::vector<std::uint8_t> pkt;
while (subscriber.pop(pkt)) {
if (pkt.size() >= 12) {
lastRtpTs = (static_cast<std::uint32_t>(pkt[4]) << 24) |
(static_cast<std::uint32_t>(pkt[5]) << 16) |
(static_cast<std::uint32_t>(pkt[6]) << 8) |
static_cast<std::uint32_t>(pkt[7]);
}
packetCount++;
octetCount += static_cast<std::uint32_t>(pkt.size());
sendRtp(fd, useUdp ? udpRtpFd : -1, useUdp ? &clientRtp : nullptr, pkt, ssrc,
seq++);
if (packetCount == 1) {
spdlog::debug("开始向会话发送 RTP (udp={})", useUdp);
} else if (packetCount % 100 == 0) {
spdlog::debug("已发送 {} 个 RTP", packetCount);
}
}
if (std::chrono::steady_clock::now() - lastSr > std::chrono::seconds(1)) {
sendSr(fd, useUdp ? udpRtcpFd : -1, useUdp ? &clientRtcp : nullptr, ssrc,
lastRtpTs, packetCount, octetCount);
lastSr = std::chrono::steady_clock::now();
}
}
}
// 关键:用 subscribed 而非 playing 判断——客户端断开时会把 playing 置 false,
// 若用它判断就会漏掉 removeSubscriber,导致 RX 线程访问已析构的栈上订阅者(崩溃)。
if (subscribed && stream != nullptr) {
stream->removeSubscriber(&subscriber);
}
netClose(fd);
}
};
RtspServer::RtspServer(std::string address, int udpBase) : impl_(std::make_unique<Impl>())
{
impl_->address = std::move(address);
impl_->udpBase = udpBase;
}
RtspServer::~RtspServer()
{
stop();
}
void RtspServer::addStream(Stream* stream)
{
if (stream == nullptr) {
return;
}
std::lock_guard<std::mutex> lock(impl_->streamsMutex);
impl_->streams[stream->name()] = stream;
}
void RtspServer::start()
{
const std::size_t pos = impl_->address.find_last_of(':');
if (pos == std::string::npos) {
throw std::runtime_error("rtsp.address 格式应为 host:port: " + impl_->address);
}
const std::string host = impl_->address.substr(0, pos);
const std::string port = impl_->address.substr(pos + 1);
addrinfo hints {};
// 用 IPv4:Windows 默认 IPV6_V6ONLY=1,绑 "::" 会导致 IPv4(127.0.0.1) 连不上。
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
addrinfo* res = nullptr;
const char* hostPtr = host.empty() ? nullptr : host.c_str();
if (getaddrinfo(hostPtr, port.c_str(), &hints, &res) != 0 || res == nullptr) {
throw std::runtime_error("解析 rtsp.address 失败: " + impl_->address);
}
impl_->listenFd = static_cast<int>(::socket(res->ai_family, SOCK_STREAM, 0));
if (impl_->listenFd < 0) {
freeaddrinfo(res);
throw std::runtime_error("创建 RTSP socket 失败");
}
const int one = 1;
setsockopt(impl_->listenFd, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char*>(&one),
sizeof(one));
if (::bind(impl_->listenFd, res->ai_addr, static_cast<socklen_t>(res->ai_addrlen)) < 0 ||
::listen(impl_->listenFd, 8) < 0) {
freeaddrinfo(res);
throw std::runtime_error("监听 RTSP 端口失败: " + impl_->address);
}
freeaddrinfo(res);
// 可选的 UDP 传输端口
if (impl_->udpBase > 0) {
auto makeUdp = [](int port) {
const int s = static_cast<int>(::socket(AF_INET, SOCK_DGRAM, 0));
if (s < 0) {
return -1;
}
sockaddr_in a {};
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_ANY);
a.sin_port = htons(static_cast<std::uint16_t>(port));
if (::bind(s, reinterpret_cast<sockaddr*>(&a), sizeof(a)) < 0) {
netClose(s);
return -1;
}
return s;
};
impl_->udpRtpFd = makeUdp(impl_->udpBase);
impl_->udpRtcpFd = makeUdp(impl_->udpBase + 1);
if (impl_->udpRtpFd < 0 || impl_->udpRtcpFd < 0) {
spdlog::warn("RTSP UDP 端口 {} 绑定失败,仅使用 TCP", impl_->udpBase);
}
}
impl_->running = true;
impl_->acceptThread = std::thread([this] { impl_->acceptLoop(); });
spdlog::info("RTSP 服务已启动: rtsp://{}/<name>", impl_->address);
}
void RtspServer::stop()
{
if (!impl_->running.exchange(false)) {
return;
}
if (impl_->listenFd >= 0) {
::shutdown(impl_->listenFd, FPC_SHUT_RDWR);
netClose(impl_->listenFd);
impl_->listenFd = -1;
}
if (impl_->udpRtpFd >= 0) {
netClose(impl_->udpRtpFd);
impl_->udpRtpFd = -1;
}
if (impl_->udpRtcpFd >= 0) {
netClose(impl_->udpRtcpFd);
impl_->udpRtcpFd = -1;
}
if (impl_->acceptThread.joinable()) {
impl_->acceptThread.join();
}
std::lock_guard<std::mutex> lock(impl_->threadsMutex);
// 客户端线程用带超时的 poll,能及时退出,这里安全 join
for (auto& t : impl_->clientThreads) {
if (t.joinable()) {
t.join();
}
}
impl_->clientThreads.clear();
}
} // namespace fpv
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <memory>
#include <string>
namespace fpv {
class Stream;
// 兼容输出用的 RTSP 服务器。
// 只做“把已经在收的 RTP 转成 RTSP 供 VLC/ffplay 拉流”,低延迟主路径仍是 UDP 直推。
// 支持 TCP interleaved 与 UDP 两种传输,H.265 单轨。
class RtspServer {
public:
// udpBase > 0 时启用 UDP 传输(RTP=udpBase, RTCP=udpBase+1)。
RtspServer(std::string address, int udpBase);
~RtspServer();
RtspServer(const RtspServer&) = delete;
RtspServer& operator=(const RtspServer&) = delete;
// 注册一路流,路径为 /<name>。
void addStream(Stream* stream);
void start();
void stop();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace fpv
+202
View File
@@ -0,0 +1,202 @@
#include "status/http_server.h"
#include <cstring>
#include <stdexcept>
#include <string>
#include <spdlog/spdlog.h>
#include "common/net_compat.h"
namespace fpv {
namespace {
// 内嵌的简易状态面板:每 1 秒拉取 /status 并用表格展示。
std::string dashboardHtml()
{
return R"HTML(<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<title>fpv-relay 状态</title>
<style>
body{font-family:sans-serif;margin:16px}
table{border-collapse:collapse}
th,td{border:1px solid #999;padding:4px 10px;text-align:center}
.on{color:#0a0}.off{color:#c00}
</style>
</head>
<body>
<h3>fpv-relay 状态</h3>
<table id="t"><thead><tr>
<th>名称</th><th>编码</th><th>状态</th><th>收包</th><th>转发</th>
<th>会话</th><th>解密错误</th><th>丢包</th>
</tr></thead><tbody></tbody></table>
<script>
async function tick(){
try{
const r=await fetch('/status',{cache:'no-store'});
const j=await r.json();
const tb=document.querySelector('#t tbody');
tb.innerHTML='';
for(const c of j.captures){
const tr=document.createElement('tr');
tr.innerHTML=`<td>${c.name}</td><td>${c.codec}</td>`
+`<td class="${c.online?'on':'off'}">${c.online?'在线':'离线'}</td>`
+`<td>${c.recv}</td><td>${c.forwarded}</td><td>${c.session}</td>`
+`<td>${c.decErr}</td><td>${c.lost}</td>`;
tb.appendChild(tr);
}
}catch(e){}
}
setInterval(tick,1000); tick();
</script>
</body></html>)HTML";
}
} // namespace
StatusServer::StatusServer(std::string address, Provider jsonProvider, Provider metricsProvider)
: address_(std::move(address)),
jsonProvider_(std::move(jsonProvider)),
metricsProvider_(std::move(metricsProvider))
{
}
StatusServer::~StatusServer()
{
stop();
}
void StatusServer::start()
{
const std::size_t pos = address_.find_last_of(':');
if (pos == std::string::npos) {
throw std::runtime_error("status.address 格式应为 host:port: " + address_);
}
const std::string host = address_.substr(0, pos);
const std::string port = address_.substr(pos + 1);
addrinfo hints {};
// 用 IPv4:Windows 默认 IPV6_V6ONLY=1,绑 "::" 会导致 IPv4(127.0.0.1) 连不上。
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
addrinfo* res = nullptr;
const char* hostPtr = host.empty() ? nullptr : host.c_str();
if (getaddrinfo(hostPtr, port.c_str(), &hints, &res) != 0 || res == nullptr) {
throw std::runtime_error("解析 status.address 失败: " + address_);
}
fd_ = static_cast<int>(::socket(res->ai_family, SOCK_STREAM, 0));
if (fd_ < 0) {
freeaddrinfo(res);
throw std::runtime_error("创建 status socket 失败");
}
const int one = 1;
setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char*>(&one), sizeof(one));
if (::bind(fd_, res->ai_addr, static_cast<socklen_t>(res->ai_addrlen)) < 0 ||
::listen(fd_, 8) < 0) {
freeaddrinfo(res);
throw std::runtime_error("监听 status 端口失败: " + address_);
}
freeaddrinfo(res);
running_ = true;
thread_ = std::thread([this] { run(); });
spdlog::info("状态页已启动: http://{}/status", address_);
}
void StatusServer::run()
{
while (running_.load()) {
// 用带超时的 poll 等待连接,保证 stop() 能及时退出,不会卡在 accept()
pollfd pfd {};
pfd.fd = static_cast<decltype(pfd.fd)>(fd_);
pfd.events = POLLIN;
const int pr = netPoll(&pfd, 1, 500);
if (pr <= 0) {
continue;
}
sockaddr_storage peer {};
socklen_t peerLen = sizeof(peer);
const int client =
static_cast<int>(::accept(fd_, reinterpret_cast<sockaddr*>(&peer), &peerLen));
if (client < 0) {
if (!running_.load()) break;
continue;
}
char req[2048] = {0};
const auto n = ::recv(client, req, static_cast<int>(sizeof(req) - 1), 0);
std::string body = "{}";
std::string contentType = "application/json";
int code = 200;
std::string reason = "OK";
if (n > 0) {
const std::string line(req, static_cast<std::size_t>(n));
// 取请求行中的路径
std::string path = "/";
const std::size_t sp1 = line.find(' ');
if (sp1 != std::string::npos) {
const std::size_t sp2 = line.find(' ', sp1 + 1);
if (sp2 != std::string::npos) {
path = line.substr(sp1 + 1, sp2 - sp1 - 1);
}
}
const std::size_t q = path.find('?');
if (q != std::string::npos) {
path = path.substr(0, q);
}
try {
if (path == "/" || path == "/index.html") {
body = dashboardHtml();
contentType = "text/html; charset=utf-8";
} else if (path == "/status") {
body = jsonProvider_ ? jsonProvider_() : "{}";
} else if (path == "/metrics") {
body = metricsProvider_ ? metricsProvider_() : "";
contentType = "text/plain; version=0.0.4; charset=utf-8";
} else {
code = 404;
reason = "Not Found";
body = "{\"error\":\"not found\"}";
}
} catch (const std::exception& e) {
code = 500;
reason = "Internal Server Error";
body = std::string("{\"error\":\"") + e.what() + "\"}";
}
}
std::string resp = "HTTP/1.1 " + std::to_string(code) + " " + reason + "\r\n";
resp += "Content-Type: " + contentType + "\r\n";
resp += "Content-Length: " + std::to_string(body.size()) + "\r\n";
resp += "Connection: close\r\n\r\n";
resp += body;
::send(client, resp.data(), static_cast<int>(resp.size()), 0);
netClose(client);
}
}
void StatusServer::stop()
{
if (!running_.exchange(false)) {
if (thread_.joinable()) thread_.join();
return;
}
if (fd_ >= 0) {
::shutdown(fd_, FPC_SHUT_RDWR);
netClose(fd_);
fd_ = -1;
}
// 监听线程用带超时的 poll,能及时退出,这里安全 join
if (thread_.joinable()) {
thread_.join();
}
}
} // namespace fpv
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <atomic>
#include <functional>
#include <string>
#include <thread>
namespace fpv {
// 极简 HTTP 状态服务。
// 提供 GET / (HTML 面板)、/status (JSON)、/metrics (Prometheus 文本)。
// 不依赖任何第三方网络库。
class StatusServer {
public:
using Provider = std::function<std::string()>;
StatusServer(std::string address, Provider jsonProvider, Provider metricsProvider);
~StatusServer();
StatusServer(const StatusServer&) = delete;
StatusServer& operator=(const StatusServer&) = delete;
// 启动监听线程。
void start();
// 停止服务。
void stop();
private:
void run();
std::string address_;
Provider jsonProvider_;
Provider metricsProvider_;
int fd_ = -1;
std::thread thread_;
std::atomic<bool> running_ {false};
};
} // namespace fpv
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <cstdint>
#include <string>
namespace fpv {
// 单路图传的运行时状态快照。
// 用于日志与 HTTP 状态页展示。
struct StreamStatus {
std::string name;
std::string codec;
bool online = false; // wfb: 已建立会话; udp: 采集线程在跑
bool recording = false; // 是否正在录制
int rssi = 0; // 无线信号强度(dBm,仅 wfb)
std::uint64_t recv = 0; // 采集收到的 RTP 包数
std::uint64_t forwarded = 0; // 已转发出去的包数
std::uint64_t all = 0; // wfb 收到的原始包数(仅 wfb)
std::uint64_t data = 0; // wfb 数据包数
std::uint64_t session = 0; // wfb 会话包数(>=1 表示已建立会话)
std::uint64_t decErr = 0; // wfb 解密错误数
std::uint64_t lost = 0; // wfb 丢包数
};
} // namespace fpv