Compare commits

...

3 Commits

Author SHA1 Message Date
cxh
be68ee5180 新增项目管理文档 2026-05-09 13:12:38 +08:00
cxh
5f88b2198e 优化Web管理页状态和参数说明 2026-05-09 12:51:17 +08:00
cxh
a6ee43d6af 添加Web配置管理页面 2026-05-09 11:36:51 +08:00
9 changed files with 1101 additions and 18 deletions

View File

@ -76,3 +76,19 @@ The application reads `config.json` next to the executable at:
```text ```text
/opt/vehicle-video-service/bin/config.json /opt/vehicle-video-service/bin/config.json
``` ```
## Web Configuration
The service includes a small built-in configuration page:
```text
http://<device-ip>:18080
```
Default login:
```text
admin / admin123
```
The page can edit camera channels, live/record switches, SRS ports, record path, MQTT settings, and the management page port. Saving writes `config.json` and regenerates SRS `live.conf` / `record.conf` under the configured SRS root. Restart the related services after saving for runtime changes to take effect.

View File

@ -13,16 +13,32 @@
"root": "/opt/vehicle-video-service/srs", "root": "/opt/vehicle-video-service/srs",
"record_config": "/opt/vehicle-video-service/srs/conf/record.conf", "record_config": "/opt/vehicle-video-service/srs/conf/record.conf",
"stream_app": "camera", "stream_app": "camera",
"live_enabled": true,
"record_enabled": true,
"live_host": "127.0.0.1", "live_host": "127.0.0.1",
"live_rtmp_port": 1935, "live_rtmp_port": 1935,
"live_http_api_port": 1985, "live_http_api_port": 1985,
"live_http_server_port": 8080,
"live_rtc_port": 8000,
"live_vhost": "live", "live_vhost": "live",
"record_host": "127.0.0.1", "record_host": "127.0.0.1",
"record_rtmp_port": 2935, "record_rtmp_port": 2935,
"record_http_api_port": 2985, "record_http_api_port": 2985,
"record_http_server_port": 2980,
"record_vhost": "record", "record_vhost": "record",
"record_path": "/media/record",
"dvr_duration_sec": 60,
"retention_days": 14,
"usage_threshold": 0.9,
"public_interface": "enP2p33s0" "public_interface": "enP2p33s0"
}, },
"web": {
"enabled": true,
"bind": "0.0.0.0",
"port": 18080,
"username": "admin",
"password": "admin123"
},
"cameras": [ "cameras": [
{ {
"device": "/dev/video0", "device": "/dev/video0",

236
docs/project-management.md Normal file
View File

@ -0,0 +1,236 @@
# Vehicle Video Service Project Management
## Project Summary
This project packages the vehicle video stack as a single deployable service bundle.
Main components:
- `vehicle_video_service`: C++ service for camera capture, RTMP push, MQTT heartbeats, live URL replies, record query, and record playback replies.
- Bundled SRS source: `external/srs-server-5.0-r3`.
- SRS live instance: low-latency live playback through RTMP to WebRTC/WHEP.
- SRS record instance: DVR segment recording and HTTP file serving.
- Built-in web configuration page: `http://<device-ip>:18080`.
Default install path:
```text
/opt/vehicle-video-service
```
Default record path:
```text
/media/record
```
## Current Services
Installed systemd services:
```text
vehicle-video-srs-live.service
vehicle-video-srs-record.service
vehicle-video-service.service
```
Legacy services to remove on old devices:
```text
srs-live.service
srs-record.service
video_manager.service
```
Removal command:
```bash
sudo systemctl disable --now srs-live.service srs-record.service video_manager.service
sudo rm -f /etc/systemd/system/srs-live.service
sudo rm -f /etc/systemd/system/srs-record.service
sudo rm -f /etc/systemd/system/video_manager.service
sudo systemctl daemon-reload
sudo systemctl reset-failed
```
## Build And Package
Build everything:
```bash
./scripts/build_all.sh
```
Build only SRS:
```bash
./scripts/build_srs.sh
```
Build only video service:
```bash
./scripts/build_video.sh
```
Package release:
```bash
./scripts/package_release.sh
```
Release artifact:
```text
dist/vehicle-video-service-aarch64.tar.gz
```
SRS is built out-of-tree into:
```text
build/srs/objs/srs
```
## Install / Upgrade
Recommended clean install from package:
```bash
mkdir -p /tmp/vvs-install
cd /tmp/vvs-install
tar -xzf ~/kunlang_video/dist/vehicle-video-service-aarch64.tar.gz
cd vehicle-video-service-aarch64
sudo ./install.sh
```
If extracting in `dist/`, old extracted files may be owned by root. Remove them with:
```bash
cd ~/kunlang_video/dist
sudo rm -rf vehicle-video-service-aarch64
tar -xzf vehicle-video-service-aarch64.tar.gz
cd vehicle-video-service-aarch64
sudo ./install.sh
```
For first install, `install.sh` enables and starts services. For upgrade installs, restart services after install:
```bash
sudo systemctl restart vehicle-video-srs-live.service vehicle-video-srs-record.service vehicle-video-service.service
```
## Web Configuration
Default URL:
```text
http://<device-ip>:18080
```
Default login:
```text
admin / admin123
```
Current web page capabilities:
- Edit MQTT settings.
- Edit camera channels: enabled, name, device node, bitrate, width, height, fps.
- Enable or disable live stream push.
- Enable or disable record stream push.
- Edit SRS root, record config path, app name, public network interface.
- Edit record path, DVR segment duration, retention days, disk cleanup threshold.
- Edit live and record SRS ports.
- Show runtime status as a channel table:
- channel
- device node
- enabled/disabled
- device exists/missing
- running/stopped
- reason
- playback URL
Saving config writes:
```text
/opt/vehicle-video-service/bin/config.json
/opt/vehicle-video-service/srs/conf/live.conf
/opt/vehicle-video-service/srs/conf/record.conf
```
Runtime changes require service restart in the current version.
## Important Config Fields
SRS and record:
- `srs.root`: SRS runtime root directory.
- `srs.record_config`: SRS record config read by `RecordManager`.
- `srs.stream_app`: RTMP app name, default `camera`.
- `srs.live_enabled`: whether to push live stream branch.
- `srs.record_enabled`: whether to push record stream branch.
- `srs.record_path`: DVR mp4 root directory.
- `srs.dvr_duration_sec`: DVR file segment duration.
- `srs.retention_days`: record retention days.
- `srs.usage_threshold`: disk cleanup threshold, for example `0.9`.
- `srs.public_interface`: network interface used to generate playback URLs.
Ports:
- `live_rtmp_port`: RTMP ingest port for live SRS.
- `live_http_api_port`: SRS HTTP API / WHEP playback port.
- `live_http_server_port`: SRS static HTTP / HTTP-FLV port.
- `live_rtc_port`: WebRTC UDP media port.
- `record_rtmp_port`: RTMP ingest port for record SRS.
- `record_http_api_port`: HTTP API port for record SRS.
- `record_http_server_port`: HTTP port used for record playback files.
## Verification Commands
Service state:
```bash
systemctl status vehicle-video-srs-live.service
systemctl status vehicle-video-srs-record.service
systemctl status vehicle-video-service.service
```
Ports:
```bash
ss -lntup | grep -E ':(1935|2935|1985|2985|8080|2980|8000|18080)\b'
```
Logs:
```bash
journalctl -u vehicle-video-service.service -f
journalctl -u vehicle-video-srs-live.service -f
journalctl -u vehicle-video-srs-record.service -f
```
Package content:
```bash
tar -tzf dist/vehicle-video-service-aarch64.tar.gz | head
```
## Current Known Notes
- `main` is ahead of `origin/main`; remember to push when ready.
- Web config save does not hot-reload video pipelines yet.
- SRS config changes require service restart.
- `dist/vehicle-video-service-aarch64/` can contain root-owned files if install was run directly from that extracted directory.
- `external/srs-server-5.0-r3/trunk/objs/` is intentionally ignored.
## Backlog
- Add hot restart for a single camera channel.
- Add one-click restart buttons in the web page.
- Add port conflict checks before saving config.
- Add record path permission checks before saving config.
- Add GStreamer plugin check display.
- Add config backup and restore from the web page.
- Add safer credential handling for web login and MQTT password.
- Add live preview/playback links per channel in the web UI.

View File

@ -70,22 +70,41 @@ struct SRSConfig
std::string root = "/opt/vehicle-video-service/srs"; std::string root = "/opt/vehicle-video-service/srs";
std::string record_config = "/opt/vehicle-video-service/srs/conf/record.conf"; std::string record_config = "/opt/vehicle-video-service/srs/conf/record.conf";
std::string stream_app = "camera"; std::string stream_app = "camera";
bool live_enabled = true;
bool record_enabled = true;
std::string live_host = "127.0.0.1"; std::string live_host = "127.0.0.1";
int live_rtmp_port = 1935; int live_rtmp_port = 1935;
int live_http_api_port = 1985; int live_http_api_port = 1985;
int live_http_server_port = 8080;
int live_rtc_port = 8000;
std::string live_vhost = "live"; std::string live_vhost = "live";
std::string record_host = "127.0.0.1"; std::string record_host = "127.0.0.1";
int record_rtmp_port = 2935; int record_rtmp_port = 2935;
int record_http_api_port = 2985; int record_http_api_port = 2985;
int record_http_server_port = 2980;
std::string record_vhost = "record"; std::string record_vhost = "record";
std::string record_path = "/media/record";
int dvr_duration_sec = 60;
int retention_days = 14;
double usage_threshold = 0.90;
std::string public_interface = "enP2p33s0"; std::string public_interface = "enP2p33s0";
}; };
struct WebConfig
{
bool enabled = true;
std::string bind = "0.0.0.0";
int port = 18080;
std::string username = "admin";
std::string password = "admin123";
};
struct AppConfig struct AppConfig
{ {
std::vector<Camera> cameras; std::vector<Camera> cameras;
MQTTConfig mqtt; MQTTConfig mqtt;
SRSConfig srs; SRSConfig srs;
WebConfig web;
static AppConfig load_from_file(const std::string& filepath) static AppConfig load_from_file(const std::string& filepath)
{ {
@ -142,17 +161,36 @@ struct AppConfig
cfg.srs.root = s.value("root", cfg.srs.root); cfg.srs.root = s.value("root", cfg.srs.root);
cfg.srs.record_config = s.value("record_config", cfg.srs.record_config); cfg.srs.record_config = s.value("record_config", cfg.srs.record_config);
cfg.srs.stream_app = s.value("stream_app", cfg.srs.stream_app); 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_host = s.value("live_host", cfg.srs.live_host); 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_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); cfg.srs.live_http_api_port = s.value("live_http_api_port", cfg.srs.live_http_api_port);
cfg.srs.live_http_server_port = s.value("live_http_server_port", cfg.srs.live_http_server_port);
cfg.srs.live_rtc_port = s.value("live_rtc_port", cfg.srs.live_rtc_port);
cfg.srs.live_vhost = s.value("live_vhost", cfg.srs.live_vhost); cfg.srs.live_vhost = s.value("live_vhost", cfg.srs.live_vhost);
cfg.srs.record_host = s.value("record_host", cfg.srs.record_host); cfg.srs.record_host = s.value("record_host", cfg.srs.record_host);
cfg.srs.record_rtmp_port = s.value("record_rtmp_port", cfg.srs.record_rtmp_port); cfg.srs.record_rtmp_port = s.value("record_rtmp_port", cfg.srs.record_rtmp_port);
cfg.srs.record_http_api_port = s.value("record_http_api_port", cfg.srs.record_http_api_port); cfg.srs.record_http_api_port = s.value("record_http_api_port", cfg.srs.record_http_api_port);
cfg.srs.record_http_server_port = s.value("record_http_server_port", cfg.srs.record_http_server_port);
cfg.srs.record_vhost = s.value("record_vhost", cfg.srs.record_vhost); cfg.srs.record_vhost = s.value("record_vhost", cfg.srs.record_vhost);
cfg.srs.record_path = s.value("record_path", cfg.srs.record_path);
cfg.srs.dvr_duration_sec = s.value("dvr_duration_sec", cfg.srs.dvr_duration_sec);
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); cfg.srs.public_interface = s.value("public_interface", cfg.srs.public_interface);
} }
if (j.contains("web"))
{
auto& w = j["web"];
cfg.web.enabled = w.value("enabled", cfg.web.enabled);
cfg.web.bind = w.value("bind", cfg.web.bind);
cfg.web.port = w.value("port", cfg.web.port);
cfg.web.username = w.value("username", cfg.web.username);
cfg.web.password = w.value("password", cfg.web.password);
}
LOG_INFO("[Config] Loaded MQTT server: " + cfg.mqtt.server_ip + ":" + std::to_string(cfg.mqtt.server_port)); 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 client ID: " + cfg.mqtt.client_id);
LOG_INFO("[Config] MQTT Credentials - username: " + cfg.mqtt.username + ", password: " + cfg.mqtt.password); LOG_INFO("[Config] MQTT Credentials - username: " + cfg.mqtt.username + ", password: " + cfg.mqtt.password);

26
include/config_server.hpp Normal file
View File

@ -0,0 +1,26 @@
#pragma once
#include <atomic>
#include <string>
#include <thread>
#include "app_config.hpp"
class ConfigServer
{
public:
ConfigServer() = default;
~ConfigServer();
bool start(const std::string& config_path);
void stop();
private:
void run();
void handle_client(int client_fd);
std::string config_path_;
std::thread thread_;
std::atomic<bool> running_{false};
int server_fd_ = -1;
};

View File

@ -5,7 +5,6 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_BIN="$ROOT_DIR/bin/vehicle_video_service" APP_BIN="$ROOT_DIR/bin/vehicle_video_service"
DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}" DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}"
PACKAGE_NAME="${PACKAGE_NAME:-vehicle-video-service-aarch64}" PACKAGE_NAME="${PACKAGE_NAME:-vehicle-video-service-aarch64}"
STAGE_DIR="$DIST_DIR/$PACKAGE_NAME"
if [[ "${SKIP_VIDEO_BUILD:-0}" != "1" ]]; then if [[ "${SKIP_VIDEO_BUILD:-0}" != "1" ]]; then
"$ROOT_DIR/scripts/build_video.sh" "$ROOT_DIR/scripts/build_video.sh"
@ -63,7 +62,11 @@ if [[ ! -x "$APP_BIN" ]]; then
exit 1 exit 1
fi fi
rm -rf "$STAGE_DIR" mkdir -p "$DIST_DIR"
STAGE_PARENT="$(mktemp -d "$DIST_DIR/.package.XXXXXX")"
STAGE_DIR="$STAGE_PARENT/$PACKAGE_NAME"
trap 'rm -rf "$STAGE_PARENT"' EXIT
mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/config" "$STAGE_DIR/srs/bin" "$STAGE_DIR/srs/conf" \ mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/config" "$STAGE_DIR/srs/bin" "$STAGE_DIR/srs/conf" \
"$STAGE_DIR/srs/html" "$STAGE_DIR/srs/log" "$STAGE_DIR/srs/run" "$STAGE_DIR/systemd" "$STAGE_DIR/srs/html" "$STAGE_DIR/srs/log" "$STAGE_DIR/srs/run" "$STAGE_DIR/systemd"
@ -78,6 +81,6 @@ install -m 0755 "$ROOT_DIR/deploy/install.sh" "$STAGE_DIR/install.sh"
install -m 0755 "$ROOT_DIR/deploy/uninstall.sh" "$STAGE_DIR/uninstall.sh" install -m 0755 "$ROOT_DIR/deploy/uninstall.sh" "$STAGE_DIR/uninstall.sh"
install -m 0644 "$ROOT_DIR/README.md" "$STAGE_DIR/README.md" install -m 0644 "$ROOT_DIR/README.md" "$STAGE_DIR/README.md"
tar -C "$DIST_DIR" -czf "$DIST_DIR/$PACKAGE_NAME.tar.gz" "$PACKAGE_NAME" tar -C "$STAGE_PARENT" -czf "$DIST_DIR/$PACKAGE_NAME.tar.gz" "$PACKAGE_NAME"
echo "Packaged $DIST_DIR/$PACKAGE_NAME.tar.gz" echo "Packaged $DIST_DIR/$PACKAGE_NAME.tar.gz"

739
src/config_server.cpp Normal file
View File

@ -0,0 +1,739 @@
#include "config_server.hpp"
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <unistd.h>
#include <cerrno>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "logger.hpp"
#include "rtmp_manager.hpp"
extern AppConfig g_app_config;
namespace
{
using json = nlohmann::json;
namespace fs = std::filesystem;
std::string read_file(const std::string& path)
{
std::ifstream ifs(path);
if (!ifs.is_open()) return "";
std::ostringstream ss;
ss << ifs.rdbuf();
return ss.str();
}
bool write_file_atomic(const std::string& path, const std::string& content)
{
std::string tmp = path + ".tmp";
{
std::ofstream ofs(tmp, std::ios::trunc);
if (!ofs.is_open()) return false;
ofs << content;
if (!ofs.good()) return false;
}
return ::rename(tmp.c_str(), path.c_str()) == 0;
}
std::string http_reason(int status)
{
switch (status)
{
case 200:
return "OK";
case 400:
return "Bad Request";
case 401:
return "Unauthorized";
case 404:
return "Not Found";
case 405:
return "Method Not Allowed";
case 500:
return "Internal Server Error";
default:
return "OK";
}
}
void send_response(int fd, int status, const std::string& content_type, const std::string& body,
const std::vector<std::pair<std::string, std::string>>& extra_headers = {})
{
std::ostringstream resp;
resp << "HTTP/1.1 " << status << " " << http_reason(status) << "\r\n";
resp << "Content-Type: " << content_type << "\r\n";
resp << "Content-Length: " << body.size() << "\r\n";
resp << "Connection: close\r\n";
for (const auto& h : extra_headers) resp << h.first << ": " << h.second << "\r\n";
resp << "\r\n";
resp << body;
std::string raw = resp.str();
::send(fd, raw.data(), raw.size(), MSG_NOSIGNAL);
}
std::string url_decode(const std::string& s)
{
std::string out;
for (size_t i = 0; i < s.size(); ++i)
{
if (s[i] == '%' && i + 2 < s.size())
{
char hex[3] = {s[i + 1], s[i + 2], 0};
out.push_back(static_cast<char>(strtol(hex, nullptr, 16)));
i += 2;
}
else if (s[i] == '+')
{
out.push_back(' ');
}
else
{
out.push_back(s[i]);
}
}
return out;
}
std::string base64_decode(const std::string& in)
{
static const std::string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
int val = 0;
int valb = -8;
for (unsigned char c : in)
{
if (std::isspace(c)) continue;
if (c == '=') break;
int idx = chars.find(c);
if (idx == (int)std::string::npos) return "";
val = (val << 6) + idx;
valb += 6;
if (valb >= 0)
{
out.push_back(char((val >> valb) & 0xFF));
valb -= 8;
}
}
return out;
}
bool authorized(const std::string& headers)
{
if (g_app_config.web.username.empty()) return true;
std::string needle = "Authorization: Basic ";
auto pos = headers.find(needle);
if (pos == std::string::npos) return false;
pos += needle.size();
auto end = headers.find("\r\n", pos);
std::string token = headers.substr(pos, end - pos);
return base64_decode(token) == g_app_config.web.username + ":" + g_app_config.web.password;
}
std::string make_live_conf(const json& cfg)
{
const auto& s = cfg.at("srs");
std::ostringstream os;
os << "listen " << s.value("live_rtmp_port", 1935) << ";\n";
os << "max_connections 2000;\n";
os << "daemon off;\n";
os << "pid ./run/srs_live.pid;\n";
os << "srs_log_tank file;\n";
os << "srs_log_file ./log/srs_live.log;\n\n";
os << "http_server {\n enabled on;\n listen " << s.value("live_http_server_port", 8080)
<< ";\n dir ./html;\n}\n\n";
os << "http_api {\n enabled on;\n listen " << s.value("live_http_api_port", 1985)
<< ";\n}\n\n";
os << "stats {\n network 0;\n}\n\n";
os << "rtc_server {\n enabled on;\n listen " << s.value("live_rtc_port", 8000)
<< ";\n candidate $CANDIDATE;\n}\n\n";
os << "vhost " << s.value("live_vhost", "live") << " {\n";
os << " rtc {\n enabled on;\n rtmp_to_rtc on;\n rtc_to_rtmp off;\n }\n\n";
os << " play {\n mw_latency 100;\n queue_length 10;\n }\n\n";
os << " http_remux {\n enabled on;\n mount [vhost]/[app]/[stream].flv;\n }\n\n";
os << " dvr {\n enabled off;\n }\n hls {\n enabled off;\n }\n forward {\n enabled off;\n }\n}\n";
return os.str();
}
std::string make_record_conf(const json& cfg)
{
const auto& s = cfg.at("srs");
std::string record_path = s.value("record_path", "/media/record");
if (!record_path.empty() && record_path.back() == '/') record_path.pop_back();
std::ostringstream os;
os << "listen " << s.value("record_rtmp_port", 2935) << ";\n";
os << "pid ./run/srs_record.pid;\n";
os << "srs_log_tank file;\n";
os << "srs_log_file ./log/srs_record.log;\n";
os << "max_connections 2000;\n";
os << "daemon off;\n\n";
os << "http_server {\n enabled on;\n listen " << s.value("record_http_server_port", 2980)
<< ";\n dir ./html;\n}\n\n";
os << "http_api {\n enabled on;\n listen " << s.value("record_http_api_port", 2985)
<< ";\n}\n\n";
os << "stats {\n network 0;\n}\n\n";
os << "vhost " << s.value("record_vhost", "record") << " {\n";
os << " dvr {\n enabled " << (s.value("record_enabled", true) ? "on" : "off")
<< ";\n dvr_plan segment;\n dvr_duration " << s.value("dvr_duration_sec", 60)
<< ";\n dvr_apply all;\n\n";
os << " dvr_path " << record_path
<< "/[stream]/[2006]-[01]-[02]/[15]/[15]-[04]-[05].mp4;\n\n time_jitter full;\n }\n\n";
os << " http_remux {\n enabled on;\n mount [vhost]/[app]/[stream].flv;\n }\n\n";
os << " rtc {\n enabled off;\n }\n}\n";
return os.str();
}
json disk_status(const std::string& path)
{
json d;
d["path"] = path;
struct statvfs vfs{};
if (statvfs(path.c_str(), &vfs) != 0)
{
d["ok"] = false;
d["error"] = strerror(errno);
return d;
}
uint64_t total = static_cast<uint64_t>(vfs.f_blocks) * vfs.f_frsize;
uint64_t avail = static_cast<uint64_t>(vfs.f_bavail) * vfs.f_frsize;
d["ok"] = true;
d["total_bytes"] = total;
d["available_bytes"] = avail;
d["used_percent"] = total == 0 ? 0 : (100.0 * (double)(total - avail) / (double)total);
return d;
}
json service_active(const std::string& name)
{
json s;
s["name"] = name;
std::string cmd = "systemctl is-active " + name + " 2>/dev/null";
FILE* fp = popen(cmd.c_str(), "r");
if (!fp)
{
s["active"] = "unknown";
return s;
}
char buf[64] = {0};
std::string out;
if (fgets(buf, sizeof(buf), fp)) out = buf;
pclose(fp);
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
s["active"] = out.empty() ? "unknown" : out;
return s;
}
json status_json()
{
json st;
st["time_ms"] = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.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"),
service_active("vehicle-video-service.service")});
st["channels"] = json::array();
auto channels = RTMPManager::get_all_channels_status();
for (size_t i = 0; i < g_app_config.cameras.size(); ++i)
{
const auto& cam = g_app_config.cameras[i];
json c;
c["loc"] = static_cast<int>(i);
c["name"] = cam.name;
c["device"] = cam.device;
c["enabled"] = cam.enabled;
c["device_exists"] = access(cam.device.c_str(), F_OK) == 0;
if (i < channels.size())
{
c["running"] = channels[i].running;
c["url"] = channels[i].url;
c["reason"] = channels[i].reason;
}
st["channels"].push_back(c);
}
return st;
}
bool write_srs_configs(const json& cfg, std::string& message)
{
if (!cfg.contains("srs")) return true;
std::string root = cfg["srs"].value("root", "/opt/vehicle-video-service/srs");
fs::path conf_dir = fs::path(root) / "conf";
std::error_code ec;
fs::create_directories(conf_dir, ec);
if (ec)
{
message = "config saved, but failed to create SRS conf dir: " + ec.message();
return false;
}
bool ok1 = write_file_atomic((conf_dir / "live.conf").string(), make_live_conf(cfg));
bool ok2 = write_file_atomic((conf_dir / "record.conf").string(), make_record_conf(cfg));
if (!ok1 || !ok2)
{
message = "config saved, but failed to write SRS conf files";
return false;
}
return true;
}
const char* index_html()
{
return R"HTML(
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vehicle Video Service</title>
<style>
:root { color-scheme: light; font-family: Arial, "Microsoft YaHei", sans-serif; background:#f4f6f8; color:#17202a; }
body { margin:0; }
header { background:#12343b; color:white; padding:18px 24px; }
h1 { margin:0; font-size:22px; font-weight:700; }
main { max-width:1180px; margin:0 auto; padding:20px; }
section { background:white; border:1px solid #d9e0e6; border-radius:8px; margin-bottom:16px; padding:16px; }
h2 { margin:0 0 14px; font-size:17px; }
.grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
.grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }
label { display:flex; flex-direction:column; gap:6px; font-size:13px; color:#52606d; }
input, select { box-sizing:border-box; width:100%; padding:9px 10px; border:1px solid #c8d1da; border-radius:6px; font-size:14px; }
input[type=checkbox] { width:auto; }
table { width:100%; border-collapse:collapse; font-size:14px; }
th, td { border-bottom:1px solid #e6ebef; padding:8px; text-align:left; vertical-align:middle; }
th { color:#52606d; font-weight:600; }
button { border:0; border-radius:6px; padding:10px 14px; background:#0f766e; color:white; font-weight:700; cursor:pointer; }
button.secondary { background:#405261; }
.actions { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.pill { display:inline-block; padding:3px 7px; border-radius:999px; background:#e8f2ff; color:#174ea6; font-size:12px; }
.warn { color:#9a3412; }
.ok { color:#166534; }
.muted { color:#667785; }
.help { margin-top:4px; font-size:12px; line-height:1.35; color:#667785; }
.status-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin-bottom:12px; }
.status-box { border:1px solid #e1e7ed; border-radius:6px; padding:10px; background:#fbfcfd; }
.status-box strong { display:block; margin-bottom:4px; color:#243746; }
.url-cell { max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
#toast { min-height:24px; margin-left:6px; }
@media (max-width: 860px) { .grid, .grid.two, .status-grid { grid-template-columns:1fr; } table { display:block; overflow-x:auto; } }
</style>
</head>
<body>
<header><h1>Vehicle Video Service</h1></header>
<main>
<section>
<div class="actions">
<button onclick="saveConfig()"></button>
<button class="secondary" onclick="loadAll()">刷新</button>
<span id="toast" class="muted"></span>
</div>
</section>
<section>
<h2></h2>
<div id="status" class="muted">加载中...</div>
</section>
<section>
<h2></h2>
<div class="grid">
<label><input id="web_enabled" type="checkbox"></label>
<label><input id="web_bind"></label>
<label><input id="web_port" type="number"></label>
<label><input id="web_username"></label>
<label><input id="web_password" type="password"></label>
</div>
</section>
<section>
<h2>MQTT</h2>
<div class="grid">
<label>ID<input id="veh_id" type="number"></label>
<label><input id="mqtt_addr"></label>
<label><input id="mqtt_port" type="number"></label>
<label>(ms)<input id="mqtt_keepalive" type="number"></label>
<label>Client ID<input id="mqtt_client"></label>
<label><input id="mqtt_user"></label>
<label><input id="mqtt_pass" type="password"></label>
<label>使<input id="mqtt_need_pwd" type="checkbox"></label>
</div>
</section>
<section>
<h2>SRS </h2>
<div class="grid">
<label>SRS根目录<input id="srs_root"></label>
<label><input id="record_config"></label>
<label><input id="public_interface"></label>
<label><input id="stream_app"></label>
<label><input id="live_enabled" type="checkbox"></label>
<label><input id="record_enabled" type="checkbox"></label>
<label><input id="record_path"></label>
<label><input id="dvr_duration" type="number"></label>
<label><input id="retention_days" type="number"></label>
<label>(0-1)<input id="usage_threshold" type="number" step="0.01"></label>
</div>
</section>
<section>
<h2></h2>
<div class="grid">
<label>Live RTMP<input id="live_rtmp_port" type="number"></label>
<label>Live HTTP API<input id="live_http_api_port" type="number"></label>
<label>Live HTTP Server<input id="live_http_server_port" type="number"></label>
<label>Live RTC UDP<input id="live_rtc_port" type="number"></label>
<label>Record RTMP<input id="record_rtmp_port" type="number"></label>
<label>Record HTTP API<input id="record_http_api_port" type="number"></label>
<label>Record HTTP Server<input id="record_http_server_port" type="number"></label>
</div>
</section>
<section>
<h2></h2>
<table>
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th>FPS</th></tr></thead>
<tbody id="camera_rows"></tbody>
</table>
</section>
</main>
<script>
let config = {};
const $ = id => document.getElementById(id);
const num = id => Number($(id).value);
async function api(path, options) {
const res = await fetch(path, options);
if (!res.ok) throw new Error(await res.text());
return await res.json();
}
function setToast(text, cls='muted') { const t=$('toast'); t.className=cls; t.textContent=text; }
function helpFor(id, text) {
const el = $(id);
if (!el || !el.parentElement || el.parentElement.querySelector('.help')) return;
const div = document.createElement('div');
div.className = 'help';
div.textContent = text;
el.parentElement.appendChild(div);
}
function installHelp() {
helpFor('srs_root', 'SRS安装根目录 srs/binconfhtml ');
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_enabled', ' live SRS ');
helpFor('record_enabled', ' record SRS SRS写录像');
helpFor('record_path', 'SRS DVR保存mp4切片的根目录');
helpFor('dvr_duration', '');
helpFor('retention_days', 'video服务扫描清理');
helpFor('usage_threshold', '使0.990%');
helpFor('live_rtmp_port', 'video服务把实时流推到这个RTMP端口');
helpFor('live_http_api_port', 'SRS WHEP/WebRTC播放接口端口MQTT返回的实时播放URL使用它');
helpFor('live_http_server_port', 'SRS自带静态页面和HTTP-FLV调试入口');
helpFor('live_rtc_port', 'WebRTC UDP媒体端口');
helpFor('record_rtmp_port', 'video服务把录像流推到这个RTMP端口');
helpFor('record_http_api_port', 'record实例的SRS HTTP API端口');
helpFor('record_http_server_port', 'HTTP访问端口URL会走这个HTTP server');
}
function fillConfig() {
const m = config.mqtt_server || {};
const s = config.srs || {};
const w = config.web || {};
$('veh_id').value = m.veh_id ?? 0;
$('mqtt_addr').value = m.address ?? '';
$('mqtt_port').value = m.port ?? 1883;
$('mqtt_keepalive').value = m.mqtt_heart_threshold ?? 1000;
$('mqtt_client').value = m.client_id ?? '';
$('mqtt_user').value = m.username ?? '';
$('mqtt_pass').value = m.password ?? '';
$('mqtt_need_pwd').checked = !!m.need_username_pwd;
$('web_enabled').checked = w.enabled ?? true;
$('web_bind').value = w.bind ?? '0.0.0.0';
$('web_port').value = w.port ?? 18080;
$('web_username').value = w.username ?? 'admin';
$('web_password').value = w.password ?? '';
$('srs_root').value = s.root ?? '/opt/vehicle-video-service/srs';
$('record_config').value = s.record_config ?? '';
$('public_interface').value = s.public_interface ?? 'enP2p33s0';
$('stream_app').value = s.stream_app ?? 'camera';
$('live_enabled').checked = s.live_enabled ?? true;
$('record_enabled').checked = s.record_enabled ?? true;
$('record_path').value = s.record_path ?? '/media/record';
$('dvr_duration').value = s.dvr_duration_sec ?? 60;
$('retention_days').value = s.retention_days ?? 14;
$('usage_threshold').value = s.usage_threshold ?? 0.9;
['live_rtmp_port','live_http_api_port','live_http_server_port','live_rtc_port','record_rtmp_port','record_http_api_port','record_http_server_port'].forEach(id => $(id).value = s[id] ?? '');
renderCameras();
}
function renderCameras() {
const tbody = $('camera_rows');
tbody.innerHTML = '';
(config.cameras || []).forEach((c, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input type="checkbox" data-i="${i}" data-k="enabled" ${c.enabled ? 'checked' : ''}></td>
<td><input data-i="${i}" data-k="name" value="${c.name ?? ''}"></td>
<td><input data-i="${i}" data-k="device" value="${c.device ?? ''}"></td>
<td><input type="number" data-i="${i}" data-k="bitrate" value="${c.bitrate ?? 2000000}"></td>
<td><input type="number" data-i="${i}" data-k="width" value="${c.width ?? 1280}"></td>
<td><input type="number" data-i="${i}" data-k="height" value="${c.height ?? 960}"></td>
<td><input type="number" data-i="${i}" data-k="fps" value="${c.fps ?? 30}"></td>`;
tbody.appendChild(tr);
});
}
function collectConfig() {
config.mqtt_server = config.mqtt_server || {};
Object.assign(config.mqtt_server, {
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,
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 || {};
Object.assign(config.srs, {
root:$('srs_root').value, record_config:$('record_config').value, public_interface:$('public_interface').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),
live_rtmp_port:num('live_rtmp_port'), live_http_api_port:num('live_http_api_port'), live_http_server_port:num('live_http_server_port'),
live_rtc_port:num('live_rtc_port'), record_rtmp_port:num('record_rtmp_port'), record_http_api_port:num('record_http_api_port'),
record_http_server_port:num('record_http_server_port')
});
document.querySelectorAll('#camera_rows input').forEach(input => {
const i = Number(input.dataset.i), k = input.dataset.k;
if (!config.cameras[i]) return;
config.cameras[i][k] = input.type === 'checkbox' ? input.checked : (input.type === 'number' ? Number(input.value) : input.value);
});
}
async function loadConfig() { config = await api('/api/config'); fillConfig(); }
async function loadStatus() {
const st = await api('/api/status');
const disk = st.disk || {};
$('status').innerHTML = renderStatus(st, disk);
return;
$('status').innerHTML = `
<div>IP<span class="pill">${st.public_ip || 'unknown'}</span></div>
<div>${disk.ok ? `<span class="ok">${disk.used_percent.toFixed(1)}% used</span>` : `<span class="warn">${disk.error || 'unavailable'}</span>`}</div>
<div>${(st.services||[]).map(s => `${s.name}: <span class="${s.active==='active'?'ok':'warn'}">${s.active}</span>`).join(' | ')}</div>
<div>${(st.channels||[]).map(c => `${c.name} ${c.device_exists?'':'()'} ${c.running?'':''}`).join('')}</div>`;
}
function escapeHtml(v) {
return String(v ?? '').replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
function renderStatus(st, disk) {
const services = (st.services || []).map(s => `${escapeHtml(s.name)}: <span class="${s.active==='active'?'ok':'warn'}">${escapeHtml(s.active)}</span>`).join('<br>');
const diskText = disk.ok ? `<span class="ok">${Number(disk.used_percent || 0).toFixed(1)}% used</span><div class="muted">${escapeHtml(disk.path)}</div>` : `<span class="warn">${escapeHtml(disk.error || 'unavailable')}</span>`;
const channelRows = (st.channels || []).map(c => {
const running = c.running ? '<span class="ok">运行</span>' : '<span class="warn">停止</span>';
const enabled = c.enabled ? '<span class="ok">启用</span>' : '<span class="muted">禁用</span>';
const exists = c.device_exists ? '<span class="ok">存在</span>' : '<span class="warn">不存在</span>';
const reason = c.reason || (c.enabled ? '' : '');
const url = c.url ? `<span title="${escapeHtml(c.url)}">${escapeHtml(c.url)}</span>` : '<span class="muted">无</span>';
return `<tr><td>${escapeHtml(c.name)}</td><td>${escapeHtml(c.device)}</td><td>${enabled}</td><td>${exists}</td><td>${running}</td><td>${escapeHtml(reason)}</td><td class="url-cell">${url}</td></tr>`;
}).join('');
return `
<div class="status-grid">
<div class="status-box"><strong>对外IP</strong><span class="pill">${escapeHtml(st.public_ip || 'unknown')}</span></div>
<div class="status-box"><strong>录像磁盘</strong>${diskText}</div>
<div class="status-box"><strong>服务</strong>${services || '<span class="muted">无</span>'}</div>
</div>
<table class="status-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>${channelRows || '<tr><td colspan="7" class="muted">无通道</td></tr>'}</tbody>
</table>`;
}
async function saveConfig() {
try {
collectConfig();
const res = await api('/api/config', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(config, null, 2) });
setToast(res.message || '', res.ok ? 'ok' : 'warn');
} catch (e) { setToast(e.message, 'warn'); }
}
async function loadAll() { try { await loadConfig(); installHelp(); await loadStatus(); setToast(''); } catch(e) { setToast(e.message, 'warn'); } }
loadAll();
</script>
</body>
</html>
)HTML";
}
} // namespace
ConfigServer::~ConfigServer() { stop(); }
bool ConfigServer::start(const std::string& config_path)
{
if (!g_app_config.web.enabled) return true;
config_path_ = config_path;
running_.store(true);
thread_ = std::thread([this]() { run(); });
return true;
}
void ConfigServer::stop()
{
running_.store(false);
if (server_fd_ >= 0)
{
::shutdown(server_fd_, SHUT_RDWR);
::close(server_fd_);
server_fd_ = -1;
}
if (thread_.joinable()) thread_.join();
}
void ConfigServer::run()
{
server_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
if (server_fd_ < 0)
{
LOG_ERROR("[ConfigServer] socket failed");
return;
}
int opt = 1;
setsockopt(server_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(g_app_config.web.port);
if (inet_pton(AF_INET, g_app_config.web.bind.c_str(), &addr.sin_addr) != 1)
{
addr.sin_addr.s_addr = INADDR_ANY;
}
if (bind(server_fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0)
{
LOG_ERROR(std::string("[ConfigServer] bind failed: ") + strerror(errno));
return;
}
if (listen(server_fd_, 16) != 0)
{
LOG_ERROR(std::string("[ConfigServer] listen failed: ") + strerror(errno));
return;
}
LOG_INFO("[ConfigServer] listening on " + g_app_config.web.bind + ":" + std::to_string(g_app_config.web.port));
while (running_.load())
{
int client = accept(server_fd_, nullptr, nullptr);
if (client < 0)
{
if (running_.load()) LOG_WARN(std::string("[ConfigServer] accept failed: ") + strerror(errno));
continue;
}
handle_client(client);
close(client);
}
}
void ConfigServer::handle_client(int client_fd)
{
std::string req;
char buf[4096];
while (req.find("\r\n\r\n") == std::string::npos)
{
ssize_t n = recv(client_fd, buf, sizeof(buf), 0);
if (n <= 0) return;
req.append(buf, n);
if (req.size() > 1024 * 1024) return;
}
auto header_end = req.find("\r\n\r\n");
std::string headers = req.substr(0, header_end + 4);
std::string body = req.substr(header_end + 4);
std::istringstream first_line(headers.substr(0, headers.find("\r\n")));
std::string method, target, version;
first_line >> method >> target >> version;
std::string path = target.substr(0, target.find('?'));
path = url_decode(path);
size_t content_length = 0;
auto cl = headers.find("Content-Length:");
if (cl != std::string::npos)
{
cl += strlen("Content-Length:");
auto end = headers.find("\r\n", cl);
content_length = std::stoul(headers.substr(cl, end - cl));
}
while (body.size() < content_length)
{
ssize_t n = recv(client_fd, buf, sizeof(buf), 0);
if (n <= 0) break;
body.append(buf, n);
}
if (!authorized(headers))
{
send_response(client_fd, 401, "text/plain; charset=utf-8", "Unauthorized",
{{"WWW-Authenticate", "Basic realm=\"Vehicle Video Service\""}});
return;
}
try
{
if (method == "GET" && (path == "/" || path == "/index.html"))
{
send_response(client_fd, 200, "text/html; charset=utf-8", index_html());
}
else if (method == "GET" && path == "/api/config")
{
std::string content = read_file(config_path_);
if (content.empty()) send_response(client_fd, 500, "application/json", R"({"ok":false})");
else send_response(client_fd, 200, "application/json; charset=utf-8", content);
}
else if (method == "POST" && path == "/api/config")
{
json cfg = json::parse(body);
std::string dumped = cfg.dump(2);
if (!write_file_atomic(config_path_, dumped + "\n"))
{
send_response(client_fd, 500, "application/json", R"({"ok":false,"message":"failed to write config"})");
return;
}
std::string msg = "saved. restart services to apply runtime changes";
bool srs_ok = write_srs_configs(cfg, msg);
send_response(client_fd, 200, "application/json; charset=utf-8",
json({{"ok", srs_ok}, {"message", msg}, {"requires_restart", true}}).dump(2));
}
else if (method == "GET" && path == "/api/status")
{
send_response(client_fd, 200, "application/json; charset=utf-8", status_json().dump(2));
}
else
{
send_response(client_fd, 404, "text/plain; charset=utf-8", "Not found");
}
}
catch (const std::exception& e)
{
send_response(client_fd, 400, "application/json; charset=utf-8",
json({{"ok", false}, {"message", e.what()}}).dump(2));
}
}

View File

@ -6,6 +6,7 @@
#include <thread> #include <thread>
#include "app_config.hpp" #include "app_config.hpp"
#include "config_server.hpp"
#include "logger.hpp" #include "logger.hpp"
#include "mqtt_client_wrapper.hpp" #include "mqtt_client_wrapper.hpp"
#include "record_manager.hpp" #include "record_manager.hpp"
@ -45,8 +46,13 @@ int main()
return -1; return -1;
} }
const std::string config_path = get_executable_dir_file_path("config.json");
g_record_manager = std::make_shared<RecordManager>(g_app_config.srs.record_config); g_record_manager = std::make_shared<RecordManager>(g_app_config.srs.record_config);
ConfigServer config_server;
config_server.start(config_path);
RTMPManager::init(); RTMPManager::init();
LOG_INFO("[MAIN] Starting all record streams..."); LOG_INFO("[MAIN] Starting all record streams...");
@ -73,6 +79,8 @@ int main()
if (g_record_manager) g_record_manager->stopAutoScan(); if (g_record_manager) g_record_manager->stopAutoScan();
config_server.stop();
RTMPManager::stop_all(); RTMPManager::stop_all();
if (mqtt_thread.joinable()) if (mqtt_thread.joinable())

View File

@ -115,24 +115,25 @@ GstElement* RTMPManager::create_pipeline(const Camera& cam)
" ! mpph264enc bps=" + " ! mpph264enc bps=" +
std::to_string(bitrate) + " gop=" + std::to_string(fps) + std::to_string(bitrate) + " gop=" + std::to_string(fps) +
" rc-mode=cbr " " rc-mode=cbr "
" ! h264parse ! tee name=t " " ! h264parse ! tee name=t ";
// ------ 分支1live ------ if (srs.live_enabled)
"t. ! queue max-size-buffers=5 leaky=downstream " {
" ! flvmux streamable=true name=mux_live " pipeline_str += "t. ! queue max-size-buffers=5 leaky=downstream "
" ! rtmpsink location=\"" + " ! flvmux streamable=true name=mux_live "
live_rtmp + " ! rtmpsink location=\"" +
"\" sync=false async=false " live_rtmp + "\" sync=false async=false ";
}
// ------ 分支2record ------ if (srs.record_enabled)
"t. ! queue max-size-buffers=5 leaky=downstream " {
" ! flvmux streamable=true name=mux_record " pipeline_str += "t. ! queue max-size-buffers=5 leaky=downstream "
" ! rtmpsink location=\"" + " ! flvmux streamable=true name=mux_record "
record_rtmp + " ! rtmpsink location=\"" +
"\" sync=false async=false " record_rtmp + "\" sync=false async=false ";
}
// ------ 分支3AI ------ pipeline_str += "t. ! queue ! fakesink sync=false";
"t. ! queue ! fakesink sync=false";
GError* error = nullptr; GError* error = nullptr;
GstElement* pipeline = gst_parse_launch(pipeline_str.c_str(), &error); GstElement* pipeline = gst_parse_launch(pipeline_str.c_str(), &error);