Implement on-demand live video protocol

This commit is contained in:
cxh 2026-06-05 09:25:04 +08:00
parent be68ee5180
commit 976fe61f94
14 changed files with 581 additions and 119 deletions

View File

@ -0,0 +1,14 @@
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
set(CMAKE_FIND_ROOT_PATH /usr/aarch64-linux-gnu)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)
set(ENV{PKG_CONFIG_PATH} "")
set(ENV{PKG_CONFIG_LIBDIR} "/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig")

View File

@ -1,116 +1,139 @@
{
"mqtt_server": {
"veh_id": 20004,
"address": "192.168.4.195",
"port": 51883,
"need_username_pwd": true,
"client_id": "20004_vehmedia",
"username": "20004_4A:69:BE:32:59:AE",
"password": "Zxwl1234@",
"mqtt_heart_threshold": 1000
},
"srs": {
"root": "/opt/vehicle-video-service/srs",
"record_config": "/opt/vehicle-video-service/srs/conf/record.conf",
"stream_app": "camera",
"live_enabled": true,
"record_enabled": true,
"live_host": "127.0.0.1",
"live_rtmp_port": 1935,
"live_http_api_port": 1985,
"live_http_server_port": 8080,
"live_rtc_port": 8000,
"live_vhost": "live",
"record_host": "127.0.0.1",
"record_rtmp_port": 2935,
"record_http_api_port": 2985,
"record_http_server_port": 2980,
"record_vhost": "record",
"record_path": "/media/record",
"dvr_duration_sec": 60,
"retention_days": 14,
"usage_threshold": 0.9,
"public_interface": "enP2p33s0"
},
"web": {
"enabled": true,
"bind": "0.0.0.0",
"port": 18080,
"username": "admin",
"password": "admin123"
},
"cameras": [
{
"bitrate": 2000000,
"device": "/dev/video0",
"enabled": false,
"fps": 30,
"height": 960,
"name": "AHD1",
"enabled": true,
"bitrate": 2000000,
"width": 1280,
"height": 960,
"fps": 30
"width": 1280
},
{
"bitrate": 2000000,
"device": "/dev/video1",
"enabled": true,
"fps": 30,
"height": 960,
"name": "AHD2",
"enabled": true,
"bitrate": 2000000,
"width": 1280,
"height": 960,
"fps": 30
"width": 1280
},
{
"bitrate": 2000000,
"device": "/dev/video2",
"enabled": true,
"fps": 30,
"height": 960,
"name": "AHD3",
"enabled": true,
"bitrate": 2000000,
"width": 1280,
"height": 960,
"fps": 30
"width": 1280
},
{
"bitrate": 2000000,
"device": "/dev/video3",
"enabled": false,
"fps": 30,
"height": 960,
"name": "AHD4",
"enabled": true,
"bitrate": 2000000,
"width": 1280,
"height": 960,
"fps": 30
"width": 1280
},
{
"bitrate": 2000000,
"device": "/dev/video11",
"enabled": true,
"fps": 30,
"height": 960,
"name": "AHD5",
"enabled": true,
"bitrate": 2000000,
"width": 1280,
"height": 960,
"fps": 30
"width": 1280
},
{
"bitrate": 2000000,
"device": "/dev/video12",
"enabled": true,
"fps": 30,
"height": 960,
"name": "AHD6",
"enabled": true,
"bitrate": 2000000,
"width": 1280,
"height": 960,
"fps": 30
"width": 1280
},
{
"bitrate": 2000000,
"device": "/dev/video13",
"name": "AHD7",
"enabled": true,
"bitrate": 2000000,
"width": 1280,
"fps": 30,
"height": 960,
"fps": 30
"name": "AHD7",
"width": 1280
},
{
"device": "/dev/video14",
"name": "AHD8",
"enabled": true,
"bitrate": 2000000,
"width": 1280,
"device": "/dev/video14",
"enabled": true,
"fps": 30,
"height": 960,
"fps": 30
"name": "AHD8",
"width": 1280
}
],
"mqtt_server": {
"address": "192.168.4.195",
"client_id": "20004_vehmedia",
"mqtt_heart_threshold": 1000,
"need_username_pwd": true,
"password": "Zxwl1234@",
"port": 51883,
"topic_profile": "protocol",
"username": "20004_4A:69:BE:32:59:AE",
"veh_id": 20004
},
"srs": {
"dvr_duration_sec": 60,
"live_enabled": true,
"live_host": "192.168.4.194",
"live_http_api_port": 11985,
"live_http_server_port": 18080,
"live_rtc_port": 8000,
"live_rtmp_port": 1935,
"live_vhost": "live",
"public_interface": "enP2p33s0",
"record_config": "/opt/vehicle-video-service/srs/conf/record.conf",
"record_enabled": true,
"record_host": "127.0.0.1",
"record_http_api_port": 2985,
"record_http_server_port": 2980,
"record_path": "/media/record",
"record_rtmp_port": 2935,
"record_vhost": "record",
"retention_days": 14,
"root": "/opt/vehicle-video-service/srs",
"stream_app": "camera",
"usage_threshold": 0.9,
"live_push_profile": "wan_194",
"live_playback_profile": "wan_194",
"live_profiles": {
"lan_194": {
"push_host": "192.168.4.194",
"push_rtmp_port": 1935,
"playback_scheme": "http",
"playback_host": "192.168.4.194",
"playback_http_api_port": 11985,
"playback_rtc_eip": "192.168.4.194:8000",
"vhost": "live"
},
"wan_194": {
"push_host": "36.153.162.171",
"push_rtmp_port": 19435,
"playback_scheme": "http",
"playback_host": "36.153.162.171",
"playback_http_api_port": 11985,
"playback_rtc_eip": "36.153.162.171:8000",
"vhost": "live"
}
}
},
"web": {
"bind": "0.0.0.0",
"enabled": true,
"password": "admin",
"port": 18081,
"username": "admin"
}
]
}

View File

@ -75,7 +75,9 @@ render_service "$PACKAGE_DIR/systemd/vehicle-video-service.service.in" \
/etc/systemd/system/vehicle-video-service.service
systemctl daemon-reload
systemctl enable --now vehicle-video-srs-live.service vehicle-video-srs-record.service vehicle-video-service.service
systemctl disable --now vehicle-video-srs-live.service >/dev/null 2>&1 || true
systemctl enable vehicle-video-srs-record.service vehicle-video-service.service
systemctl restart vehicle-video-srs-record.service vehicle-video-service.service
warn_missing_gst_plugin v4l2src
warn_missing_gst_plugin mpph264enc
@ -84,4 +86,4 @@ warn_missing_gst_plugin flvmux
warn_missing_gst_plugin rtmpsink
echo "Installed to $INSTALL_DIR"
echo "Services: vehicle-video-srs-live, vehicle-video-srs-record, vehicle-video-service"
echo "Services: vehicle-video-srs-record, vehicle-video-service"

View File

@ -1,7 +1,7 @@
[Unit]
Description=Vehicle Video Service
After=network-online.target vehicle-video-srs-live.service vehicle-video-srs-record.service
Wants=network-online.target vehicle-video-srs-live.service vehicle-video-srs-record.service
After=network-online.target vehicle-video-srs-record.service
Wants=network-online.target vehicle-video-srs-record.service
[Service]
Type=simple

209
docs/视频协议.md Normal file
View File

