Compare commits
No commits in common. "be68ee5180ba2c6896b6a21588d7fde691387d46" and "929728f032e5a43651e15e5593a454246aabe03b" have entirely different histories.
be68ee5180
...
929728f032
16
README.md
16
README.md
@ -76,19 +76,3 @@ 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.
|
|
||||||
|
|||||||
16
config.json
16
config.json
@ -13,32 +13,16 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -1,236 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -70,41 +70,22 @@ 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)
|
||||||
{
|
{
|
||||||
@ -161,36 +142,17 @@ 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);
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
#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;
|
|
||||||
};
|
|
||||||
@ -5,6 +5,7 @@ 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"
|
||||||
@ -62,11 +63,7 @@ if [[ ! -x "$APP_BIN" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$DIST_DIR"
|
rm -rf "$STAGE_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"
|
||||||
|
|
||||||
@ -81,6 +78,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 "$STAGE_PARENT" -czf "$DIST_DIR/$PACKAGE_NAME.tar.gz" "$PACKAGE_NAME"
|
tar -C "$DIST_DIR" -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"
|
||||||
|
|||||||
@ -1,739 +0,0 @@
|
|||||||
#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/bin、conf、html 放在这里。');
|
|
||||||
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_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.9表示90%,超过后按最老小时清理。');
|
|
||||||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,7 +6,6 @@
|
|||||||
#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"
|
||||||
@ -46,13 +45,8 @@ 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...");
|
||||||
@ -79,8 +73,6 @@ 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())
|
||||||
|
|||||||
@ -115,25 +115,24 @@ 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 "
|
||||||
|
|
||||||
if (srs.live_enabled)
|
// ------ 分支1:live ------
|
||||||
{
|
"t. ! queue max-size-buffers=5 leaky=downstream "
|
||||||
pipeline_str += "t. ! queue max-size-buffers=5 leaky=downstream "
|
|
||||||
" ! flvmux streamable=true name=mux_live "
|
" ! flvmux streamable=true name=mux_live "
|
||||||
" ! rtmpsink location=\"" +
|
" ! rtmpsink location=\"" +
|
||||||
live_rtmp + "\" sync=false async=false ";
|
live_rtmp +
|
||||||
}
|
"\" sync=false async=false "
|
||||||
|
|
||||||
if (srs.record_enabled)
|
// ------ 分支2:record ------
|
||||||
{
|
"t. ! queue max-size-buffers=5 leaky=downstream "
|
||||||
pipeline_str += "t. ! queue max-size-buffers=5 leaky=downstream "
|
|
||||||
" ! flvmux streamable=true name=mux_record "
|
" ! flvmux streamable=true name=mux_record "
|
||||||
" ! rtmpsink location=\"" +
|
" ! rtmpsink location=\"" +
|
||||||
record_rtmp + "\" sync=false async=false ";
|
record_rtmp +
|
||||||
}
|
"\" sync=false async=false "
|
||||||
|
|
||||||
pipeline_str += "t. ! queue ! fakesink sync=false";
|
// ------ 分支3:AI ------
|
||||||
|
"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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user