first
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "config/config.h"
|
||||
|
||||
namespace fpv {
|
||||
|
||||
// 把接收网卡设置为 monitor 模式并锁定到指定信道。
|
||||
// 目前仅 Linux 可用;成功返回 true。Windows 下返回 false。
|
||||
bool setupRadio(const CaptureConfig& cfg);
|
||||
|
||||
} // namespace fpv
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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_);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user