@ -0,0 +1,209 @@
# 通用视频软件视频协议
本文档定义通用视频软件与平台之间的视频相关 MQTT 协议。
本版本仅将实时视频心跳和实时播放地址获取作为联调依据。录像查询、录像播放章节为预留章节,暂缓实现,本版本不作为联调依据。
## 1. MQTT 连接
连接参数由软件运行目录下的 `config.json` 提供:
| 字段 | 说明 |
| --- | --- |
| `mqtt_server.address` | MQTT 服务器地址 |
| `mqtt_server.port` | MQTT 服务器端口 |
| `mqtt_server.client_id` | MQTT client id |
| `mqtt_server.username` | MQTT 用户名 |
| `mqtt_server.password` | MQTT 密码 |
| `mqtt_server.veh_id` | 设备/车辆唯一编号,协议 topic 中记为 `{vid}` |
| `mqtt_server.topic_profile` | topic 配置,默认 `protocol`,可切换为 `legacy` |
## 2. MQTT Topic
默认 `topic_profile=protocol` 时使用协议主题:
| 用途 | Topic |
| --- | --- |
| 实时视频状态上报 | `/zxwl/sweeper/{vid}/video/status` |
| 实时播放地址获取 | `/zxwl/sweeper/{vid}/video/query` |
| 录像段查询,预留 | `/zxwl/sweeper/{vid}/record/query` |
| 录像段播放,预留 | `/zxwl/sweeper/{vid}/record/play` |
兼容 `topic_profile=legacy` 时使用旧主题:
| 用途 | Topic |
| --- | --- |
| 实时视频状态上报 | `/kun/vehicle/video/status/{vid}` |
| 实时播放地址获取 | `/kun/vehicle/video/request/{vid}` |
| 录像段查询,预留 | `/kun/vehicle/video/record/query/{vid}` |
| 录像段播放,预留 | `/kun/vehicle/video/record/play/{vid}` |
## 3. 实时视频状态上报
### 3.1 数据协议
| 项目 | 内容 |
| --- | --- |
| 协议 | MQTT + JSON |
| 发送方 | 视频软件 |
| 接收方 | 平台 |
| 上报频率 | 默认 1s 一次,由 `mqtt_server.mqtt_heart_threshold` 配置 |
| 默认 topic | `/zxwl/sweeper/{vid}/video/status` |
### 3.2 消息内容
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `timestamp` | long | 上报时间戳13 位毫秒 |
| `status` | int | `0`:未推流或全部异常;`1`:全部实时通道正常;`2`:部分实时通道异常 |
| `channels` | array | 各通道实时推流状态 |
`channels` 对象结构:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `loc` | int | 摄像头位置索引0-7 |
| `running` | bool | 当前实时推流是否正常 |
| `reason` | string/null | 不正常时的原因;正常时为 `null` |
服务启动默认不向 live SRS 外推实时流。未收到 `switch=1` 前,心跳中通道 `running=false``reason` 表示实时推流未开启。
### 3.3 示例
```json
{
"timestamp": 1744247201000,
"status": 0,
"channels": [
{
"loc": 0,
"running": false,
"reason": "Live stream not requested"
}
]
}
```
## 4. 实时播放地址获取
### 4.1 数据协议
| 项目 | 内容 |
| --- | --- |
| 协议 | MQTT + JSON |
| 请求方 | 平台 |
| 应答方 | 视频软件 |
| 默认 topic | `/zxwl/sweeper/{vid}/video/query` |
请求和应答使用同一个 topic通过 `type``seqNo` 区分。
### 4.2 请求内容
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `type` | string | 固定为 `request` |
| `seqNo` | long/string | 应答流水号,请求和应答保持一致 |
| `data` | object | 请求内容 |
`data` 对象结构:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `switch` | int | `1`:开始向服务器推实时视频并返回播放 URL`0`:停止向服务器推实时视频,避免持续消耗外网流量 |
### 4.3 开始实时推流示例
请求:
```json
{
"type": "request",
"seqNo": 1234567890,
"data": {
"switch": 1
}
}
```
应答:
```json
{
"type": "response",
"seqNo": 1234567890,
"data": [
{
"loc": 0,
"url": "http://36.153.162.171:11985/rtc/v1/whep/?app=camera&stream=AHD1_main&vhost=live&eip=36.153.162.171:8000"
}
]
}
```
说明:
- URL 使用当前 `srs.live_playback_profile` 生成。
- 公网 profile 必须带 `eip=36.153.162.171:8000`
- `stream` 使用 `AHD*_main`,不带 `.flv`
- 切换实时推流状态时允许视频管线短暂重启。
### 4.4 停止实时推流示例
请求:
```json
{
"type": "request",
"seqNo": 1234567891,
"data": {
"switch": 0
}
}
```
应答:
```json
{
"type": "response",
"seqNo": 1234567891,
"data": []
}
```
收到 `switch=0` 后,视频软件停止向 live SRS 外推实时流;服务进程继续运行,录像分支仍由 `srs.record_enabled` 配置决定。
## 5. 录像段查询接口,预留
本章节暂缓实现,本版本不作为联调依据。当前已有录像代码不主动删除,但不纳入本次协议联调目标。
预留 topic
```text
/zxwl/sweeper/{vid}/record/query
```
预留请求字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `loc` | int | 摄像头位置索引 |
| `startTime` | long | 查询开始时间,毫秒 |
| `endTime` | long | 查询结束时间,毫秒 |
## 6. 录像段播放接口,预留
本章节暂缓实现,本版本不作为联调依据。当前已有录像代码不主动删除,但不纳入本次协议联调目标。
预留 topic
```text
/zxwl/sweeper/{vid}/record/play
```
预留请求字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `loc` | int | 摄像头位置索引 |
| `segmentId` | string | 录像段 ID |

View File

