Commit 609efc1c by James Yuan

feat: 固化 IPC 独立 IP 摄像台账并完善网关/大屏预览

保存 cam-142…156 直连配置与 MediaMTX 流映射,按 IP 解析流 ID,静默 JPEG 降级预览。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 92bab587
# MediaMTX + ffmpeg:海康 H.265 需转码为 H.264 才能被 Chrome WebRTC 播放
FROM bluenviron/mediamtx:1 AS mtx
FROM alpine:3.20
RUN apk add --no-cache ffmpeg ca-certificates \
&& mkdir -p /mediamtx
COPY --from=mtx /mediamtx /mediamtx
COPY mediamtx.yml /mediamtx.yml
ENTRYPOINT ["/mediamtx"]
CMD ["/mediamtx.yml"]
......@@ -23,14 +23,17 @@ services:
container_name: jh-lab-mediamtx
restart: unless-stopped
environment:
MTX_WEBRTCICEHOSTNAT1TO1IPS: ${WEBRTC_ICE_HOST:-192.168.32.110}
MTX_WEBRTCICEHOSTNAT1TO1IPS: ${WEBRTC_ICE_HOST:-192.168.10.107}
MTX_WEBRTCICETCPMUTUALLISTENINGPORT: "8189"
volumes:
- ./mediamtx.yml:/mediamtx.yml:ro
ports:
- "${MEDIAMTX_RTSP_PORT:-8554}:8554"
- "${MEDIAMTX_HLS_PORT:-8888}:8888"
- "${MEDIAMTX_WEBRTC_PORT:-8889}:8889"
- "${MEDIAMTX_WEBRTC_UDP:-8189}:8189/udp"
- "${MEDIAMTX_WEBRTC_TCP:-8189}:8189"
- "9997:9997"
server:
image: jh-lab-server:prod
......
{
"listen": "0.0.0.0",
"port": 38080,
"platformUrl": "http://127.0.0.1:3001",
"platformUrl": "http://192.168.10.107:3001",
"pollMs": 2000,
"mediaWhepBase": "http://127.0.0.1:8889",
"modbusAddressBase": 1,
......@@ -58,7 +58,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-142",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.142:554/Streaming/Channels/102"
},
{
"id": "cam-143",
......@@ -68,7 +70,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-143",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.143:554/Streaming/Channels/102"
},
{
"id": "cam-144",
......@@ -78,7 +82,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-144",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.144:554/Streaming/Channels/102"
},
{
"id": "cam-145",
......@@ -88,7 +94,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-145",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.145:554/Streaming/Channels/102"
},
{
"id": "cam-146",
......@@ -98,7 +106,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-146",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.146:554/Streaming/Channels/102"
},
{
"id": "cam-147",
......@@ -108,7 +118,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-147",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.147:554/Streaming/Channels/102"
},
{
"id": "cam-148",
......@@ -118,7 +130,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-148",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.148:554/Streaming/Channels/102"
},
{
"id": "cam-149",
......@@ -128,7 +142,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-149",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.149:554/Streaming/Channels/102"
},
{
"id": "cam-150",
......@@ -138,7 +154,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-150",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.150:554/Streaming/Channels/102"
},
{
"id": "cam-151",
......@@ -148,7 +166,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-151",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.151:554/Streaming/Channels/102"
},
{
"id": "cam-152",
......@@ -158,7 +178,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-152",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.152:554/Streaming/Channels/102"
},
{
"id": "cam-153",
......@@ -168,7 +190,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-153",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.153:554/Streaming/Channels/102"
},
{
"id": "cam-154",
......@@ -178,7 +202,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-154",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.154:554/Streaming/Channels/102"
},
{
"id": "cam-155",
......@@ -188,7 +214,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-155",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.155:554/Streaming/Channels/102"
},
{
"id": "cam-156",
......@@ -198,7 +226,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-156",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.156:554/Streaming/Channels/102"
}
],
"doors": [
......
@echo off
cd /d %~dp0
setlocal
cd /d "%~dp0"
title JhLab Gateway
REM stop old process
taskkill /F /IM JhLabGateway.exe >nul 2>nul
timeout /t 1 /nobreak >nul
start "" /B JhLabGateway.exe config.json
echo Gateway started: http://127.0.0.1:38080/ui/
ping 127.0.0.1 -n 2 >nul
REM open admin UI after a short delay (gateway still starting)
start "" cmd /c "ping 127.0.0.1 -n 5 >nul & start http://127.0.0.1:38080/ui/"
echo.
echo JhLab Gateway starting...
echo Admin UI: http://127.0.0.1:38080/ui/
echo Close THIS window to stop the gateway process.
echo.
REM foreground: closing this console ends JhLabGateway.exe
JhLabGateway.exe config.json
set ERR=%ERRORLEVEL%
echo.
echo Gateway exited. code=%ERR%
pause
endlocal
# MediaMTX — each IPC independent IP (substream 102)
webrtc: yes
webrtcAddress: :8889
rtsp: yes
rtspAddress: :8554
hls: yes
hlsAddress: :8888
api: yes
apiAddress: :9997
paths:
cam-142:
source: rtsp://admin:Hk223366@192.168.10.142:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-143:
source: rtsp://admin:Hk223366@192.168.10.143:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-144:
source: rtsp://admin:Hk223366@192.168.10.144:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-145:
source: rtsp://admin:Hk223366@192.168.10.145:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-146:
source: rtsp://admin:Hk223366@192.168.10.146:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-147:
source: rtsp://admin:Hk223366@192.168.10.147:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-148:
source: rtsp://admin:Hk223366@192.168.10.148:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-149:
source: rtsp://admin:Hk223366@192.168.10.149:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-150:
source: rtsp://admin:Hk223366@192.168.10.150:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-151:
source: rtsp://admin:Hk223366@192.168.10.151:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-152:
source: rtsp://admin:Hk223366@192.168.10.152:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-153:
source: rtsp://admin:Hk223366@192.168.10.153:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-154:
source: rtsp://admin:Hk223366@192.168.10.154:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-155:
source: rtsp://admin:Hk223366@192.168.10.155:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
cam-156:
source: rtsp://admin:Hk223366@192.168.10.156:554/Streaming/Channels/102
sourceOnDemand: yes
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 45s
{
"source": "澳门镜湖医院分子实验室项目技术交底表.xls",
"source": "gateway/win-protocol/config.json + 交底表 IPC 独立 IP",
"cameras": [
{
"id": "cam-142",
......@@ -9,7 +9,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-142",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.142:554/Streaming/Channels/102"
},
{
"id": "cam-143",
......@@ -19,7 +21,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-143",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.143:554/Streaming/Channels/102"
},
{
"id": "cam-144",
......@@ -29,7 +33,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-144",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.144:554/Streaming/Channels/102"
},
{
"id": "cam-145",
......@@ -39,7 +45,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-145",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.145:554/Streaming/Channels/102"
},
{
"id": "cam-146",
......@@ -49,7 +57,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-146",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.146:554/Streaming/Channels/102"
},
{
"id": "cam-147",
......@@ -59,7 +69,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-147",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.147:554/Streaming/Channels/102"
},
{
"id": "cam-148",
......@@ -69,7 +81,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-148",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.148:554/Streaming/Channels/102"
},
{
"id": "cam-149",
......@@ -79,7 +93,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-149",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.149:554/Streaming/Channels/102"
},
{
"id": "cam-150",
......@@ -89,7 +105,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-150",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.150:554/Streaming/Channels/102"
},
{
"id": "cam-151",
......@@ -99,7 +117,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-151",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.151:554/Streaming/Channels/102"
},
{
"id": "cam-152",
......@@ -109,7 +129,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-152",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.152:554/Streaming/Channels/102"
},
{
"id": "cam-153",
......@@ -119,7 +141,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-153",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.153:554/Streaming/Channels/102"
},
{
"id": "cam-154",
......@@ -129,7 +153,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-154",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.154:554/Streaming/Channels/102"
},
{
"id": "cam-155",
......@@ -139,7 +165,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-155",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.155:554/Streaming/Channels/102"
},
{
"id": "cam-156",
......@@ -149,7 +177,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-156",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.156:554/Streaming/Channels/102"
}
],
"doors": [
......
{
"note": "IPC 独立 IP 摄像台账(cam-142…156);与网关 config.json / MediaMTX 流名对齐",
"updatedAt": "2026-11-02",
"devices": [
{
"id": "85ac83ed-89de-46b1-881d-9376e639e595",
"name": "试剂准备间",
"ip": "192.168.10.142",
"port": 8000,
"channel": 10,
"deviceType": "ipc",
"streamId": "cam-142",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.142:554/Streaming/Channels/102",
"location": "试剂准备间",
"remark": "交底表 · 海康威视 · 192.168.10.142:8000",
"scenePoint": {
"x": 133.5430677376102,
"y": 22.197197787207845,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "38975b17-d33e-48ed-ad6c-9b7d6f80798f",
"name": "后走廊",
"ip": "192.168.10.143",
"port": 8000,
"channel": 15,
"deviceType": "ipc",
"streamId": "cam-143",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.143:554/Streaming/Channels/102",
"location": "后走廊",
"remark": "交底表 · 海康威视 · 192.168.10.143:8000",
"scenePoint": {
"x": 133.54302627339493,
"y": 22.197266211012888,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "3a09dc9c-e9b8-4c74-9706-6f69aae32019",
"name": "样本制备区2房前面",
"ip": "192.168.10.144",
"port": 8000,
"channel": 2,
"deviceType": "ipc",
"streamId": "cam-144",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.144:554/Streaming/Channels/102",
"location": "样本制备区2房前面",
"remark": "交底表 · 海康威视 · 192.168.10.144:8000",
"scenePoint": {
"x": 133.54304285908103,
"y": 22.197218473474486,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "932f3870-ec55-462e-993e-31cf5590ef0f",
"name": "样本制备区1房后面",
"ip": "192.168.10.145",
"port": 8000,
"channel": 9,
"deviceType": "ipc",
"streamId": "cam-145",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.145:554/Streaming/Channels/102",
"location": "样本制备区1房后面",
"remark": "交底表 · 海康威视 · 192.168.10.145:8000",
"scenePoint": {
"x": 133.5430552983456,
"y": 22.197170735936083,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "2ed69680-d341-49b3-9ddb-192941c94e43",
"name": "文库制备2房后面",
"ip": "192.168.10.146",
"port": 8000,
"channel": 3,
"deviceType": "ipc",
"streamId": "cam-146",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.146:554/Streaming/Channels/102",
"location": "文库制备2房后面",
"remark": "交底表 · 海康威视 · 192.168.10.146:8000",
"scenePoint": {
"x": 133.5430179805519,
"y": 22.197170735936083,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "155186dc-fe9a-4f38-b10a-a77fdea9713b",
"name": "文库制备2房前面",
"ip": "192.168.10.147",
"port": 8000,
"channel": 5,
"deviceType": "ipc",
"streamId": "cam-147",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.147:554/Streaming/Channels/102",
"location": "文库制备2房前面",
"remark": "交底表 · 海康威视 · 192.168.10.147:8000",
"scenePoint": {
"x": 133.5430179805519,
"y": 22.197218473474486,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "fb15759f-b4d2-4ad7-a40c-4e790f81510c",
"name": "测序区后",
"ip": "192.168.10.148",
"port": 8000,
"channel": 7,
"deviceType": "ipc",
"streamId": "cam-148",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.148:554/Streaming/Channels/102",
"location": "测序区后",
"remark": "交底表 · 海康威视 · 192.168.10.148:8000",
"scenePoint": {
"x": 133.5430055412873,
"y": 22.197170735936083,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "a21fb190-754b-4d37-9178-2f1dbed34aff",
"name": "文库制备区1房后面",
"ip": "192.168.10.149",
"port": 8000,
"channel": 14,
"deviceType": "ipc",
"streamId": "cam-149",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.149:554/Streaming/Channels/102",
"location": "文库制备区1房后面",
"remark": "交底表 · 海康威视 · 192.168.10.149:8000",
"scenePoint": {
"x": 133.54303041981646,
"y": 22.197170735936083,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "d7a8b714-fff9-4846-9377-febf33a6c1e2",
"name": "收样咨询室",
"ip": "192.168.10.150",
"port": 8000,
"channel": 1,
"deviceType": "ipc",
"streamId": "cam-150",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.150:554/Streaming/Channels/102",
"location": "收样咨询室",
"remark": "交底表 · 海康威视 · 192.168.10.150:8000",
"scenePoint": {
"x": 133.54307022546308,
"y": 22.197145275915602,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "95bfee9f-7e42-4321-b5ea-59ebc48e9c06",
"name": "生信分析/报告室",
"ip": "192.168.10.151",
"port": 8000,
"channel": 8,
"deviceType": "ipc",
"streamId": "cam-151",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.151:554/Streaming/Channels/102",
"location": "生信分析/报告室",
"remark": "交底表 · 海康威视 · 192.168.10.151:8000",
"scenePoint": {
"x": 133.54307022546308,
"y": 22.197224838479606,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "d4f38476-01ef-483e-be75-f6f38efd533b",
"name": "样本制备区2房后面",
"ip": "192.168.10.152",
"port": 8000,
"channel": 4,
"deviceType": "ipc",
"streamId": "cam-152",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.152:554/Streaming/Channels/102",
"location": "样本制备区2房后面",
"remark": "交底表 · 海康威视 · 192.168.10.152:8000",
"scenePoint": {
"x": 133.54304285908103,
"y": 22.197170735936083,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "af0b0ea0-4ae8-4fcd-a5eb-58d1d061241c",
"name": "前走廊",
"ip": "192.168.10.153",
"port": 8000,
"channel": 6,
"deviceType": "ipc",
"streamId": "cam-153",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.153:554/Streaming/Channels/102",
"location": "前走廊",
"remark": "交底表 · 海康威视 · 192.168.10.153:8000",
"scenePoint": {
"x": 133.54304866407116,
"y": 22.197266211012888,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "4fc7975e-0f81-476b-995d-a9b3fad8e337",
"name": "样本制备区1房前面",
"ip": "192.168.10.154",
"port": 8000,
"channel": 12,
"deviceType": "ipc",
"streamId": "cam-154",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.154:554/Streaming/Channels/102",
"location": "样本制备区1房前面",
"remark": "交底表 · 海康威视 · 192.168.10.154:8000",
"scenePoint": {
"x": 133.5430552983456,
"y": 22.197218473474486,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "3aa27733-4114-4345-80a0-4b90ea3fd6ca",
"name": "测序区前",
"ip": "192.168.10.155",
"port": 8000,
"channel": 13,
"deviceType": "ipc",
"streamId": "cam-155",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.155:554/Streaming/Channels/102",
"location": "测序区前",
"remark": "交底表 · 海康威视 · 192.168.10.155:8000",
"scenePoint": {
"x": 133.5430055412873,
"y": 22.197218473474486,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
},
{
"id": "b6d76901-23e7-4df4-bc23-12f88c0cd956",
"name": "文库制备区1房前面",
"ip": "192.168.10.156",
"port": 8000,
"channel": 11,
"deviceType": "ipc",
"streamId": "cam-156",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.156:554/Streaming/Channels/102",
"location": "文库制备区1房前面",
"remark": "交底表 · 海康威视 · 192.168.10.156:8000",
"scenePoint": {
"x": 133.54303041981646,
"y": 22.197218473474486,
"z": 51,
"icon": "cctv-blue.png"
},
"enabled": true
}
]
}
......@@ -4,29 +4,47 @@ project(JhLabGateway LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# ---- HCNetSDK(仅 MSVC x64 推荐启用)----
# ---- HCNetSDK:MSVC 链 .lib;MinGW 可用 gendef/dlltool 生成的 .a ----
option(JHLAB_ENABLE_HCNETSDK "Link Hikvision HCNetSDK" OFF)
set(JHLAB_HCNETSDK_ROOT "" CACHE PATH "HCNetSDK package root (contains incCn/ and lib/win64/)")
if(NOT JHLAB_HCNETSDK_ROOT)
# win-protocol → gateway → web(repo) → code → 项目根/设备对接
set(_hik_cand
foreach(_hik_cand
"${CMAKE_SOURCE_DIR}/../../../../设备对接/HCNetSDKV6.1.10.10_build20250123_Win64_ZH_demosign/HCNetSDKV6.1.10.10_build20250123_Win64_ZH"
"D:/build/sdk"
"${CMAKE_SOURCE_DIR}/../sdk"
)
if(EXISTS "${_hik_cand}/incCn/HCNetSDK.h")
set(JHLAB_HCNETSDK_ROOT "${_hik_cand}" CACHE PATH "HCNetSDK package root" FORCE)
break()
endif()
endforeach()
endif()
if(MSVC AND JHLAB_HCNETSDK_ROOT AND EXISTS "${JHLAB_HCNETSDK_ROOT}/incCn/HCNetSDK.h"
AND EXISTS "${JHLAB_HCNETSDK_ROOT}/lib/win64/HCNetSDK.lib")
set(_hik_have_headers FALSE)
set(_hik_have_msvc_lib FALSE)
set(_hik_have_dll FALSE)
if(JHLAB_HCNETSDK_ROOT AND EXISTS "${JHLAB_HCNETSDK_ROOT}/incCn/HCNetSDK.h")
set(_hik_have_headers TRUE)
endif()
if(JHLAB_HCNETSDK_ROOT AND EXISTS "${JHLAB_HCNETSDK_ROOT}/lib/win64/HCNetSDK.lib")
set(_hik_have_msvc_lib TRUE)
endif()
if(JHLAB_HCNETSDK_ROOT AND EXISTS "${JHLAB_HCNETSDK_ROOT}/lib/win64/HCNetSDK.dll")
set(_hik_have_dll TRUE)
endif()
if(MSVC AND _hik_have_headers AND _hik_have_msvc_lib)
set(JHLAB_ENABLE_HCNETSDK ON)
message(STATUS "HCNetSDK enabled: ${JHLAB_HCNETSDK_ROOT}")
message(STATUS "HCNetSDK enabled (MSVC): ${JHLAB_HCNETSDK_ROOT}")
elseif(MINGW AND _hik_have_headers AND _hik_have_dll)
set(JHLAB_ENABLE_HCNETSDK ON)
message(STATUS "HCNetSDK enabled (MinGW import lib): ${JHLAB_HCNETSDK_ROOT}")
elseif(JHLAB_ENABLE_HCNETSDK)
message(WARNING "JHLAB_ENABLE_HCNETSDK=ON but SDK not found / not MSVC — building stub")
message(WARNING "JHLAB_ENABLE_HCNETSDK=ON but SDK headers/libs missing — building stub")
set(JHLAB_ENABLE_HCNETSDK OFF)
else()
message(STATUS "HCNetSDK stub mode (set JHLAB_HCNETSDK_ROOT + MSVC to enable)")
message(STATUS "HCNetSDK stub mode (provide JHLAB_HCNETSDK_ROOT + MSVC/.lib or MinGW/DLL)")
endif()
add_executable(JhLabGateway
......@@ -39,8 +57,29 @@ target_include_directories(JhLabGateway PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/thir
if(JHLAB_ENABLE_HCNETSDK)
target_compile_definitions(JhLabGateway PRIVATE JHLAB_HAS_HCNETSDK)
target_include_directories(JhLabGateway PRIVATE "${JHLAB_HCNETSDK_ROOT}/incCn")
if(MSVC)
target_link_directories(JhLabGateway PRIVATE "${JHLAB_HCNETSDK_ROOT}/lib/win64")
target_link_libraries(JhLabGateway PRIVATE HCNetSDK)
elseif(MINGW)
set(_hik_implib_dir "${CMAKE_BINARY_DIR}/hcnet_implib")
file(MAKE_DIRECTORY "${_hik_implib_dir}")
set(_hik_dll "${JHLAB_HCNETSDK_ROOT}/lib/win64/HCNetSDK.dll")
set(_hik_def "${_hik_implib_dir}/HCNetSDK.def")
set(_hik_a "${_hik_implib_dir}/libHCNetSDK.a")
find_program(GENDEF_EXE gendef REQUIRED)
find_program(DLLTOOL_EXE dlltool REQUIRED)
add_custom_command(
OUTPUT "${_hik_a}"
COMMAND "${GENDEF_EXE}" - "${_hik_dll}" > "${_hik_def}"
COMMAND "${DLLTOOL_EXE}" -d "${_hik_def}" -l "${_hik_a}" -D HCNetSDK.dll
DEPENDS "${_hik_dll}"
COMMENT "Generating MinGW import library for HCNetSDK.dll"
VERBATIM
)
add_custom_target(hcnet_implib DEPENDS "${_hik_a}")
add_dependencies(JhLabGateway hcnet_implib)
target_link_libraries(JhLabGateway PRIVATE "${_hik_a}")
endif()
endif()
if(WIN32)
......
@echo off
setlocal EnableExtensions
REM MinGW + HCNetSDK on Windows (no VS required)
REM Expect:
REM D:\build\tools\mingw64\bin\g++.exe
REM D:\build\tools\cmake\...\bin\cmake.exe
REM D:\build\sdk\incCn + lib\win64
REM D:\build\win-protocol (source)
set ROOT=D:\build
set SRC=%ROOT%\win-protocol
set SDK=%ROOT%\sdk
set MINGW=%ROOT%\tools\mingw64\bin
set CMAKE=%ROOT%\tools\cmake\cmake-3.30.5-windows-x86_64\bin\cmake.exe
if not exist "%MINGW%\g++.exe" (
echo [ERR] MinGW not found: %MINGW%\g++.exe
echo Place extracted mingw64 toolchain under D:\build\tools\mingw64
exit /b 1
)
if not exist "%CMAKE%" (
echo [ERR] cmake not found: %CMAKE%
exit /b 1
)
if not exist "%SDK%\incCn\HCNetSDK.h" (
echo [ERR] HCNetSDK headers missing: %SDK%\incCn
exit /b 1
)
if not exist "%SDK%\lib\win64\HCNetSDK.dll" (
echo [ERR] HCNetSDK.dll missing
exit /b 1
)
set PATH=%MINGW%;%PATH%
cd /d "%SRC%"
echo === cmake configure MinGW + HCNetSDK ===
if exist build-mingw-hcnet rmdir /s /q build-mingw-hcnet
"%CMAKE%" -S . -B build-mingw-hcnet -G "MinGW Makefiles" ^
-DCMAKE_BUILD_TYPE=Release ^
-DCMAKE_C_COMPILER=%MINGW%\gcc.exe ^
-DCMAKE_CXX_COMPILER=%MINGW%\g++.exe ^
-DJHLAB_HCNETSDK_ROOT=%SDK% ^
-DJHLAB_ENABLE_HCNETSDK=ON
if errorlevel 1 exit /b 1
echo === build ===
"%CMAKE%" --build build-mingw-hcnet -j %NUMBER_OF_PROCESSORS%
if errorlevel 1 exit /b 1
echo === result ===
dir /b build-mingw-hcnet\JhLabGateway.exe
echo DONE
endlocal
{
"listen": "0.0.0.0",
"port": 38080,
"platformUrl": "http://192.168.32.122:3001",
"platformUrl": "http://192.168.10.107:3001",
"pollMs": 2000,
"mediaWhepBase": "http://127.0.0.1:8889",
"modbusAddressBase": 1,
......@@ -58,7 +58,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-142",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.142:554/Streaming/Channels/102"
},
{
"id": "cam-143",
......@@ -68,7 +70,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-143",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.143:554/Streaming/Channels/102"
},
{
"id": "cam-144",
......@@ -78,7 +82,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-144",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.144:554/Streaming/Channels/102"
},
{
"id": "cam-145",
......@@ -88,7 +94,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-145",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.145:554/Streaming/Channels/102"
},
{
"id": "cam-146",
......@@ -98,7 +106,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-146",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.146:554/Streaming/Channels/102"
},
{
"id": "cam-147",
......@@ -108,7 +118,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-147",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.147:554/Streaming/Channels/102"
},
{
"id": "cam-148",
......@@ -118,7 +130,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-148",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.148:554/Streaming/Channels/102"
},
{
"id": "cam-149",
......@@ -128,7 +142,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-149",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.149:554/Streaming/Channels/102"
},
{
"id": "cam-150",
......@@ -138,7 +154,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-150",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.150:554/Streaming/Channels/102"
},
{
"id": "cam-151",
......@@ -148,7 +166,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-151",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.151:554/Streaming/Channels/102"
},
{
"id": "cam-152",
......@@ -158,7 +178,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-152",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.152:554/Streaming/Channels/102"
},
{
"id": "cam-153",
......@@ -168,7 +190,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-153",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.153:554/Streaming/Channels/102"
},
{
"id": "cam-154",
......@@ -178,7 +202,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-154",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.154:554/Streaming/Channels/102"
},
{
"id": "cam-155",
......@@ -188,7 +214,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-155",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.155:554/Streaming/Channels/102"
},
{
"id": "cam-156",
......@@ -198,7 +226,9 @@
"username": "admin",
"password": "Hk223366",
"channel": 1,
"enabled": true
"enabled": true,
"streamId": "cam-156",
"rtspUrl": "rtsp://admin:Hk223366@192.168.10.156:554/Streaming/Channels/102"
}
],
"doors": [
......
' Hidden autostart helper (no console window).
' Prefer start-gateway.bat when you want close-window-to-exit behavior.
Set s = CreateObject("WScript.Shell")
s.CurrentDirectory = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
s.Run "cmd /c taskkill /F /IM JhLabGateway.exe >nul 2>nul", 0, True
s.Run "JhLabGateway.exe config.json", 0, False
......@@ -27,6 +27,22 @@
namespace {
/** 供控制台关闭 / 管理台「退出网关」停止 listen */
httplib::Server* gHttpServer = nullptr;
#ifdef _WIN32
BOOL WINAPI gatewayConsoleCtrlHandler(DWORD type) {
if (type == CTRL_C_EVENT || type == CTRL_CLOSE_EVENT || type == CTRL_BREAK_EVENT ||
type == CTRL_LOGOFF_EVENT || type == CTRL_SHUTDOWN_EVENT) {
if (gHttpServer) gHttpServer->stop();
// CLOSE 需尽快返回,否则系统强杀
Sleep(200);
return TRUE;
}
return FALSE;
}
#endif
struct PointCfg {
std::string id;
std::string itemId;
......@@ -1525,6 +1541,17 @@ int main(int argc, char** argv) {
respondCameraPreview(req.matches[1], res);
});
/** 管理台主动退出:停止 HTTP,进程随后结束(配合前台 start-gateway.bat) */
svr.Post("/admin/api/shutdown", [](const httplib::Request& req, httplib::Response& res) {
if (!authOk(req)) return unauthorized(res);
res.set_content("{\"ok\":true,\"message\":\"gateway stopping\"}",
"application/json; charset=utf-8");
std::thread([] {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
if (gHttpServer) gHttpServer->stop();
}).detach();
});
int port = 0;
std::string host;
{
......@@ -1554,9 +1581,18 @@ int main(int argc, char** argv) {
std::cout << "[gateway] HCNetSDK CAM: " << (hik_cam::sdkLinked() ? "linked" : "stub")
<< " camsLoggedIn=" << hik_cam::loggedInCount()
<< " mediaWhepBase=" << (g.mediaWhepBase.empty() ? "(unset)" : g.mediaWhepBase) << "\n";
gHttpServer = &svr;
#ifdef _WIN32
// 关闭控制台窗口 / Ctrl+C 时停止服务并退出进程(必须用 WINAPI 回调,勿用 C++ lambda)
SetConsoleCtrlHandler(gatewayConsoleCtrlHandler, TRUE);
#endif
if (!svr.listen(host.c_str(), port)) {
std::cerr << "[gateway] failed to bind\n";
gHttpServer = nullptr;
return 1;
}
gHttpServer = nullptr;
return 0;
}
@echo off
setlocal
cd /d "%~dp0"
title JhLab Gateway
REM stop old process
taskkill /F /IM JhLabGateway.exe >nul 2>nul
ping 127.0.0.1 -n 2 >nul
REM open admin UI after a short delay (gateway still starting)
start "" cmd /c "ping 127.0.0.1 -n 5 >nul & start http://127.0.0.1:38080/ui/"
echo.
echo JhLab Gateway starting...
echo Admin UI: http://127.0.0.1:38080/ui/
echo Close THIS window to stop the gateway process.
echo.
REM foreground: closing this console ends JhLabGateway.exe
JhLabGateway.exe config.json
set ERR=%ERRORLEVEL%
echo.
echo Gateway exited. code=%ERR%
pause
endlocal
......@@ -235,6 +235,21 @@ async function boot() {
localStorage.removeItem('gw_token');
location.replace('/ui/login.html');
};
document.getElementById('btnShutdown').onclick = async () => {
if (!confirm('确认退出并结束网关进程?')) return;
try {
await api('/admin/api/shutdown', { method: 'POST', body: '{}' });
banner('网关正在退出…', true);
} catch (e) {
// 进程已退出时请求可能失败,视为成功
banner('网关已退出(或连接已断开)', true);
}
setTimeout(() => {
try {
window.close();
} catch (_) {}
}, 500);
};
document.getElementById('btnRefreshHealth').onclick = () => loadHealth().catch((e) => banner(e.message));
document.getElementById('btnReloadDevices').onclick = () => loadConfig().catch((e) => banner(e.message));
document.getElementById('btnRefreshPreview').onclick = () =>
......
......@@ -17,7 +17,8 @@
<button type="button" data-page="deviceEdit">设备配置</button>
<button type="button" data-page="cameras">摄像头预览</button>
</nav>
<button class="btn ghost" type="button" id="btnLogout">退出</button>
<button class="btn ghost" type="button" id="btnLogout">退出登录</button>
<button class="btn danger" type="button" id="btnShutdown" title="结束网关进程">退出网关</button>
</header>
<p class="msg" id="banner"></p>
......
......@@ -9,15 +9,24 @@ export function resolveWhepBase(): string {
return (config.webrtcWhepBase || resolveGatewayBase()).replace(/\/$/, '')
}
/** 将平台设备映射为媒体流 ID(优先 streamId,与网关/MediaMTX 对齐) */
/** 将平台设备映射为媒体流 ID(与网关/MediaMTX 的 cam-142… 对齐) */
export function deviceStreamId(device: {
id: string
channel?: number
deviceType?: string
streamId?: string
ip?: string
}): string {
if (device.streamId && String(device.streamId).trim()) return String(device.streamId).trim()
// 现场 IP 末段优先(MediaMTX path / 网关 cameras[].id)
const ip = String(device.ip || '').trim()
const ipHit = ip.match(/^192\.168\.10\.(\d+)$/)
if (ipHit) return `cam-${ipHit[1]}`
if (/^(cam|nvr)-\d+$/i.test(device.id)) return device.id
if (device.streamId && /^((cam|nvr)-\d+)$/i.test(String(device.streamId).trim())) {
return String(device.streamId).trim()
}
if (device.deviceType === 'nvr-ch' && device.channel) return `nvr-${device.channel}`
if (device.streamId && String(device.streamId).trim()) return String(device.streamId).trim()
if (/^(cam|nvr)-/i.test(device.id)) return device.id
return `cam-${device.id}`
}
......
......@@ -13,7 +13,7 @@ try {
}
import { migrate } from './db/migrate.js'
import { pingDb } from './db/pool.js'
import { setPgEnabled } from './db/state.js'
import { isPgEnabled, setPgEnabled } from './db/state.js'
import { seedAdminIfNeeded } from './users/store.js'
import { ensureBillingSeed } from './billing/store.js'
import { ensureSettingsSeed } from './settings/store.js'
......@@ -42,19 +42,58 @@ import { alarmsRouter } from './routes/alarms.js'
import { weatherRouter } from './routes/weather.js'
import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js'
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/** 启动时等待 PG 就绪(避免 crash-recovery 窗口内永久降级,导致历史读写全停) */
async function waitForPostgres(maxAttempts = 30, delayMs = 2000): Promise<boolean> {
for (let i = 1; i <= maxAttempts; i++) {
if (await pingDb()) return true
console.warn(`[db] PostgreSQL 未就绪,重试 ${i}/${maxAttempts}…`)
if (i < maxAttempts) await sleep(delayMs)
}
return false
}
async function enablePostgres() {
setPgEnabled(true)
await migrate()
console.log('[db] PostgreSQL 已连接,采集数据将落库')
}
/** 启动降级后后台探活;库恢复后自动迁库并恢复历史读写(不改采集循环) */
function startPostgresReconnectLoop() {
const delayMs = Math.max(5_000, Number(process.env.PG_RECONNECT_MS || 15_000))
const timer = setInterval(() => {
void (async () => {
if (isPgEnabled()) return
if (!(await pingDb())) return
try {
await enablePostgres()
console.log('[db] PostgreSQL 已恢复,历史读写已重新启用')
} catch (e) {
setPgEnabled(false)
console.warn('[db] PostgreSQL 恢复失败:', e instanceof Error ? e.message : e)
}
})()
}, delayMs)
timer.unref?.()
}
async function bootstrap() {
const pgOk = await pingDb()
let pgOk = await waitForPostgres()
setPgEnabled(pgOk)
if (pgOk) {
await migrate()
console.log('[db] PostgreSQL 已连接,采集数据将落库')
await enablePostgres()
} else {
console.warn(
'[db] PostgreSQL 未连接,已降级为本地文件/内存模式(登录可用)。\n' +
` 请启动数据库后重启服务,例如: docker compose up -d db\n` +
` 将后台重试连接;也可手动: docker compose up -d db && docker restart jh-lab-server\n` +
` 当前 DATABASE_URL=${config.databaseUrl.replace(/:[^:@/]+@/, ':****@')}`,
)
startPostgresReconnectLoop()
}
await seedAdminIfNeeded()
......@@ -108,7 +147,7 @@ async function bootstrap() {
res.json({
ok: true,
mode: config.dataMode,
database: pgOk ? 'postgres' : 'fallback',
database: isPgEnabled() ? 'postgres' : 'fallback',
gatewayUrl: config.gatewayUrl || null,
viewportHint: 'glb|ue-stream configured on web',
})
......@@ -146,7 +185,7 @@ async function bootstrap() {
server.listen(config.port, () => {
console.log(`[server] http://localhost:${config.port}`)
console.log(`[server] dataMode=${config.dataMode}`)
console.log(`[server] database=${pgOk ? 'postgres' : 'fallback(file/memory)'}`)
console.log(`[server] database=${isPgEnabled() ? 'postgres' : 'fallback(file/memory)'}`)
console.log(`[server] default admin: ${config.adminUsername} / ${config.adminPassword}`)
})
}
......
......@@ -82,6 +82,20 @@ videoRouter.get('/devices', (_req, res) => {
res.json({ devices: listDevices() })
})
function findVideoDevice(idOrKey: string) {
const key = String(idOrKey || '').trim()
const devices = listDevices()
let device = devices.find((d) => d.id === key || d.streamId === key)
if (!device && /^cam-\d+$/i.test(key)) {
const octet = key.slice(4)
device = devices.find((d) => String(d.ip || '').endsWith(`.${octet}`))
}
if (!device) {
device = devices.find((d) => d.name === key || d.location === key)
}
return { devices, device, key }
}
videoRouter.post('/devices', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
......@@ -151,10 +165,9 @@ videoRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res
/** JPEG 抓图:反代网关 /cameras/:id/preview.jpg(WebRTC 未就绪时的降级) */
videoRouter.get('/devices/:id/preview.jpg', async (req, res) => {
const devices = listDevices()
const device = devices.find((d) => d.id === req.params.id)
const key = device ? deviceStreamId(device) : req.params.id
const result = await fetchCameraPreviewJpeg(key)
const { device, key } = findVideoDevice(req.params.id)
const streamKey = device ? deviceStreamId(device) : key
const result = await fetchCameraPreviewJpeg(streamKey)
if (!result.ok && result.body.length === 0) {
res.status(502).json({ ok: false, message: result.message || '抓图失败' })
return
......@@ -166,15 +179,14 @@ videoRouter.get('/devices/:id/preview.jpg', async (req, res) => {
/**
* WebRTC WHEP:浏览器 POST application/sdp(offer),返回 answer SDP
* 媒体面由 Windows 网关 / ZLMediaKit 提供
* 媒体面由 Windows 网关 / MediaMTX 提供。
* 大屏匿名可播;写操作仍需登录。
*/
videoRouter.post(
'/devices/:id/whep',
authRequired,
expressSdp,
async (req, res) => {
const devices = listDevices()
const device = devices.find((d) => d.id === req.params.id)
const { device } = findVideoDevice(req.params.id)
if (!device) {
res.status(404).json({ ok: false, message: '设备不存在' })
return
......
......@@ -482,16 +482,48 @@ function looksLikeRawId(s: string): boolean {
return false
}
function findCameraSensor(marker: SceneMarkerHit, markerId: string | null): SensorSnapshot | null {
const sensors = realtime.snapshot.sensors || []
const keys = [marker.tag1, markerId, marker.id]
.map((k) => String(k || '').replace(/^sm:sensor:/i, '').trim())
.filter(Boolean)
for (const key of keys) {
const hit = sensors.find(
(s) => s.category === 'camera' && (s.id === key || s.code === key || s.videoDeviceId === key),
)
if (hit) return hit
}
const name = (marker.name || '').trim()
if (name && !looksLikeRawId(name)) {
return (
sensors.find(
(s) =>
s.category === 'camera' &&
(s.name === name || s.name.includes(name) || name.includes(s.name)),
) || null
)
}
return null
}
function findCamera(marker: SceneMarkerHit, markerId: string | null): VideoDevice | null {
// 引擎回调里 id/name/tag1 都可能是设备 id
const keys = [markerId, marker.id, marker.tag1, marker.tag2, marker.name]
.map(normalizeCamKey)
.filter(Boolean)
for (const id of keys) {
const hit = cameras.value.find((d) => d.id === id)
const hit = cameras.value.find(
(d) => d.id === id || d.streamId === id || (d.ip && `cam-${d.ip.split('.').pop()}` === id),
)
if (hit) return hit
}
const label = (marker.name || '').trim()
// 大屏设施 Marker:sensors.videoDeviceId → 视频台账
const sensor = findCameraSensor(marker, markerId)
if (sensor?.videoDeviceId) {
const byVid = cameras.value.find((d) => d.id === sensor.videoDeviceId)
if (byVid) return byVid
}
const label = (sensor?.name || marker.name || '').trim()
if (label && !looksLikeRawId(label)) {
const byName = cameras.value.find((d) => d.name === label || d.location === label)
if (byName) return byName
......@@ -533,16 +565,25 @@ function onMarkerClick(payload: {
if (!markerId && !marker.id && !marker.tag0 && !marker.name) return
const cam = findCamera(marker, markerId)
const camSensor = findCameraSensor(marker, markerId)
const category = categoryOfMarker(marker)
nearHit.value = marker
if (category === 'camera' || cam) {
if (category === 'camera' || cam || camSensor) {
nearKind.value = 'video'
nearLines.value = []
nearSensorId.value = null
nearDeviceId.value = cam?.id || null
openNearDialog(resolveNearTitle(cam, marker, markerId), markerId || marker.id, raw, {
center: true,
})
nearSensorId.value = camSensor?.id || null
// 优先视频台账 id;否则用传感器绑定的 videoDeviceId / 稳定 cam-* code
nearDeviceId.value =
cam?.id ||
camSensor?.videoDeviceId ||
(camSensor?.code && /^cam-\d+$/i.test(camSensor.code) ? camSensor.code : null) ||
null
openNearDialog(
resolveNearTitle(cam, marker, markerId) || camSensor?.name || t('screen.camPreview'),
markerId || marker.id,
raw,
{ center: true },
)
return
}
const light = findLightSensor(marker, markerId)
......@@ -578,7 +619,7 @@ const bridge = useRtengineBridge({
})
const modePoisEnabled = computed(() => props.layout === 'fill' && useCloudFrame.value)
const { syncModePois, syncModePoisRebuild } = useScreenModePois(bridge, modePoisEnabled, cloudUrl)
const { syncModePoisRebuild } = useScreenModePois(bridge, modePoisEnabled, cloudUrl)
const { syncLightEffects } = useLightSceneEffects(bridge, modePoisEnabled)
function cameraSceneSig(list: VideoDevice[]) {
......
......@@ -10,19 +10,39 @@ const props = defineProps<{
const auth = useAuthStore()
const videoEl = ref<HTMLVideoElement | null>(null)
const status = ref<'idle' | 'connecting' | 'playing' | 'error'>('idle')
const errorMsg = ref('')
const jpegEl = ref<HTMLImageElement | null>(null)
const status = ref<'idle' | 'connecting' | 'playing' | 'jpeg'>('idle')
const jpegUrl = ref('')
let pc: RTCPeerConnection | null = null
let whepResource: string | null = null
let jpegTimer: number | null = null
function stopJpeg() {
if (jpegTimer != null) {
window.clearInterval(jpegTimer)
jpegTimer = null
}
jpegUrl.value = ''
}
function startJpegFallback(_reason?: string) {
stopJpeg()
const tick = () => {
jpegUrl.value = `/api/video/devices/${encodeURIComponent(props.deviceId)}/preview.jpg?t=${Date.now()}`
}
tick()
jpegTimer = window.setInterval(tick, 1200)
status.value = 'jpeg'
}
async function stop() {
stopJpeg()
try {
if (whepResource && auth.token) {
await fetch(whepResource, {
method: 'DELETE',
headers: { Authorization: `Bearer ${auth.token}` },
}).catch(() => undefined)
if (whepResource) {
const headers: Record<string, string> = {}
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
await fetch(whepResource, { method: 'DELETE', headers }).catch(() => undefined)
}
} finally {
whepResource = null
......@@ -37,7 +57,6 @@ async function start() {
await stop()
if (!props.deviceId) return
status.value = 'connecting'
errorMsg.value = ''
pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
......@@ -45,6 +64,7 @@ async function start() {
pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' })
pc.ontrack = (ev) => {
stopJpeg()
if (!videoEl.value) return
videoEl.value.srcObject = ev.streams[0] || new MediaStream([ev.track])
void videoEl.value.play().catch(() => undefined)
......@@ -53,8 +73,9 @@ async function start() {
pc.onconnectionstatechange = () => {
if (!pc) return
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
status.value = 'error'
errorMsg.value = 'WebRTC 连接中断'
if (status.value === 'playing') {
startJpegFallback('WebRTC 连接中断,已降级抓图')
}
}
}
......@@ -64,13 +85,15 @@ async function start() {
await waitIceGathering(pc)
const path = props.whepPath || `/api/video/devices/${encodeURIComponent(props.deviceId)}/whep`
const res = await fetch(path, {
method: 'POST',
headers: {
const headers: Record<string, string> = {
'Content-Type': 'application/sdp',
Accept: 'application/sdp',
Authorization: `Bearer ${auth.token}`,
},
}
// 大屏匿名可播;管理后台有 token 时一并带上
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const res = await fetch(path, {
method: 'POST',
headers,
body: pc.localDescription?.sdp || offer.sdp || '',
})
const loc = res.headers.get('Location')
......@@ -87,11 +110,10 @@ async function start() {
throw new Error(msg)
}
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp })
} catch (e) {
status.value = 'error'
errorMsg.value = e instanceof Error ? e.message : 'WebRTC 启动失败'
await stop()
status.value = 'error'
} catch {
pc?.close()
pc = null
startJpegFallback()
}
}
......@@ -125,17 +147,14 @@ defineExpose({ restart: start, stop })
<template>
<div class="webrtc-player" :class="status">
<video ref="videoEl" class="video" autoplay playsinline muted />
<div v-if="status !== 'playing'" class="overlay">
<template v-if="status === 'connecting'">正在建立 WebRTC…</template>
<template v-else-if="status === 'error'">
<div>WebRTC 未就绪</div>
<div class="sub">{{ errorMsg }}</div>
<button type="button" class="btn" @click="start">重试</button>
</template>
<template v-else>空闲</template>
</div>
<div v-else class="live-tag"><i /><span>WebRTC · LIVE</span></div>
<video v-show="status === 'playing'" ref="videoEl" class="video" autoplay playsinline muted />
<img
v-show="status === 'jpeg'"
ref="jpegEl"
class="video jpeg"
:src="jpegUrl"
alt=""
/>
</div>
</template>
......@@ -155,53 +174,7 @@ defineExpose({ restart: start, stop })
object-fit: contain;
background: #000;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #c9d7f0;
font-size: 14px;
background: rgba(5, 10, 20, 0.72);
text-align: center;
padding: 16px;
}
.sub {
font-size: 12px;
color: #8fa3c4;
max-width: 80%;
line-height: 1.5;
word-break: break-all;
}
.btn {
border: 1px solid rgba(61, 139, 253, 0.45);
background: rgba(61, 139, 253, 0.2);
color: #fff;
border-radius: 6px;
padding: 6px 14px;
cursor: pointer;
}
.live-tag {
position: absolute;
top: 12px;
left: 12px;
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
color: #9dffc0;
}
.live-tag i {
width: 7px;
height: 7px;
border-radius: 50%;
background: #3dd68c;
box-shadow: 0 0 8px #3dd68c;
.jpeg {
object-fit: contain;
}
</style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment