From 976fe61f9439e501154424553300ee86fab83d35 Mon Sep 17 00:00:00 2001 From: cxh Date: Fri, 5 Jun 2026 09:25:04 +0800 Subject: [PATCH] Implement on-demand live video protocol --- cmake/toolchains/aarch64-linux-gnu.cmake | 14 ++ config.json | 183 ++++++++------- deploy/install.sh | 6 +- .../systemd/vehicle-video-service.service.in | 4 +- docs/视频协议.md | 209 ++++++++++++++++++ include/app_config.hpp | 64 +++++- include/data_manager.hpp | 2 +- include/rtmp_manager.hpp | 4 + scripts/build_video_aarch64_wsl.sh | 17 ++ scripts/package_release.sh | 4 +- src/config_server.cpp | 44 +++- src/main.cpp | 2 +- src/mqtt_client_wrapper.cpp | 53 +++-- src/rtmp_manager.cpp | 94 +++++++- 14 files changed, 581 insertions(+), 119 deletions(-) create mode 100644 cmake/toolchains/aarch64-linux-gnu.cmake create mode 100644 docs/视频协议.md create mode 100644 scripts/build_video_aarch64_wsl.sh diff --git a/cmake/toolchains/aarch64-linux-gnu.cmake b/cmake/toolchains/aarch64-linux-gnu.cmake new file mode 100644 index 0000000..4af4cc6 --- /dev/null +++ b/cmake/toolchains/aarch64-linux-gnu.cmake @@ -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") diff --git a/config.json b/config.json index eda619f..8f1a041 100644 --- a/config.json +++ b/config.json @@ -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" + } } diff --git a/deploy/install.sh b/deploy/install.sh index ae2189e..02a9144 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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" diff --git a/deploy/systemd/vehicle-video-service.service.in b/deploy/systemd/vehicle-video-service.service.in index 9a87c23..94336ef 100644 --- a/deploy/systemd/vehicle-video-service.service.in +++ b/deploy/systemd/vehicle-video-service.service.in @@ -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 diff --git a/docs/视频协议.md b/docs/视频协议.md new file mode 100644 index 0000000..0b4a2ca --- /dev/null +++ b/docs/视频协议.md @@ -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 | diff --git a/include/app_config.hpp b/include/app_config.hpp index 7c6f727..b7f5a69 100644 --- a/include/app_config.hpp +++ b/include/app_config.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -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) { - 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; + 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 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); diff --git a/include/data_manager.hpp b/include/data_manager.hpp index f553b23..1765b57 100644 --- a/include/data_manager.hpp +++ b/include/data_manager.hpp @@ -9,7 +9,7 @@ struct VideoPushRequest struct DataItem { - int switchVal; // 0=开始推流, 1=停止推流 + int switchVal; // 1=start live stream, 0=stop live stream std::vector channels; int streamType; // 0=主, 1=子 }; diff --git a/include/rtmp_manager.hpp b/include/rtmp_manager.hpp index 835e282..281f467 100644 --- a/include/rtmp_manager.hpp +++ b/include/rtmp_manager.hpp @@ -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> streams; static std::mutex streams_mutex; + static std::atomic live_requested_; static constexpr int RETRY_BASE_DELAY_MS = 3000; }; diff --git a/scripts/build_video_aarch64_wsl.sh b/scripts/build_video_aarch64_wsl.sh new file mode 100644 index 0000000..35000d4 --- /dev/null +++ b/scripts/build_video_aarch64_wsl.sh @@ -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" diff --git a/scripts/package_release.sh b/scripts/package_release.sh index cbfce8b..2cf9c58 100755 --- a/scripts/package_release.sh +++ b/scripts/package_release.sh @@ -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" ) diff --git a/src/config_server.cpp b/src/config_server.cpp index a1f08bb..1686dd0 100644 --- a/src/config_server.cpp +++ b/src/config_server.cpp @@ -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() + @@ -381,6 +381,12 @@ const char* index_html() + + + + + + @@ -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', '用于生成对外播放地址的网卡名,比如 enP2p33s0;取不到IP会回退到127.0.0.1。'); helpFor('stream_app', 'RTMP路径里的app名,默认 camera,对应 rtmp://host/app/stream。'); + helpFor('live_push_profile', '控制实时流推到194内网地址还是公网NAT地址。'); + 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 => ``).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), diff --git a/src/main.cpp b/src/main.cpp index 722eaf3..beee970 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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( diff --git a/src/mqtt_client_wrapper.cpp b/src/mqtt_client_wrapper.cpp index 2aea9cc..ece21ea 100644 --- a/src/mqtt_client_wrapper.cpp +++ b/src/mqtt_client_wrapper.cpp @@ -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(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(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; } // 发布回复 diff --git a/src/rtmp_manager.cpp b/src/rtmp_manager.cpp index dc099ed..27cd82c 100644 --- a/src/rtmp_manager.cpp +++ b/src/rtmp_manager.cpp @@ -52,6 +52,7 @@ std::string get_ip_address(const std::string& ifname) // ========== 静态成员 ========== std::unordered_map> RTMPManager::streams; std::mutex RTMPManager::streams_mutex; +std::atomic 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 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 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::get_all_channels_status() @@ -408,6 +470,20 @@ std::vector RTMPManager::get_all_channels_status() ch.loc = static_cast(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);