@ -4,6 +4,7 @@
#include <unistd.h>
#include <fstream>
#include <map>
#include <nlohmann/json.hpp>
#include <stdexcept>
#include <string>
@ -39,12 +40,21 @@ struct VehicleMQTTTopics
std::string record_query;
std::string record_play;
void fill_with_veh_id(const std::string& vehId)
void fill_with_veh_id(const std::string& vehId, const std::string& profile)
{
if (profile == "legacy")
{
heartbeat_up = "/kun/vehicle/video/status/" + vehId;
video_down = "/kun/vehicle/video/request/" + vehId;
record_query = "/kun/vehicle/video/record/query/" + vehId;
record_play = "/kun/vehicle/video/record/play/" + vehId;
return;
}
heartbeat_up = "/zxwl/sweeper/" + vehId + "/video/status";
video_down = "/zxwl/sweeper/" + vehId + "/video/query";
record_query = "/zxwl/sweeper/" + vehId + "/record/query";
record_play = "/zxwl/sweeper/" + vehId + "/record/play";
}
};
@ -57,6 +67,7 @@ struct MQTTConfig
std::string client_id;
std::string username;
std::string password;
std::string topic_profile = "protocol";
int keep_alive = 2000;
int qos = 1;
@ -67,11 +78,24 @@ struct MQTTConfig
struct SRSConfig
{
struct LiveProfile
{
std::string push_host;
int push_rtmp_port = 0;
std::string playback_scheme = "http";
std::string playback_host;
int playback_http_api_port = 0;
std::string playback_rtc_eip;
std::string vhost = "live";
};
std::string root = "/opt/vehicle-video-service/srs";
std::string record_config = "/opt/vehicle-video-service/srs/conf/record.conf";
std::string stream_app = "camera";
bool live_enabled = true;
bool record_enabled = true;
std::string live_push_profile = "wan_194";
std::string live_playback_profile = "wan_194";
std::string live_host = "127.0.0.1";
int live_rtmp_port = 1935;
int live_http_api_port = 1985;
@ -88,6 +112,10 @@ struct SRSConfig
int retention_days = 14;
double usage_threshold = 0.90;
std::string public_interface = "enP2p33s0";
std::map<std::string, LiveProfile> live_profiles = {
{"lan_194", {"192.168.4.194", 1935, "http", "192.168.4.194", 11985, "192.168.4.194:8000", "live"}},
{"wan_194", {"36.153.162.171", 19435, "http", "36.153.162.171", 11985, "36.153.162.171:8000", "live"}},
};
};
struct WebConfig
@ -152,8 +180,9 @@ struct AppConfig
cfg.mqtt.client_id = m.value("client_id", "");
cfg.mqtt.username = m.value("username", "");
cfg.mqtt.password = m.value("password", "");
cfg.mqtt.topic_profile = m.value("topic_profile", cfg.mqtt.topic_profile);
cfg.mqtt.keep_alive = m.value("mqtt_heart_threshold", cfg.mqtt.keep_alive);
cfg.mqtt.topics.fill_with_veh_id(cfg.mqtt.vehicle_id);
cfg.mqtt.topics.fill_with_veh_id(cfg.mqtt.vehicle_id, cfg.mqtt.topic_profile);
if (j.contains("srs"))
{
@ -163,6 +192,8 @@ struct AppConfig
cfg.srs.stream_app = s.value("stream_app", cfg.srs.stream_app);
cfg.srs.live_enabled = s.value("live_enabled", cfg.srs.live_enabled);
cfg.srs.record_enabled = s.value("record_enabled", cfg.srs.record_enabled);
cfg.srs.live_push_profile = s.value("live_push_profile", cfg.srs.live_push_profile);
cfg.srs.live_playback_profile = s.value("live_playback_profile", cfg.srs.live_playback_profile);
cfg.srs.live_host = s.value("live_host", cfg.srs.live_host);
cfg.srs.live_rtmp_port = s.value("live_rtmp_port", cfg.srs.live_rtmp_port);
cfg.srs.live_http_api_port = s.value("live_http_api_port", cfg.srs.live_http_api_port);
@ -179,6 +210,26 @@ struct AppConfig
cfg.srs.retention_days = s.value("retention_days", cfg.srs.retention_days);
cfg.srs.usage_threshold = s.value("usage_threshold", cfg.srs.usage_threshold);
cfg.srs.public_interface = s.value("public_interface", cfg.srs.public_interface);
if (s.contains("live_profiles") && s["live_profiles"].is_object())
{
cfg.srs.live_profiles.clear();
for (auto it = s["live_profiles"].begin(); it != s["live_profiles"].end(); ++it)
{
if (!it.value().is_object()) continue;
SRSConfig::LiveProfile profile;
const auto& p = it.value();
profile.push_host = p.value("push_host", profile.push_host);
profile.push_rtmp_port = p.value("push_rtmp_port", profile.push_rtmp_port);
profile.playback_scheme = p.value("playback_scheme", profile.playback_scheme);
profile.playback_host = p.value("playback_host", profile.playback_host);
profile.playback_http_api_port =
p.value("playback_http_api_port", profile.playback_http_api_port);
profile.playback_rtc_eip = p.value("playback_rtc_eip", profile.playback_rtc_eip);
profile.vhost = p.value("vhost", profile.vhost);
cfg.srs.live_profiles[it.key()] = profile;
}
}
}
if (j.contains("web"))
@ -194,6 +245,7 @@ struct AppConfig
LOG_INFO("[Config] Loaded MQTT server: " + cfg.mqtt.server_ip + ":" + std::to_string(cfg.mqtt.server_port));
LOG_INFO("[Config] MQTT client ID: " + cfg.mqtt.client_id);
LOG_INFO("[Config] MQTT Credentials - username: " + cfg.mqtt.username + ", password: " + cfg.mqtt.password);
LOG_INFO("[Config] MQTT topic profile: " + cfg.mqtt.topic_profile);
LOG_INFO("[Config] MQTT Topics: " + cfg.mqtt.topics.heartbeat_up + ", " + cfg.mqtt.topics.video_down);
LOG_INFO("[Config] MQTT keepAlive: " + std::to_string(cfg.mqtt.keep_alive));
LOG_INFO("[Config] SRS root: " + cfg.srs.root);

