LSS (Local Sender Service) 是一个部署在本地网络的边缘流媒体代理服务,负责通过 FFmpeg 将 IP 摄像头的 RTSP 流推送到 Cloudflare WHIP 端点,同时提供摄像头 PTZ 云台控制能力。
┌─────────────────────────────────────────┐
│ RCS (Remote Camera Service) - 云端 │
│ - 中央管理服务 │
│ - 通过 Ably 下发指令 │
│ - 通过 HTTP 接收心跳/回调 │
└──────────────┬──────────────────────────┘
│ Ably Realtime (WebSocket)
│ Channel: lss:{nodeId}:commands
▼
┌─────────────────────────────────────────┐
│ LSS (Local Sender Service) - 本地边缘 │
│ - FFmpeg 进程管理 (RTSP → WHIP) │
│ - 摄像头 PTZ 控制 (ONVIF / ISAPI) │
│ - 摄像头发现与探测 │
│ - 巡航路线执行 │
└──────────────┬──────────────────────────┘
│ FFmpeg -f whip
▼
┌─────────────────────────────────────────┐
│ Cloudflare Stream (WebRTC) │
└─────────────────────────────────────────┘
采用领域驱动设计 (DDD) 分层:
| 层 | 职责 |
|---|---|
| adapter | REST API 控制器,对外暴露 HTTP 接口 |
| application | 业务编排服务,协调领域对象与基础设施 |
| domain | 核心领域模型、枚举、Gateway 接口定义 |
| infrastructure | 外部集成实现:FFmpeg、Ably、ONVIF、ISAPI、RCS HTTP 客户端 |
type StreamTask struct {
StreamSn string // 任务唯一标识(由 RCS 分配)
CameraID string // 摄像头标识
InputType InputType // 输入类型:RTSP / FILE / SRT / RTMP
SourceURL string // 输入源地址,如 rtsp://admin:pass@192.168.0.64/...
Loop bool // FILE 模式下是否循环播放
WhipURL string // Cloudflare WHIP 推流地址
PlaybackURL string // WHEP 播放地址
Status StreamStatus // 任务状态
ProcessID int64 // FFmpeg 进程 PID
ErrorMessage string // 错误信息
RetryCount int // 已重试次数,默认 0
MaxRetries int // 最大重试次数,默认 3
CreatedAt time.Time
StartedAt time.Time
StoppedAt time.Time
LastHeartbeat time.Time
VideoConfig VideoConfig // 视频编码配置
AudioConfig AudioConfig // 音频编码配置
FFmpegCommand string // RCS 预构建的完整 FFmpeg 命令(优先使用)
}
type VideoConfig struct {
Codec string // 默认 "libx264"
Bitrate string // 默认 "2500k"
FPS int // 默认 30
Width int // 可选,不设则不缩放
Height int // 可选
Preset string // 默认 "veryfast"
KeyframeInterval int // 默认 30 (等于 FPS)
}
type AudioConfig struct {
Codec string // 默认 "opus"
Bitrate string // 默认 "128k"
SampleRate int // 默认 48000
Channels int // 默认 2
}
type InputType string
const (
InputRTSP InputType = "RTSP"
InputFILE InputType = "FILE"
InputSRT InputType = "SRT"
InputRTMP InputType = "RTMP"
)
type StreamStatus string
const (
StatusIdle StreamStatus = "IDLE" // 空闲
StatusStarting StreamStatus = "STARTING" // 启动中
StatusStreaming StreamStatus = "STREAMING" // 推流中
StatusReconnecting StreamStatus = "RECONNECTING" // 重连中
StatusStopped StreamStatus = "STOPPED" // 已停止
StatusError StreamStatus = "ERROR" // 错误
)
辅助方法:
IsActive() — STARTING / STREAMING / RECONNECTING 返回 trueIsTerminal() — STOPPED / ERROR 返回 truetype LssNode struct {
NodeID string // 格式: "lss-{machineId}"
NodeName string // 人类可读名称
MachineID string // 物理机器标识
AblyClientID string // 与 NodeID 相同
NodeIP string // 节点 IP
NodePort int // 服务端口,默认 10060
Status NodeStatus // ONLINE / OFFLINE / BUSY / MAINTENANCE
ActiveTaskCount int // 当前活跃推流任务数
MaxTasks int // 最大并发任务数,默认 4
RegisteredAt time.Time
LastHeartbeat time.Time
FFmpegPath string // 默认 "/usr/local/ffmpeg-whip/bin/ffmpeg"
SystemInfo SystemInfo
Cameras []Camera // 已发现的摄像头列表
}
type SystemInfo struct {
OS string
CPUCores int
TotalMemory int64 // 字节
AvailableMemory int64
FFmpegVersion string
}
type NodeStatus string
const (
NodeOnline NodeStatus = "ONLINE"
NodeOffline NodeStatus = "OFFLINE"
NodeBusy NodeStatus = "BUSY"
NodeMaintenance NodeStatus = "MAINTENANCE"
)
type Camera struct {
IP string // 摄像头 IP
Port int // 端口
ONVIF bool // 是否支持 ONVIF
RTSPURL string // RTSP 流地址
Vendor string // "HIKVISION" / "DAHUA" / "UNKNOWN"
Model string // 型号
}
Go 实现需要实现以下接口:
type FFmpegGateway interface {
StartProcess(task *StreamTask) (pid int64, err error)
StopProcess(processID int64) error
IsAlive(processID int64) bool
KillProcess(processID int64)
GetProcessLog(processID int64) string
}
type RcsGateway interface {
Register(node *LssNode) error
HeartbeatWithCameras(node *LssNode) error
TaskStartedCallback(taskID string, processID int64, hlsPlaybackURL string) error
TaskStoppedCallback(taskID string) error
TaskErrorCallback(taskID string, errorMessage string) error
}
type CameraScannerGateway interface {
ScanCameras(subnet string) ([]Camera, error)
ScanLocalNetwork() ([]Camera, error)
}
type CameraDeviceAdapter interface {
ProbeDeviceInfo(cmd *ProbeCameraCommand) (*CameraDeviceInfo, error)
ExecutePTZControl(cmd *PTZControlCommand, ip string, port int, username, password string) error
}
LSS 通过 Ably WebSocket 接收 RCS 下发的指令,并通过同一通道返回执行结果。
| 通道名 | 方向 | 用途 |
|---|---|---|
lss:registry |
LSS → RCS | Presence 注册/在线状态 |
lss:{nodeId}:commands |
RCS → LSS | 接收指令 |
lss:{nodeId}:status |
LSS → RCS | 发布状态和指令执行结果 |
task:{taskId}:events |
LSS → RCS | 任务级别事件 |
所有 Ably 消息使用 JSON 格式,通过 Message.name 区分事件类型:
事件类型 command(推流指令):
{
"command": "START_STREAM",
"payload": {
"taskId": "task_001",
"cameraId": "cam_001",
"sourceUrl": "rtsp://admin:pass@192.168.0.64/Streaming/Channels/101",
"whipUrl": "https://customer-xxx.cloudflarestream.com/xxx/whip",
"playbackUrl": "https://customer-xxx.cloudflarestream.com/xxx/whep",
"inputType": "RTSP",
"loop": false,
"ffmpegCommand": "完整 FFmpeg 命令字符串(可选,优先使用)",
"videoConfig": {
"codec": "libx264",
"bitrate": "2500k",
"fps": 30,
"preset": "veryfast"
},
"audioConfig": {
"codec": "opus",
"bitrate": "128k",
"sampleRate": 48000,
"channels": 2
}
}
}
其他推流指令:
STOP_STREAM — payload: { "taskId": "task_001", "force": false }QUERY_STATUS — payload: { "taskId": "task_001" }事件类型 ptz_command(PTZ 云台指令):
{
"command": "PTZ_CONTROL",
"payload": {
"cameraId": "cam_001",
"action": "PAN_LEFT",
"speed": 50
}
}
PTZ 指令列表:
PTZ_CONTROL — payload: { cameraId, action, speed }PTZ_STOP — payload: { cameraId }PTZ_PRESET_GOTO — payload: { cameraId, presetId }PTZ_PRESET_SET — payload: { cameraId, presetId, presetName }PTZ_CONTINUOUS_MOVE — payload: { cameraId, action, speed, duration }事件类型 camera_command(摄像头指令):
{
"command": "CAMERA_CHECK",
"payload": {
"cameraId": "cam_001",
"ip": "192.168.0.64",
"port": 80,
"username": "admin",
"password": "xxx"
}
}
摄像头指令列表:
CAMERA_CHECK — 连通性测试CAMERA_SNAPSHOT — 抓取截图,返回 Base64 编码的 JPEGCAMERA_PRESET_LIST — 获取预置位列表CAMERA_DEVICE_INFO — 获取设备信息(厂商、型号、序列号)事件类型 onvif_command(ONVIF 指令):
ONVIF_PROBE — 探测 ONVIF 设备能力事件类型 patrol_command(巡航指令):
{
"command": "PATROL_START",
"payload": {
"cameraId": "cam_001",
"tourId": "tour_001",
"ip": "192.168.0.64",
"port": 80,
"username": "admin",
"password": "xxx",
"vendorType": "HIKVISION",
"loopCount": 0,
"waypoints": [
{
"action": "LEFT",
"speed": 50,
"duration": 3000,
"startTime": 0
},
{
"action": "STOP",
"speed": 0,
"duration": 2000,
"startTime": 3000
},
{
"action": "RIGHT",
"speed": 50,
"duration": 3000,
"startTime": 5000
}
]
}
}
巡航指令列表:
PATROL_START — 启动巡航PATROL_STOP — 停止巡航PATROL_PAUSE — 暂停巡航PATROL_RESUME — 恢复巡航PATROL_STATUS — 查询巡航状态LSS 执行完指令后,通过 lss:{nodeId}:status 通道发布结果:
{
"type": "COMMAND_RESULT",
"command": "START_STREAM",
"taskId": "task_001",
"success": true,
"data": { ... },
"errorMessage": null,
"timestamp": "2026-01-30T10:00:00Z"
}
LSS 每 10 秒通过 Ably 发送心跳消息到 lss:{nodeId}:status:
{
"type": "HEARTBEAT",
"nodeId": "lss-machine_007",
"status": "ONLINE",
"activeTaskCount": 2,
"maxTasks": 4,
"activeTasks": {
"task_001": 1706601600000,
"task_002": 1706601650000
},
"timestamp": "2026-01-30T10:00:00Z"
}
LSS 启动后加入 lss:registry 通道的 Presence,数据包含节点基本信息。RCS 通过 Presence 感知 LSS 上线/下线。
基础路径:http://{nodeIP}:10060/api
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /stream/start |
启动推流任务 |
| POST | /stream/stop |
停止推流任务 |
| GET | /stream/task/{taskId} |
查询单个任务状态 |
| GET | /stream/tasks/active |
查询所有活跃任务 |
| GET | /stream/tasks |
查询所有任务 |
| GET | /stream/node |
获取节点信息 |
| GET | /stream/health |
健康检查 |
POST /stream/start 请求体:
{
"taskId": "task_001",
"cameraId": "cam_001",
"sourceUrl": "rtsp://admin:pass@192.168.0.64/Streaming/Channels/101",
"whipUrl": "https://customer-xxx.cloudflarestream.com/xxx/whip",
"playbackUrl": "https://...",
"inputType": "RTSP",
"loop": false,
"ffmpegCommand": "...",
"videoConfig": { "codec": "libx264", "bitrate": "2500k", "fps": 30, "preset": "veryfast" },
"audioConfig": { "codec": "opus", "bitrate": "128k", "sampleRate": 48000, "channels": 2 }
}
POST /stream/stop 请求体:
{
"taskId": "task_001",
"force": false
}
统一响应格式:
{
"code": 0,
"message": "success",
"data": { ... }
}
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | {RCS_BASE_URL}/lss/register |
节点注册 |
| POST | {RCS_BASE_URL}/lss/heartbeat |
HTTP 心跳(5 秒间隔) |
| POST | {RCS_BASE_URL}/lss/task/callback |
任务状态回调 |
POST /lss/register 请求体:
{
"nodeId": "lss-machine_007",
"nodeName": "LSS-Node-1",
"nodeIp": "192.168.0.100",
"nodePort": 10060,
"machineId": "machine_007",
"ablyClientId": "lss-machine_007",
"maxTasks": 4,
"ffmpegPath": "/usr/local/ffmpeg-whip/bin/ffmpeg",
"systemInfo": {
"os": "Linux 5.15.0",
"cpuCores": 8,
"totalMemory": 16000000000,
"availableMemory": 12000000000,
"ffmpegVersion": "6.0-whip"
},
"cameras": [
{
"ip": "192.168.0.64",
"port": 554,
"onvif": true,
"rtspUrl": "rtsp://192.168.0.64:554/Streaming/Channels/101",
"vendor": "HIKVISION",
"model": "DS-2CD2T45FWD"
}
]
}
POST /lss/heartbeat 请求体:
{
"nodeId": "lss-machine_007",
"activeTaskCount": 2,
"cpuUsage": 45.5,
"memoryUsage": 62.3,
"cameras": [
{
"ip": "192.168.0.64",
"port": 554,
"online": true,
"rtspUrl": "rtsp://...",
"vendor": "HIKVISION",
"model": "DS-2CD2T45FWD"
}
]
}
POST /lss/task/callback 请求体:
{
"taskId": "task_001",
"nodeId": "lss-machine_007",
"callbackType": "STARTED",
"status": "STREAMING",
"processId": 12345,
"hlsPlaybackUrl": "...",
"errorMessage": null
}
callbackType 枚举值:STARTED / STOPPED / ERROR / STATUS_UPDATE
LSS 自行实现了轻量级 ONVIF SOAP 客户端,不依赖 WS 框架。
PasswordDigest = Base64(SHA-1(nonce + created + password))
SOAP Header 示例:
<s:Header>
<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<wsse:UsernameToken>
<wsse:Username>admin</wsse:Username>
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">
base64_digest_value
</wsse:Password>
<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">
base64_nonce_value
</wsse:Nonce>
<wsu:Created>2026-01-30T10:00:00.000Z</wsu:Created>
</wsse:UsernameToken>
</wsse:Security>
</s:Header>
设备服务 — http://{ip}:{port}/onvif/device_service
| 操作 | 用途 | 返回值 |
|---|---|---|
GetDeviceInformation |
获取设备信息 | manufacturer, model, firmwareVersion, serialNumber, hardwareId |
GetCapabilities |
获取设备能力 | media/PTZ/device 服务地址 |
媒体服务 — http://{ip}:{port}/onvif/media_service
| 操作 | 用途 | 返回值 |
|---|---|---|
GetProfiles |
获取媒体 Profile 列表 | Profile Token(PTZ 操作必需) |
PTZ 服务 — http://{ip}:{port}/onvif/ptz_service
| 操作 | 参数 | 用途 |
|---|---|---|
ContinuousMove |
profileToken, panSpeed(-1.0~1.0), tiltSpeed(-1.0~1.0), zoomSpeed(-1.0~1.0) | 持续移动 |
Stop |
profileToken | 停止移动 |
GotoPreset |
profileToken, presetToken | 转到预置位 |
SetPreset |
profileToken, presetName | 设置预置位 |
ContinuousMove SOAP Body 示例:
<s:Body>
<tptz:ContinuousMove xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl">
<tptz:ProfileToken>Profile_1</tptz:ProfileToken>
<tptz:Velocity>
<tt:PanTilt xmlns:tt="http://www.onvif.org/ver10/schema" x="-0.50" y="0.00"/>
<tt:Zoom xmlns:tt="http://www.onvif.org/ver10/schema" x="0.00"/>
</tptz:Velocity>
</tptz:ContinuousMove>
</s:Body>
ONVIF 每次 PTZ 操作都需要 Profile Token。首次调用时通过 GetProfiles 获取,之后按 ip:port 缓存复用。
需要先发送无认证请求获取 WWW-Authenticate challenge,然后用 Digest 算法计算 response。支持 nonce 复用和 nonce count 递增。
| 方法 | 路径 | 用途 |
|---|---|---|
| PUT | /ISAPI/PTZCtrl/channels/{ch}/continuous |
持续云台移动 |
| PUT | /ISAPI/PTZCtrl/channels/{ch}/momentary |
瞬时云台移动 |
| PUT | /ISAPI/PTZCtrl/channels/{ch}/presets/{id}/goto |
跳转到预置位 |
| PUT | /ISAPI/PTZCtrl/channels/{ch}/presets/{id} |
设置预置位 |
| DELETE | /ISAPI/PTZCtrl/channels/{ch}/presets/{id} |
删除预置位 |
| GET | /ISAPI/PTZCtrl/channels/{ch}/presets |
获取预置位列表 |
| PUT | /ISAPI/PTZCtrl/channels/{ch}/patrols/{id}/start |
启动巡航 |
| PUT | /ISAPI/PTZCtrl/channels/{ch}/patrols/{id}/stop |
停止巡航 |
| GET | /ISAPI/System/deviceInfo |
获取设备信息 |
| GET | /ISAPI/Streaming/channels/{ch}/picture |
抓取截图 (JPEG) |
{ch} 默认为 1(通道号)。
持续移动 XML 请求体:
<PTZData>
<pan>-50</pan> <!-- -100~100, 负值=左, 正值=右 -->
<tilt>0</tilt> <!-- -100~100, 负值=下, 正值=上 -->
<zoom>0</zoom> <!-- -100~100, 负值=缩小, 正值=放大 -->
</PTZData>
设置预置位 XML 请求体:
<PTZPreset>
<id>1</id>
<presetName>Entrance</presetName>
</PTZPreset>
如果 StreamTask.FFmpegCommand 非空,直接使用 RCS 预构建的命令。否则本地构建。
RTSP 模式命令模板:
{ffmpegPath} \
-rtsp_transport tcp \
-analyzeduration 1000000 \
-probesize 1000000 \
-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 3 \
-i {sourceUrl} \
-map 0:v:0 -map "0:a?" \
-c:v libx264 -preset veryfast -tune zerolatency \
-profile:v baseline -level 3.1 -pix_fmt yuv420p \
-g {keyframeInterval} -keyint_min {keyframeInterval} \
-b:v {videoBitrate} -maxrate {videoBitrate} -bufsize {2x videoBitrate} \
-c:a opus -strict -2 -ar {sampleRate} -ac {channels} -b:a {audioBitrate} \
-f whip {whipUrl}
FILE 模式额外参数:
-re -stream_loop -1 -i {sourceUrl}
SRT 模式额外参数:
-i "srt://{sourceUrl}"
os/exec 启动 FFmpeg 子进程,记录 PIDmap[int64]*ProcessInfo,线程安全(需用 sync.RWMutex)\ 续行),需清理后拆分为参数列表type ProcessInfo struct {
PID int64
Process *os.Process
StartTime time.Time
LogBuffer string // 最近 2000 字符的日志
}
func selectAdapter(vendorType string) string {
switch strings.ToUpper(vendorType) {
case "HIKVISION", "HIK":
return "ISAPI"
default:
return "ONVIF" // 其他厂商默认使用 ONVIF
}
}
type PTZAction string
const (
PanLeft PTZAction = "PAN_LEFT"
PanRight PTZAction = "PAN_RIGHT"
TiltUp PTZAction = "TILT_UP"
TiltDown PTZAction = "TILT_DOWN"
ZoomIn PTZAction = "ZOOM_IN"
ZoomOut PTZAction = "ZOOM_OUT"
FocusNear PTZAction = "FOCUS_NEAR"
FocusFar PTZAction = "FOCUS_FAR"
FocusAuto PTZAction = "FOCUS_AUTO"
IrisOpen PTZAction = "IRIS_OPEN"
IrisClose PTZAction = "IRIS_CLOSE"
Stop PTZAction = "STOP"
SetPreset PTZAction = "SET_PRESET"
GotoPreset PTZAction = "GOTO_PRESET"
DeletePreset PTZAction = "DELETE_PRESET"
StartPatrol PTZAction = "START_PATROL"
StopPatrol PTZAction = "STOP_PATROL"
)
| 参数 | 海康 ISAPI | ONVIF |
|---|---|---|
| 方向速度 | -100 ~ 100(整数) | -1.0 ~ 1.0(浮点数) |
| 转换公式 | 直接使用 | speed / 100.0 |
Action 到方向的映射:
| Action | pan | tilt | zoom |
|---|---|---|---|
| PAN_LEFT | -speed | 0 | 0 |
| PAN_RIGHT | +speed | 0 | 0 |
| TILT_UP | 0 | +speed | 0 |
| TILT_DOWN | 0 | -speed | 0 |
| ZOOM_IN | 0 | 0 | +speed |
| ZOOM_OUT | 0 | 0 | -speed |
| STOP | 0 | 0 | 0 |
巡航路线由一组 航点 (Waypoint) 组成,每个航点定义一个 PTZ 动作、速度、开始时间和持续时间。执行器按时序执行这些航点,支持循环、暂停/恢复。
type WaypointInfo struct {
Action string // LEFT, RIGHT, UP, DOWN, ZOOM_IN, ZOOM_OUT, STOP, ...
Speed int // 0-100
Duration int // 持续时间,毫秒
StartTime int // 相对于巡航开始的时间偏移,毫秒
}
type PatrolSession struct {
TourID string
CameraID string
State PatrolState // RUNNING / PAUSED / STOPPED
CurrentWaypoint int // 0-based 索引
CurrentLoop int // 0-based 循环计数
PauseLock sync.Mutex // 暂停/恢复同步
PauseCond *sync.Cond // 用于暂停等待
}
type PatrolState string
const (
PatrolRunning PatrolState = "RUNNING"
PatrolPaused PatrolState = "PAUSED"
PatrolStopped PatrolState = "STOPPED"
)
对每个循环 (loopCount, 0=无限循环):
对每个航点:
1. 等待 (waypoint.startTime - 上一个航点结束时间)
2. 发送 PTZ ContinuousMove(action, speed)
3. 等待 duration(每 500ms 检查中断标志)
4. 发送 PTZ Stop
5. 如果状态为 PAUSED,阻塞等待恢复
6. 如果状态为 STOPPED,退出
map[string]*PatrolSession(key = cameraId)管理192.168.0.0/24)探测流程:
GetDeviceInformation + GetCapabilities + GetProfilesGET /ISAPI/System/deviceInfotype CameraDeviceInfo struct {
CameraID string
IP string
Port int
Manufacturer string // "Hikvision", "Dahua", etc.
Model string
SerialNumber string
FirmwareVersion string
HardwareID string
MacAddress string
DeviceName string
ProbeMethod string // "ONVIF" / "ISAPI"
ProtocolSupport ProtocolSupport
Capabilities *CameraCapabilities
ProbeTime time.Time
}
type ProtocolSupport struct {
ONVIF bool
ISAPI bool
RTSP bool
ONVIFVersion string
}
type CameraCapabilities struct {
PTZ PTZCapabilities
Video VideoCapabilities
Audio AudioCapabilities
}
type PTZCapabilities struct {
Supported bool
ContinuousMove bool
AbsoluteMove bool
RelativeMove bool
Preset bool
MaxPresets int
Patrol bool
MaxPatrols int
Zoom bool
Focus bool
Iris bool
}
处理 Ably camera_command 事件:
| 指令 | 处理逻辑 |
|---|---|
CAMERA_CHECK |
HTTP GET 设备信息接口,成功则在线 |
CAMERA_SNAPSHOT |
GET /ISAPI/Streaming/channels/1/picture,如需要则缩放图片,Base64 编码后返回 |
CAMERA_PRESET_LIST |
GET /ISAPI/PTZCtrl/channels/1/presets,解析 XML 返回 |
CAMERA_DEVICE_INFO |
调用探测服务获取完整设备信息 |
1. 收集系统信息(OS、CPU、内存、FFmpeg 版本)
2. 生成 NodeID: "lss-{machineId}"
3. 获取本机 IP
4. 获取公网 IP(可选,GET https://api.ipify.org)
5. 扫描本地网络摄像头
6. POST /lss/register 向 RCS 注册
7. 连接 Ably,加入 Presence
8. 订阅指令通道 lss:{nodeId}:commands
9. 启动 HTTP 心跳定时器(5 秒间隔)
10. 启动 Ably 心跳定时器(10 秒间隔)
11. 启动摄像头定时扫描(60 秒间隔)
| 通道 | 间隔 | 传输方式 | 内容 |
|---|---|---|---|
| HTTP | 5 秒 | POST /lss/heartbeat | nodeId, activeTaskCount, cpuUsage, memoryUsage, cameras |
| Ably | 10 秒 | Ably Message | 同上 + activeTasks 详情 |
1. 停止所有活跃推流任务(逐个 SIGTERM → SIGKILL)
2. 离开 Ably Presence
3. 关闭 Ably 连接
4. 停止所有定时器
type ErrorCode int
const (
Success ErrorCode = 0
// 1xxxx: 认证错误
Unauthorized ErrorCode = 10001
AccessDenied ErrorCode = 10002
// 2xxxx: 参数错误
BadRequest ErrorCode = 20001
ParamMissing ErrorCode = 20002
ParamInvalid ErrorCode = 20003
NotFound ErrorCode = 20004
ValidationFailed ErrorCode = 20005
// 301xx: 任务相关
TaskNotFound ErrorCode = 30101
TaskAlreadyExist ErrorCode = 30102
TaskLimitReached ErrorCode = 30103
TaskStartFailed ErrorCode = 30104
TaskStopFailed ErrorCode = 30105
TaskInvalidState ErrorCode = 30106
// 302xx: RTSP 相关
RTSPConnFailed ErrorCode = 30201
RTSPAuthFailed ErrorCode = 30202
RTSPStreamError ErrorCode = 30203
// 303xx: 节点相关
NodeNotReady ErrorCode = 30301
NodeBusy ErrorCode = 30302
NodeOffline ErrorCode = 30303
// 401xx: FFmpeg 相关
FFmpegNotFound ErrorCode = 40101
FFmpegStartFail ErrorCode = 40102
FFmpegCrash ErrorCode = 40103
FFmpegTimeout ErrorCode = 40104
// 402xx: WHIP/Cloudflare
WHIPConnFailed ErrorCode = 40201
WHIPAuthFailed ErrorCode = 40202
WHIPStreamError ErrorCode = 40203
// 403xx: Ably
AblyConnFailed ErrorCode = 40301
AblyAuthFailed ErrorCode = 40302
AblyPublishFail ErrorCode = 40303
// 404xx: RCS
RCSConnFailed ErrorCode = 40401
RCSAuthFailed ErrorCode = 40402
RCSCallbackFail ErrorCode = 40403
// 5xxxx: 内部错误
InternalError ErrorCode = 50001
DatabaseError ErrorCode = 50002
Unavailable ErrorCode = 50003
UnknownError ErrorCode = 59999
)
server:
port: 10060 # HTTP 服务端口
ably:
api_key: '' # Ably API Key(必填)
client_id: '' # 自动生成: lss-{machineId}
heartbeat_interval: 10s # Ably 心跳间隔
lss:
node:
name: 'LSS-Node-1' # 节点名称
machine_id: '' # 机器标识(必填)
max_tasks: 4 # 最大并发推流数
ffmpeg:
path: '/usr/local/ffmpeg-whip/bin/ffmpeg' # FFmpeg 路径
stop_timeout: 10s # 停止超时
rcs:
base_url: '' # RCS 服务地址(必填)
connect_timeout: 10s # HTTP 连接超时
read_timeout: 30s # HTTP 读取超时
heartbeat_interval: 5s # HTTP 心跳间隔
camera:
scan_enabled: true # 是否启用摄像头扫描
scan_subnet: '192.168.0.0/24' # 扫描子网
scan_timeout: 30s # 扫描超时
rescan_interval: 60s # 重扫描间隔
环境变量:
| 变量 | 必填 | 说明 |
|---|---|---|
ABLY_API_KEY |
是 | Ably API Key |
LSS_MACHINE_ID |
是 | 机器标识 |
RCS_BASE_URL |
是 | RCS 服务地址 |
LSS_NODE_NAME |
否 | 节点名称,默认 "LSS-Node-1" |
LSS_MAX_TASKS |
否 | 最大并发数,默认 4 |
FFMPEG_PATH |
否 | FFmpeg 路径 |
sync.RWMutex 保护的 map),无数据库Go 实现需使用 Ably Go SDK,功能包括:
不需要完整的 ONVIF SDK,自行构建 SOAP XML 请求即可:
net/http 发送 POST 请求encoding/xml 解析 XML 响应net/http 客户端所有状态均保存在内存中。服务重启后状态丢失,依赖 RCS 重新下发指令恢复。
使用结构化日志(推荐 slog 或 zerolog),关键操作需记录:
RCS → Ably(lss:{nodeId}:commands) → [command: START_STREAM]
→ LSS 接收消息
→ StreamTaskService.StartStream()
→ 创建 StreamTask
→ FFmpegGateway.StartProcess() → 启动 FFmpeg 子进程
→ 等待 2 秒确认进程存活
→ RcsGateway.TaskStartedCallback(taskId, pid, playbackUrl)
→ Ably 发布结果到 lss:{nodeId}:status
RCS → Ably(lss:{nodeId}:commands) → [ptz_command: PTZ_CONTROL, action: PAN_LEFT, speed: 50]
→ LSS 接收消息
→ PTZControlService.HandlePTZControl()
→ 获取缓存的凭证
→ 判断厂商类型
→ 海康: PUT /ISAPI/PTZCtrl/channels/1/continuous → <PTZData><pan>-50</pan>...</PTZData>
→ 或 ONVIF: POST /onvif/ptz_service → ContinuousMove(x=-0.5, y=0)
→ Ably 发布结果
RCS → Ably → [patrol_command: PATROL_START, waypoints: [...], loopCount: 3]
→ PatrolTourExecutor.StartPatrol()
→ 创建 PatrolSession
→ 启动 goroutine 执行巡航
→ 循环 3 次:
→ 航点 1: 等待 → ContinuousMove(LEFT, 50) → 等 3s → Stop
→ 航点 2: 等待 → Stop → 等 2s
→ 航点 3: 等待 → ContinuousMove(RIGHT, 50) → 等 3s → Stop
→ 巡航完成,清理会话