View File

@ -9,7 +9,7 @@ struct VideoPushRequest
struct DataItem
{
int switchVal; // 0=开始推流, 1=停止推流
int switchVal; // 1=start live stream, 0=stop live stream
std::vector<int> channels;
int streamType; // 0=主, 1=子
};

View File

@ -28,6 +28,9 @@ class RTMPManager
static void init();
static void start_all();
static void stop_all();
static void restart_all();
static void set_live_requested(bool requested);
static bool live_requested();
static bool is_streaming(const std::string& cam_name);
static std::string get_stream_url(const std::string& cam_name);
@ -59,6 +62,7 @@ class RTMPManager
static std::unordered_map<std::string, std::unique_ptr<StreamContext>> streams;
static std::mutex streams_mutex;
static std::atomic<bool> live_requested_;
static constexpr int RETRY_BASE_DELAY_MS = 3000;
};

View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="${BUILD_DIR:-$ROOT_DIR/build-aarch64}"
GENERATOR_ARGS=()
if command -v ninja >/dev/null 2>&1; then
GENERATOR_ARGS=(-G Ninja)
fi
cmake -S "$ROOT_DIR" -B "$BUILD_DIR" \
-DCMAKE_TOOLCHAIN_FILE="$ROOT_DIR/cmake/toolchains/aarch64-linux-gnu.cmake" \
"${GENERATOR_ARGS[@]}"
cmake --build "$BUILD_DIR" --target vehicle_video_service --parallel "$(nproc 2>/dev/null || echo 4)"
echo "Built $ROOT_DIR/bin/vehicle_video_service"

View File

@ -4,7 +4,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_BIN="$ROOT_DIR/bin/vehicle_video_service"
DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}"
PACKAGE_NAME="${PACKAGE_NAME:-vehicle-video-service-aarch64}"
PACKAGE_NAME="${PACKAGE_NAME:-vehicle-video-service-wan-aarch64}"
if [[ "${SKIP_VIDEO_BUILD:-0}" != "1" ]]; then
"$ROOT_DIR/scripts/build_video.sh"
@ -15,6 +15,7 @@ find_srs_bin() {
"${SRS_BIN:-}"
"$ROOT_DIR/build/srs/objs/srs"
"$ROOT_DIR/external/srs-server-5.0-r3/trunk/objs/srs"
"/opt/vehicle-video-service/srs/bin/srs"
"/home/aiec/srs-server-5.0-r3/trunk/objs/srs"
"/home/aiec/srs/bin/srs"
)
@ -33,6 +34,7 @@ find_srs_html() {
local candidates=(
"${SRS_HTML_DIR:-}"
"$ROOT_DIR/external/srs-server-5.0-r3/trunk/research/players"
"/opt/vehicle-video-service/srs/html"
"/home/aiec/srs/html"
"$ROOT_DIR/deploy/srs/html"
)

View File

@ -245,8 +245,7 @@ json status_json()
.count();
st["public_ip"] = get_ip_address(g_app_config.srs.public_interface);
st["disk"] = disk_status(g_app_config.srs.record_path);
st["services"] = json::array({service_active("vehicle-video-srs-live.service"),
service_active("vehicle-video-srs-record.service"),
st["services"] = json::array({service_active("vehicle-video-srs-record.service"),
service_active("vehicle-video-service.service")});
st["channels"] = json::array();
auto channels = RTMPManager::get_all_channels_status();
@ -370,6 +369,7 @@ const char* index_html()
<label>Client ID<input id="mqtt_client"></label>
<label><input id="mqtt_user"></label>
<label><input id="mqtt_pass" type="password"></label>
<label>Topic Profile<select id="mqtt_topic_profile"><option value="protocol">protocol</option><option value="legacy">legacy</option></select></label>
<label>使<input id="mqtt_need_pwd" type="checkbox"></label>
</div>
</section>
@ -381,6 +381,12 @@ const char* index_html()
<label><input id="record_config"></label>
<label><input id="public_interface"></label>
<label><input id="stream_app"></label>
<label><select id="live_push_profile"></select></label>
<label><select id="live_playback_profile"></select></label>
<label>Live Host<input id="live_host"></label>
<label>Live VHost<input id="live_vhost"></label>
<label>Record Host<input id="record_host"></label>
<label>Record VHost<input id="record_vhost"></label>
<label><input id="live_enabled" type="checkbox"></label>
<label><input id="record_enabled" type="checkbox"></label>
<label><input id="record_path"></label>
@ -415,6 +421,10 @@ const char* index_html()
let config = {};
const $ = id => document.getElementById(id);
const num = id => Number($(id).value);
const defaultLiveProfiles = {
lan_194: { push_host:'192.168.4.194', push_rtmp_port:1935, playback_scheme:'http', playback_host:'192.168.4.194', playback_http_api_port:11985, playback_rtc_eip:'192.168.4.194:8000', vhost:'live' },
wan_194: { push_host:'36.153.162.171', push_rtmp_port:19435, playback_scheme:'http', playback_host:'36.153.162.171', playback_http_api_port:11985, playback_rtc_eip:'36.153.162.171:8000', vhost:'live' }
};
async function api(path, options) {
const res = await fetch(path, options);
@ -438,6 +448,12 @@ function installHelp() {
helpFor('record_config', 'video服务读取这个SRS录像配置来解析录像目录HTTP端口和切片时长');
helpFor('public_interface', ' enP2p33s0IP会回退到127.0.0.1');
helpFor('stream_app', 'RTMP路径里的app名 camera rtmp://host/app/stream。');
helpFor('live_push_profile', '194NAT地址');
helpFor('live_playback_profile', 'MQTT返回194内网WHEP地址还是公网WHEP地址');
helpFor('live_host', '退RTMP主机使');
helpFor('live_vhost', '退vhost');
helpFor('record_host', '127.0.0.1');
helpFor('record_vhost', 'SRS的vhost');
helpFor('live_enabled', ' live SRS ');
helpFor('record_enabled', ' record SRS SRS写录像');
helpFor('record_path', 'SRS DVR保存mp4切片的根目录');
@ -451,6 +467,16 @@ function installHelp() {
helpFor('record_rtmp_port', 'video服务把录像流推到这个RTMP端口');
helpFor('record_http_api_port', 'record实例的SRS HTTP API端口');
helpFor('record_http_server_port', 'HTTP访问端口URL会走这个HTTP server');
helpFor('mqtt_topic_profile', 'protocol uses /zxwl/sweeper/{vid}/video/... topics; legacy uses /kun/vehicle/video/... topics.');
}
function fillProfileSelect(id, selected) {
const s = config.srs || {};
const profiles = Object.keys(s.live_profiles || defaultLiveProfiles);
const value = selected || profiles[0] || '';
const options = profiles.includes(value) ? profiles : [value, ...profiles];
$(id).innerHTML = options.map(p => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`).join('');
$(id).value = value;
}
function fillConfig() {
@ -464,6 +490,7 @@ function fillConfig() {
$('mqtt_client').value = m.client_id ?? '';
$('mqtt_user').value = m.username ?? '';
$('mqtt_pass').value = m.password ?? '';
$('mqtt_topic_profile').value = m.topic_profile ?? 'protocol';
$('mqtt_need_pwd').checked = !!m.need_username_pwd;
$('web_enabled').checked = w.enabled ?? true;
$('web_bind').value = w.bind ?? '0.0.0.0';
@ -474,6 +501,12 @@ function fillConfig() {
$('record_config').value = s.record_config ?? '';
$('public_interface').value = s.public_interface ?? 'enP2p33s0';
$('stream_app').value = s.stream_app ?? 'camera';
fillProfileSelect('live_push_profile', s.live_push_profile ?? 'wan_194');
fillProfileSelect('live_playback_profile', s.live_playback_profile ?? 'wan_194');
$('live_host').value = s.live_host ?? '127.0.0.1';
$('live_vhost').value = s.live_vhost ?? 'live';
$('record_host').value = s.record_host ?? '127.0.0.1';
$('record_vhost').value = s.record_vhost ?? 'record';
$('live_enabled').checked = s.live_enabled ?? true;
$('record_enabled').checked = s.record_enabled ?? true;
$('record_path').value = s.record_path ?? '/media/record';
@ -507,12 +540,19 @@ function collectConfig() {
veh_id: num('veh_id'), address: $('mqtt_addr').value, port: num('mqtt_port'),
need_username_pwd: $('mqtt_need_pwd').checked, client_id: $('mqtt_client').value,
username: $('mqtt_user').value, password: $('mqtt_pass').value,
topic_profile: $('mqtt_topic_profile').value,
mqtt_heart_threshold: num('mqtt_keepalive')
});
config.web = { enabled:$('web_enabled').checked, bind:$('web_bind').value, port:num('web_port'), username:$('web_username').value, password:$('web_password').value };
config.srs = config.srs || {};
if (!config.srs.live_profiles || Object.keys(config.srs.live_profiles).length === 0) {
config.srs.live_profiles = defaultLiveProfiles;
}
Object.assign(config.srs, {
root:$('srs_root').value, record_config:$('record_config').value, public_interface:$('public_interface').value,
live_push_profile:$('live_push_profile').value, live_playback_profile:$('live_playback_profile').value,
live_host:$('live_host').value, live_vhost:$('live_vhost').value,
record_host:$('record_host').value, record_vhost:$('record_vhost').value,
stream_app:$('stream_app').value, live_enabled:$('live_enabled').checked, record_enabled:$('record_enabled').checked,
record_path:$('record_path').value, dvr_duration_sec:num('dvr_duration'), retention_days:num('retention_days'),
usage_threshold:Number($('usage_threshold').value),

View File

@ -55,7 +55,7 @@ int main()
RTMPManager::init();
LOG_INFO("[MAIN] Starting all record streams...");
LOG_INFO("[MAIN] Starting initial video pipelines...");
RTMPManager::start_all();
std::thread mqtt_thread(

View File

@ -31,7 +31,7 @@ static void send_heartbeat()
auto channels_info = RTMPManager::get_all_channels_status();
nlohmann::json channels = nlohmann::json::array();
int total = static_cast<int>(channels_info.size());
int total = 0;
int running_count = 0;
for (const auto& ch : channels_info)
@ -40,6 +40,8 @@ static void send_heartbeat()
item["loc"] = ch.loc;
item["running"] = ch.running;
if (ch.reason != "Camera disabled") total++;
if (ch.running)
{
item["reason"] = nullptr;
@ -55,8 +57,8 @@ static void send_heartbeat()
nlohmann::json hb;
hb["timestamp"] = ms;
hb["status"] = (running_count == 0) ? 0 // 全部失败
: (running_count == total ? 1 : 2); // 全部正常 or 部分异常
hb["status"] = (total == 0 || running_count == 0) ? 0
: (running_count == total ? 1 : 2);
hb["channels"] = channels;
// 发布心跳
@ -84,27 +86,48 @@ static void handle_video_down_request(const nlohmann::json& req)
return;
}
int sw = req["data"].value("switch", 0);
if (sw != 1)
if (!req["data"].contains("switch"))
{
LOG_INFO("[video_down] switch != 1, ignore");
LOG_WARN("[video_down] Missing switch");
return;
}
// 取得当前所有 RTMP 播放地址
auto channels_info = RTMPManager::get_all_channels_status();
// 构造响应
int sw = req["data"].value("switch", -1);
nlohmann::json resp;
resp["type"] = "response";
resp["seqNo"] = req.value("seqNo", "0");
resp["seqNo"] = req.contains("seqNo") ? req["seqNo"] : nlohmann::json("0");
resp["data"] = nlohmann::json::array();
int loc = 0;
for (const auto& ch : channels_info)
if (sw == 1)
{
resp["data"].push_back({{"loc", loc}, {"url", ch.url}});
loc++;
if (!g_app_config.srs.live_enabled)
{
LOG_WARN("[video_down] switch=1 ignored because srs.live_enabled=false");
}
else
{
bool restart_needed = !RTMPManager::live_requested();
RTMPManager::set_live_requested(true);
if (restart_needed) RTMPManager::restart_all();
for (size_t i = 0; i < g_app_config.cameras.size(); ++i)
{
const auto& cam = g_app_config.cameras[i];
if (!cam.enabled) continue;
resp["data"].push_back({{"loc", static_cast<int>(i)}, {"url", RTMPManager::get_stream_url(cam.name)}});
}
}
}
else if (sw == 0)
{
bool restart_needed = RTMPManager::live_requested();
RTMPManager::set_live_requested(false);
if (restart_needed) RTMPManager::restart_all();
}
else
{
LOG_WARN("[video_down] Invalid switch: " + std::to_string(sw));
return;
}
// 发布回复

View File

@ -52,6 +52,7 @@ std::string get_ip_address(const std::string& ifname)
// ========== 静态成员 ==========
std::unordered_map<std::string, std::unique_ptr<RTMPManager::StreamContext>> RTMPManager::streams;
std::mutex RTMPManager::streams_mutex;
std::atomic<bool> RTMPManager::live_requested_{false};
// ========== 初始化 ==========
void RTMPManager::init()
@ -62,6 +63,54 @@ void RTMPManager::init()
std::string RTMPManager::make_key(const std::string& name) { return name + "_main"; }
static const SRSConfig::LiveProfile* find_live_profile(const std::string& name)
{
const auto& profiles = g_app_config.srs.live_profiles;
auto it = profiles.find(name);
if (it == profiles.end()) return nullptr;
return &it->second;
}
static std::string live_vhost_or_default(const SRSConfig::LiveProfile* profile)
{
if (profile && !profile->vhost.empty()) return profile->vhost;
return g_app_config.srs.live_vhost;
}
static std::string make_live_rtmp_url(const std::string& stream_name)
{
const auto& srs = g_app_config.srs;
const auto* profile = find_live_profile(srs.live_push_profile);
if (profile && !profile->push_host.empty() && profile->push_rtmp_port > 0)
{
return "rtmp://" + profile->push_host + ":" + std::to_string(profile->push_rtmp_port) + "/" +
srs.stream_app + "/" + stream_name + "?vhost=" + live_vhost_or_default(profile);
}
return "rtmp://" + srs.live_host + ":" + std::to_string(srs.live_rtmp_port) + "/" + srs.stream_app + "/" +
stream_name + "?vhost=" + srs.live_vhost;
}
static std::string make_live_whep_url(const std::string& stream_name)
{
const auto& srs = g_app_config.srs;
const auto* profile = find_live_profile(srs.live_playback_profile);
if (profile && !profile->playback_host.empty() && profile->playback_http_api_port > 0)
{
const std::string scheme = profile->playback_scheme.empty() ? "http" : profile->playback_scheme;
std::string url = scheme + "://" + profile->playback_host + ":" +
std::to_string(profile->playback_http_api_port) + "/rtc/v1/whep/?app=" + srs.stream_app +
"&stream=" + stream_name + "&vhost=" + live_vhost_or_default(profile);
if (!profile->playback_rtc_eip.empty()) url += "&eip=" + profile->playback_rtc_eip;
return url;
}
std::string ip = get_ip_address(srs.public_interface);
if (ip.empty()) ip = "127.0.0.1";
return "http://" + ip + ":" + std::to_string(srs.live_http_api_port) + "/rtc/v1/whep/?app=" + srs.stream_app +
"&stream=" + stream_name + "&vhost=" + srs.live_vhost;
}
// ========== 创建推流管线 ==========
GstElement* RTMPManager::create_pipeline(const Camera& cam)
{
@ -72,8 +121,7 @@ GstElement* RTMPManager::create_pipeline(const Camera& cam)
const std::string stream_name = cam.name + "_main";
const auto& srs = g_app_config.srs;
const std::string live_rtmp = "rtmp://" + srs.live_host + ":" + std::to_string(srs.live_rtmp_port) + "/" +
srs.stream_app + "/" + stream_name + "?vhost=" + srs.live_vhost;
const std::string live_rtmp = make_live_rtmp_url(stream_name);
const std::string record_rtmp = "rtmp://" + srs.record_host + ":" + std::to_string(srs.record_rtmp_port) + "/" +
srs.stream_app + "/" + stream_name + "?vhost=" + srs.record_vhost;
@ -117,7 +165,7 @@ GstElement* RTMPManager::create_pipeline(const Camera& cam)
" rc-mode=cbr "
" ! h264parse ! tee name=t ";
if (srs.live_enabled)
if (srs.live_enabled && live_requested_.load())
{
pipeline_str += "t. ! queue max-size-buffers=5 leaky=downstream "
" ! flvmux streamable=true name=mux_live "
@ -331,9 +379,17 @@ void RTMPManager::stream_loop(Camera cam, StreamContext* ctx)
// ========== 启停与状态 ==========
void RTMPManager::start_all()
{
LOG_INFO("[RTMP] Starting all record streams...");
LOG_INFO("[RTMP] Starting all enabled streams...");
std::lock_guard<std::mutex> lock(streams_mutex);
const bool need_record = g_app_config.srs.record_enabled;
const bool need_live = g_app_config.srs.live_enabled && live_requested_.load();
if (!need_record && !need_live)
{
LOG_INFO("[RTMP] Neither record nor live streaming is enabled, skip camera pipelines");
return;
}
int delay_ms = 0;
for (auto& cam : g_app_config.cameras)
{
@ -374,6 +430,16 @@ void RTMPManager::stop_all()
streams.clear();
}
void RTMPManager::restart_all()
{
stop_all();
start_all();
}
void RTMPManager::set_live_requested(bool requested) { live_requested_.store(requested); }
bool RTMPManager::live_requested() { return live_requested_.load(); }
bool RTMPManager::is_streaming(const std::string& cam_name)
{
std::lock_guard<std::mutex> lock(streams_mutex);
@ -387,11 +453,7 @@ bool RTMPManager::is_streaming(const std::string& cam_name)
std::string RTMPManager::get_stream_url(const std::string& cam_name)
{
const auto& srs = g_app_config.srs;
std::string ip = get_ip_address(srs.public_interface);
if (ip.empty()) ip = "127.0.0.1";
return "http://" + ip + ":" + std::to_string(srs.live_http_api_port) + "/rtc/v1/whep/?app=" + srs.stream_app +
"&stream=" + cam_name + "_main&vhost=" + srs.live_vhost;
return make_live_whep_url(cam_name + "_main");
}
// ========== 汇总状态 ==========
std::vector<RTMPManager::ChannelInfo> RTMPManager::get_all_channels_status()
@ -408,6 +470,20 @@ std::vector<RTMPManager::ChannelInfo> RTMPManager::get_all_channels_status()
ch.loc = static_cast<int>(i);
ch.url.clear();
ch.running = false;
ch.reason = cam.enabled ? "Live stream not requested" : "Camera disabled";
if (!cam.enabled)
{
result.push_back(ch);
continue;
}
if (!live_requested_.load())
{
result.push_back(ch);
continue;
}
ch.reason = "Not started";
auto it = streams.find(key);