Commit 92bab587 by James Yuan

feat: 接入海康摄像登录/抓图与 WHEP 反代,打通大屏预览

网关新增 hik_cam,平台对齐 cam-* 流名并合并在线态;大屏弹窗改走 WebRTC。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 4a3440d1
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
"port": 38080, "port": 38080,
"platformUrl": "http://127.0.0.1:3001", "platformUrl": "http://127.0.0.1:3001",
"pollMs": 2000, "pollMs": 2000,
"mediaWhepBase": "http://127.0.0.1:8889",
"modbusAddressBase": 1, "modbusAddressBase": 1,
"modbusAddressOffset": -1, "modbusAddressOffset": -1,
"modbusWriteAddressBase": 1, "modbusWriteAddressBase": 1,
......
...@@ -22,7 +22,7 @@ Windows 协议网关已接入 **ACS 登录 / 布防回调 / 远程开门 / 事 ...@@ -22,7 +22,7 @@ Windows 协议网关已接入 **ACS 登录 / 布防回调 / 远程开门 / 事
平台实时循环合并 `/access/snapshot`;`POST /api/realtime/access/open` 转发网关开门。 平台实时循环合并 `/access/snapshot`;`POST /api/realtime/access/open` 转发网关开门。
摄像登录/抓图/WebRTC 仍待后续;`config.json` 已写入交底表全部摄像头与 NVR,供管理台与后续预览使用。 摄像已接入 **登录 / JPEG 抓图 / 平台快照**;WebRTC 经 `mediaWhepBase` 反代 MediaMTX/ZLM(需现场把 RTSP 注册为 `cam-*` 流名)。
## 门禁(4) ## 门禁(4)
......
...@@ -125,8 +125,15 @@ Body 与 `GET /snapshot` 相同。平台写入 `device_points.last_raw / last_en ...@@ -125,8 +125,15 @@ Body 与 `GET /snapshot` 相同。平台写入 `device_points.last_raw / last_en
| GET | `/doors` | 门状态列表 | | GET | `/doors` | 门状态列表 |
| POST | `/doors/:id/open` | 远程开门(body 可选 `mode`) | | POST | `/doors/:id/open` | 远程开门(body 可选 `mode`) |
| GET | `/events` | 最近通行事件 | | GET | `/events` | 最近通行事件 |
| GET | `/access/snapshot` | 平台门禁快照 |
| WS | `/alarms` | ACS 报警推送 | | WS | `/alarms` | ACS 报警推送 |
| GET | `/cameras/:id/snapshot.jpg` | JPEG 抓图(最小可用预览) | | GET | `/cameras` | 摄像列表 + 登录态 |
| GET | `/cameras/snapshot` | 平台摄像快照 `{cameras,online,total}` |
| GET | `/cameras/:id/preview.jpg` | JPEG 抓图(SDK);失败返回 SVG 占位 |
| GET | `/cameras/:id/snapshot.jpg` | 同上别名 |
| POST | `/webrtc/whep/:streamId` | WHEP:有 `mediaWhepBase` 时反代媒体面 |
`config.json` 可选字段:`mediaWhepBase`(如 `http://127.0.0.1:8889`)、`cameras[].streamId` / `rtspUrl` / `streamType`。
## 配置文件(网关本地) ## 配置文件(网关本地)
......
...@@ -75,6 +75,7 @@ docker compose up -d --build ...@@ -75,6 +75,7 @@ docker compose up -d --build
## 现场采集机(Windows 网关) ## 现场采集机(Windows 网关)
1. 网关或旁路媒体服务提供 WHEP;或本机仍用 Docker MediaMTX,网关只负责 Modbus/门禁 1. `config.json` 配置 `mediaWhepBase`(如 `http://127.0.0.1:8889`);网关 `POST /webrtc/whep/:streamId` 反代到 MediaMTX
2. 摄像头 RTSP 拉进 MediaMTX 流名与 `cam-{deviceId}` / `nvr-{channel}` 对齐 2. 摄像头 RTSP 拉进 MediaMTX,流名与 `cameras[].streamId`(默认 `cam-142`…)对齐;网关 `GET /cameras` 可查看 `rtspUrl`
3. 浏览器需能访问 ICE/UDP(`8189`) 3. 浏览器需能访问 ICE/UDP(`8189`)
4. JPEG 降级:`GET /cameras/:id/preview.jpg`(HCNetSDK 抓图,需 MSVC 产物)
...@@ -32,6 +32,7 @@ endif() ...@@ -32,6 +32,7 @@ endif()
add_executable(JhLabGateway add_executable(JhLabGateway
src/main.cpp src/main.cpp
src/hik_acs.cpp src/hik_acs.cpp
src/hik_cam.cpp
) )
target_include_directories(JhLabGateway PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/third_party) target_include_directories(JhLabGateway PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/third_party)
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
- Modbus TCP 多主机采集 / 写(`.102` / `.103` / `.104` / `.105`) - Modbus TCP 多主机采集 / 写(`.102` / `.103` / `.104` / `.105`)
- 海康 HCNetSDK 门禁 ACS(登录 / 布防事件 / 远程开门)— **需 MSVC** - 海康 HCNetSDK 门禁 ACS(登录 / 布防事件 / 远程开门)— **需 MSVC**
- 摄像预览 / WebRTC 仍为后续 - 海康摄像(登录 / JPEG 抓图 / 快照)— **需 MSVC**;WebRTC 经 `mediaWhepBase` 反代 MediaMTX
对外 HTTP API 见 [`docs/gateway-api.md`](../../docs/gateway-api.md)、门禁见 [`docs/access-hcnetsdk.md`](../../docs/access-hcnetsdk.md)。 对外 HTTP API 见 [`docs/gateway-api.md`](../../docs/gateway-api.md)、门禁见 [`docs/access-hcnetsdk.md`](../../docs/access-hcnetsdk.md)。
...@@ -20,6 +20,7 @@ win-protocol/ ...@@ -20,6 +20,7 @@ win-protocol/
config.json config.json
src/main.cpp src/main.cpp
src/hik_acs.cpp / .hpp # 门禁 ACS src/hik_acs.cpp / .hpp # 门禁 ACS
src/hik_cam.cpp / .hpp # 摄像登录 / 抓图
src/modbus_tcp.hpp src/modbus_tcp.hpp
third_party/httplib.h third_party/httplib.h
``` ```
...@@ -68,4 +69,4 @@ JhLabGateway.exe config.json ...@@ -68,4 +69,4 @@ JhLabGateway.exe config.json
2. 后台「设备管理」导入点位 →「下发网关」 2. 后台「设备管理」导入点位 →「下发网关」
3. `GET /health`、`GET /snapshot` 验证 3. `GET /health`、`GET /snapshot` 验证
Modbus 已实采;门禁 ACS 需 MSVC 产物。摄像预览 / WebRTC 媒体面仍待接入。 Modbus 已实采;门禁 ACS / 摄像抓图需 MSVC 产物。WebRTC:采集机部署 MediaMTX,`config.json` 配 `mediaWhepBase`,流名与 `cameras[].streamId`(默认 `cam-*`)对齐。
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
"port": 38080, "port": 38080,
"platformUrl": "http://192.168.32.122:3001", "platformUrl": "http://192.168.32.122:3001",
"pollMs": 2000, "pollMs": 2000,
"mediaWhepBase": "http://127.0.0.1:8889",
"modbusAddressBase": 1, "modbusAddressBase": 1,
"modbusAddressOffset": -1, "modbusAddressOffset": -1,
"modbusWriteAddressBase": 1, "modbusWriteAddressBase": 1,
......
#pragma once
/**
* 海康摄像:登录 / JPEG 抓图 / 在线状态 / 平台快照。
* 定义了 JHLAB_HAS_HCNETSDK 时链真实 HCNetSDK;否则为可编译占位(MinGW)。
* SDK Init/Cleanup 由 hik_acs 统一管理;本模块仅负责摄像设备会话。
*/
#include <cstdint>
#include <string>
#include <vector>
namespace hik_cam {
struct DeviceCfg {
std::string id;
std::string name;
std::string host;
int port = 8000;
std::string username = "admin";
std::string password;
int channel = 1; // 抓图通道(IPC 通常为 1)
std::string streamId; // MediaMTX / WHEP 流名;空则用 id
std::string rtspUrl; // 可选覆盖;空则按海康惯例拼子码流
int streamType = 1; // 0=主码流 1=子码流(拼 RTSP 用)
bool enabled = true;
};
/** 标记就绪(不重复 Init SDK) */
void init();
/** 登出全部摄像;不 Cleanup SDK */
void shutdown();
/** 按 config 摄像头列表登录;差量更新 */
void syncDevices(const std::vector<DeviceCfg>& cameras);
/** 可选:登录 NVR(预留通道预览) */
void syncNvr(const DeviceCfg& nvr);
int loggedInCount();
bool sdkLinked();
/** 拼进 /health 的 cameras 对象(不含外层花括号) */
std::string healthFieldsJson();
/** [{id,name,host,port,channel,streamId,rtspUrl,loggedIn,status,lastError}] */
std::string camerasJsonArray();
/** 平台快照:{cameras:[{id,name,status,streamId}], online, total} */
std::string cameraSnapshotJson();
/** 按 id 或 streamId 取 JPEG;成功写入 jpegOut,失败返回 false */
bool captureJpeg(const std::string& idOrStream, std::vector<uint8_t>* jpegOut, std::string* errMsg = nullptr);
/** 解析流名 → 设备 id(未命中返回空) */
std::string resolveIdByStream(const std::string& streamId);
/** 设备默认 RTSP(海康 Streaming/Channels/{ch}{stream+1}) */
std::string defaultRtspUrl(const DeviceCfg& cfg);
} // namespace hik_cam
...@@ -9,9 +9,16 @@ export function resolveWhepBase(): string { ...@@ -9,9 +9,16 @@ export function resolveWhepBase(): string {
return (config.webrtcWhepBase || resolveGatewayBase()).replace(/\/$/, '') return (config.webrtcWhepBase || resolveGatewayBase()).replace(/\/$/, '')
} }
/** 将平台设备映射为媒体流 ID */ /** 将平台设备映射为媒体流 ID(优先 streamId,与网关/MediaMTX 对齐) */
export function deviceStreamId(device: { id: string; channel?: number; deviceType?: string }): string { export function deviceStreamId(device: {
id: string
channel?: number
deviceType?: string
streamId?: string
}): string {
if (device.streamId && String(device.streamId).trim()) return String(device.streamId).trim()
if (device.deviceType === 'nvr-ch' && device.channel) return `nvr-${device.channel}` if (device.deviceType === 'nvr-ch' && device.channel) return `nvr-${device.channel}`
if (/^(cam|nvr)-/i.test(device.id)) return device.id
return `cam-${device.id}` return `cam-${device.id}`
} }
......
/** /**
* 摄像头 / 门禁设施定义(来源:澳门镜湖医院分子实验室项目技术交底表)。 * 摄像头 / 门禁设施定义(来源:澳门镜湖医院分子实验室项目技术交底表)。
* 不含硬盘录像机、交换机等基础设施。 * 摄像头 id / streamId 与网关 config.json `cameras[].id`(cam-142…)对齐。
*/ */
const PLAN_BL = { lon: 133.54299724844427, lat: 22.19728530602825, h: 51 } const PLAN_BL = { lon: 133.54299724844427, lat: 22.19728530602825, h: 51 }
...@@ -15,11 +15,15 @@ function uv(u: number, v: number): { x: number; y: number; z: number } { ...@@ -15,11 +15,15 @@ function uv(u: number, v: number): { x: number; y: number; z: number } {
} }
export type CameraFacilityDef = { export type CameraFacilityDef = {
/** 稳定设备 id,同时作为默认 WHEP streamId */
id: string id: string
name: string name: string
ip: string ip: string
port: number port: number
/** IPC 抓图通道(通常 1) */
channel: number channel: number
/** NVR 通道号(可选,用于 nvr-{ch} 流) */
nvrChannel?: number
scene: { x: number; y: number; z: number } scene: { x: number; y: number; z: number }
} }
...@@ -31,31 +35,31 @@ export type AccessFacilityDef = { ...@@ -31,31 +35,31 @@ export type AccessFacilityDef = {
scene: { x: number; y: number; z: number } scene: { x: number; y: number; z: number }
} }
/** 交底表 IPC(海康威视 · 8000 · admin) */ /** 交底表 IPC(海康威视 · 8000 · admin);id=网关 cam-* */
export const CAMERA_FACILITY_DEFS: CameraFacilityDef[] = [ export const CAMERA_FACILITY_DEFS: CameraFacilityDef[] = [
{ id: 'cam-recv', name: '收样咨询室', ip: '192.168.10.150', port: 8000, channel: 1, scene: uv(0.88, 0.88) }, { id: 'cam-150', name: '收样咨询室', ip: '192.168.10.150', port: 8000, channel: 1, nvrChannel: 1, scene: uv(0.88, 0.88) },
{ id: 'cam-sample2-front', name: '样本制备区2房前面', ip: '192.168.10.144', port: 8000, channel: 2, scene: uv(0.55, 0.42) }, { id: 'cam-144', name: '样本制备区2房前面', ip: '192.168.10.144', port: 8000, channel: 1, nvrChannel: 2, scene: uv(0.55, 0.42) },
{ id: 'cam-lib2-back', name: '文库制备2房后面', ip: '192.168.10.146', port: 8000, channel: 3, scene: uv(0.25, 0.72) }, { id: 'cam-146', name: '文库制备2房后面', ip: '192.168.10.146', port: 8000, channel: 1, nvrChannel: 3, scene: uv(0.25, 0.72) },
{ id: 'cam-sample2-back', name: '样本制备区2房后面', ip: '192.168.10.152', port: 8000, channel: 4, scene: uv(0.55, 0.72) }, { id: 'cam-152', name: '样本制备区2房后面', ip: '192.168.10.152', port: 8000, channel: 1, nvrChannel: 4, scene: uv(0.55, 0.72) },
{ id: 'cam-lib2-front', name: '文库制备2房前面', ip: '192.168.10.147', port: 8000, channel: 5, scene: uv(0.25, 0.42) }, { id: 'cam-147', name: '文库制备2房前面', ip: '192.168.10.147', port: 8000, channel: 1, nvrChannel: 5, scene: uv(0.25, 0.42) },
{ id: 'cam-front-corridor', name: '前走廊', ip: '192.168.10.153', port: 8000, channel: 6, scene: uv(0.62, 0.12) }, { id: 'cam-153', name: '前走廊', ip: '192.168.10.153', port: 8000, channel: 1, nvrChannel: 6, scene: uv(0.62, 0.12) },
{ id: 'cam-seq-back', name: '测序区后', ip: '192.168.10.148', port: 8000, channel: 7, scene: uv(0.1, 0.72) }, { id: 'cam-148', name: '测序区后', ip: '192.168.10.148', port: 8000, channel: 1, nvrChannel: 7, scene: uv(0.1, 0.72) },
{ id: 'cam-report', name: '生信分析/报告室', ip: '192.168.10.151', port: 8000, channel: 8, scene: uv(0.88, 0.38) }, { id: 'cam-151', name: '生信分析/报告室', ip: '192.168.10.151', port: 8000, channel: 1, nvrChannel: 8, scene: uv(0.88, 0.38) },
{ id: 'cam-sample1-back', name: '样本制备区1房后面', ip: '192.168.10.145', port: 8000, channel: 9, scene: uv(0.7, 0.72) }, { id: 'cam-145', name: '样本制备区1房后面', ip: '192.168.10.145', port: 8000, channel: 1, nvrChannel: 9, scene: uv(0.7, 0.72) },
{ id: 'cam-reagent', name: '试剂准备间', ip: '192.168.10.142', port: 8000, channel: 10, scene: uv(0.85, 0.55) }, { id: 'cam-142', name: '试剂准备间', ip: '192.168.10.142', port: 8000, channel: 1, nvrChannel: 10, scene: uv(0.85, 0.55) },
{ id: 'cam-lib1-front', name: '文库制备区1房前面', ip: '192.168.10.156', port: 8000, channel: 11, scene: uv(0.4, 0.42) }, { id: 'cam-156', name: '文库制备区1房前面', ip: '192.168.10.156', port: 8000, channel: 1, nvrChannel: 11, scene: uv(0.4, 0.42) },
{ id: 'cam-sample1-front', name: '样本制备区1房前面', ip: '192.168.10.154', port: 8000, channel: 12, scene: uv(0.7, 0.42) }, { id: 'cam-154', name: '样本制备区1房前面', ip: '192.168.10.154', port: 8000, channel: 1, nvrChannel: 12, scene: uv(0.7, 0.42) },
{ id: 'cam-seq-front', name: '测序区前', ip: '192.168.10.155', port: 8000, channel: 13, scene: uv(0.1, 0.42) }, { id: 'cam-155', name: '测序区前', ip: '192.168.10.155', port: 8000, channel: 1, nvrChannel: 13, scene: uv(0.1, 0.42) },
{ id: 'cam-lib1-back', name: '文库制备区1房后面', ip: '192.168.10.149', port: 8000, channel: 14, scene: uv(0.4, 0.72) }, { id: 'cam-149', name: '文库制备区1房后面', ip: '192.168.10.149', port: 8000, channel: 1, nvrChannel: 14, scene: uv(0.4, 0.72) },
{ id: 'cam-back-corridor', name: '后走廊', ip: '192.168.10.143', port: 8000, channel: 15, scene: uv(0.35, 0.12) }, { id: 'cam-143', name: '后走廊', ip: '192.168.10.143', port: 8000, channel: 1, nvrChannel: 15, scene: uv(0.35, 0.12) },
] ]
/** 交底表门禁控制器 */ /** 交底表门禁控制器(网关 door-120…;平台设施 id 保留语义名,按 IP/名称匹配状态) */
export const ACCESS_FACILITY_DEFS: AccessFacilityDef[] = [ export const ACCESS_FACILITY_DEFS: AccessFacilityDef[] = [
{ id: 'door-recv', name: '咨询室门禁', ip: '192.168.10.120', port: 8000, scene: uv(0.9, 0.92) }, { id: 'door-120', name: '咨询室门禁', ip: '192.168.10.120', port: 8000, scene: uv(0.9, 0.92) },
{ id: 'door-back-corridor', name: '后走廊门禁', ip: '192.168.10.121', port: 8000, scene: uv(0.28, 0.1) }, { id: 'door-121', name: '后走廊门禁', ip: '192.168.10.121', port: 8000, scene: uv(0.28, 0.1) },
{ id: 'door-report', name: '报告室门禁', ip: '192.168.10.122', port: 8000, scene: uv(0.9, 0.32) }, { id: 'door-122', name: '报告室门禁', ip: '192.168.10.122', port: 8000, scene: uv(0.9, 0.32) },
{ id: 'door-front-corridor', name: '前走廊门禁', ip: '192.168.10.123', port: 8000, scene: uv(0.7, 0.1) }, { id: 'door-123', name: '前走廊门禁', ip: '192.168.10.123', port: 8000, scene: uv(0.7, 0.1) },
] ]
/** NVR / 交换机(写入视频通信配置,不作为大屏设施点) */ /** NVR / 交换机(写入视频通信配置,不作为大屏设施点) */
......
...@@ -13,6 +13,8 @@ import { ...@@ -13,6 +13,8 @@ import {
} from '../devices/store.js' } from '../devices/store.js'
import { buildSnapshotFromDevices } from './fromDevices.js' import { buildSnapshotFromDevices } from './fromDevices.js'
import { fetchAccessSnapshot } from '../access/adapter.js' import { fetchAccessSnapshot } from '../access/adapter.js'
import { fetchCameraSnapshot } from '../video/cameraAdapter.js'
import { applyCameraStatuses } from '../video/store.js'
let snapshot: RealtimeSnapshot = { let snapshot: RealtimeSnapshot = {
ts: 0, ts: 0,
...@@ -196,6 +198,34 @@ async function tick() { ...@@ -196,6 +198,34 @@ async function tick() {
console.warn('[realtime] access snapshot merge failed', e) console.warn('[realtime] access snapshot merge failed', e)
} }
// 海康摄像:网关登录态写入 video 台账 status(大屏/后台列表)
try {
const cams = await fetchCameraSnapshot()
if (cams && cams.cameras.length) {
applyCameraStatuses(cams.cameras)
// 同步传感器快照中的 camera 在线态(若有)
if (Array.isArray(snapshot.sensors)) {
for (const s of snapshot.sensors) {
if (s.category !== 'camera') continue
const hit = cams.cameras.find(
(c) =>
c.id === s.code ||
c.id === s.videoDeviceId ||
c.streamId === s.videoDeviceId ||
c.name === s.name ||
c.streamId === s.code,
)
if (hit) {
s.on = hit.status === 'online'
if (hit.status === 'alarm') s.alarms = ['摄像报警']
}
}
}
}
} catch (e) {
console.warn('[realtime] camera snapshot merge failed', e)
}
await attachPointAlarms() await attachPointAlarms()
publish() publish()
void persistSafe(snapshot) void persistSafe(snapshot)
......
import { Router } from 'express' import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js' import { authRequired, requireRole } from '../auth/middleware.js'
import { deviceStreamId, proxyWhepOffer } from '../gateway/client.js' import { deviceStreamId, proxyWhepOffer } from '../gateway/client.js'
import { fetchCameraPreviewJpeg } from '../video/cameraAdapter.js'
import { import {
createDevice, createDevice,
deleteDevice, deleteDevice,
...@@ -148,6 +149,21 @@ videoRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res ...@@ -148,6 +149,21 @@ 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)
if (!result.ok && result.body.length === 0) {
res.status(502).json({ ok: false, message: result.message || '抓图失败' })
return
}
res.status(result.ok ? 200 : 502)
res.type(result.contentType || 'image/jpeg')
res.send(result.body)
})
/** /**
* WebRTC WHEP:浏览器 POST application/sdp(offer),返回 answer SDP * WebRTC WHEP:浏览器 POST application/sdp(offer),返回 answer SDP
* 媒体面由 Windows 网关 / ZLMediaKit 提供 * 媒体面由 Windows 网关 / ZLMediaKit 提供
......
...@@ -560,9 +560,11 @@ export async function ensureCameraSensorSeed(): Promise<{ created: number; skipp ...@@ -560,9 +560,11 @@ export async function ensureCameraSensorSeed(): Promise<{ created: number; skipp
ip: def.ip, ip: def.ip,
port: def.port, port: def.port,
channel: def.channel, channel: def.channel,
nvrChannel: def.nvrChannel,
protocol: BRIEFING_INFRA.protocol, protocol: BRIEFING_INFRA.protocol,
username: BRIEFING_INFRA.username, username: BRIEFING_INFRA.username,
videoDeviceId: video?.id || null, videoDeviceId: video?.id || def.id,
streamId: def.id,
icon: 'cctv-blue.png', icon: 'cctv-blue.png',
}, },
points: [], points: [],
......
import { resolveGatewayBase } from '../gateway/client.js'
import { config } from '../config.js'
import type { DeviceStatus } from './store.js'
function cameraBase(): string {
return (resolveGatewayBase() || config.accessBridgeUrl || '').replace(/\/$/, '')
}
export type CameraGatewaySnap = {
id: string
name: string
status: DeviceStatus
streamId: string
}
/**
* 摄像快照:走 Windows 网关 `GET /cameras/snapshot`。
* 未配置网关时返回 null。
*/
export async function fetchCameraSnapshot(): Promise<{
cameras: CameraGatewaySnap[]
online: number
total: number
} | null> {
const base = cameraBase()
if (!base) return null
try {
const res = await fetch(`${base}/cameras/snapshot`, {
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`camera snapshot HTTP ${res.status}`)
const raw = (await res.json()) as {
cameras?: Array<{ id?: string; name?: string; status?: string; streamId?: string }>
online?: number
total?: number
}
const cameras: CameraGatewaySnap[] = (Array.isArray(raw.cameras) ? raw.cameras : []).map((c) => ({
id: String(c.id || ''),
name: String(c.name || ''),
status: c.status === 'offline' || c.status === 'alarm' ? c.status : 'online',
streamId: String(c.streamId || c.id || ''),
}))
return {
cameras,
online: Number(raw.online || cameras.filter((c) => c.status === 'online').length),
total: Number(raw.total || cameras.length),
}
} catch (e) {
console.warn('[camera] gateway snapshot unavailable', e)
return null
}
}
/** 代理网关 JPEG 抓图;失败返回 null */
export async function fetchCameraPreviewJpeg(idOrStream: string): Promise<{
ok: boolean
contentType: string
body: Buffer
message?: string
}> {
const base = cameraBase()
if (!base) {
return { ok: false, contentType: '', body: Buffer.alloc(0), message: '未配置 GATEWAY_URL' }
}
try {
const res = await fetch(`${base}/cameras/${encodeURIComponent(idOrStream)}/preview.jpg`, {
signal: AbortSignal.timeout(10000),
})
const ab = await res.arrayBuffer()
const body = Buffer.from(ab)
const contentType = res.headers.get('content-type') || 'image/jpeg'
if (!res.ok) {
return { ok: false, contentType, body, message: `preview HTTP ${res.status}` }
}
return { ok: true, contentType, body }
} catch (e) {
return {
ok: false,
contentType: '',
body: Buffer.alloc(0),
message: e instanceof Error ? e.message : 'preview 请求失败',
}
}
}
...@@ -29,6 +29,8 @@ export interface VideoDevice { ...@@ -29,6 +29,8 @@ export interface VideoDevice {
enabled: boolean enabled: boolean
ip: string ip: string
remark: string remark: string
/** WHEP/MediaMTX 流名;与网关 cameras[].streamId 对齐 */
streamId: string
/** 三维场景坐标;未打点为 null */ /** 三维场景坐标;未打点为 null */
scenePoint: ScenePoint | null scenePoint: ScenePoint | null
createdAt: string createdAt: string
...@@ -85,7 +87,7 @@ function defaultConfig(): VideoCommConfig { ...@@ -85,7 +87,7 @@ function defaultConfig(): VideoCommConfig {
function seedDevices(): VideoDevice[] { function seedDevices(): VideoDevice[] {
const now = new Date().toISOString() const now = new Date().toISOString()
const rows: Omit<VideoDevice, 'id' | 'createdAt' | 'updatedAt'>[] = [ const rows: Omit<VideoDevice, 'id' | 'streamId' | 'createdAt' | 'updatedAt'>[] = [
{ name: '走廊东', channel: 1, location: 'E栋一层走廊', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null }, { name: '走廊东', channel: 1, location: 'E栋一层走廊', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
{ name: 'PCR区', channel: 2, location: '分子诊断 PCR', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null }, { name: 'PCR区', channel: 2, location: '分子诊断 PCR', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
{ name: '样本库', channel: 3, location: '样本库入口', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null }, { name: '样本库', channel: 3, location: '样本库入口', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
...@@ -93,12 +95,16 @@ function seedDevices(): VideoDevice[] { ...@@ -93,12 +95,16 @@ function seedDevices(): VideoDevice[] {
{ name: '门禁口', channel: 5, location: '主入口门禁', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null }, { name: '门禁口', channel: 5, location: '主入口门禁', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
{ name: '冷链库', channel: 6, location: '冷链监测区', deviceType: 'nvr-ch', status: 'alarm', enabled: true, ip: '', remark: '海康通道 · 联动冷柜超温', scenePoint: null }, { name: '冷链库', channel: 6, location: '冷链监测区', deviceType: 'nvr-ch', status: 'alarm', enabled: true, ip: '', remark: '海康通道 · 联动冷柜超温', scenePoint: null },
] ]
return rows.map((r) => ({ return rows.map((r) => {
const id = randomUUID()
return {
...r, ...r,
id: randomUUID(), id,
streamId: r.deviceType === 'nvr-ch' ? `nvr-${r.channel}` : `cam-${id}`,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})) }
})
} }
function normalizeProtocol(p: string | undefined): VideoProtocol { function normalizeProtocol(p: string | undefined): VideoProtocol {
...@@ -122,6 +128,7 @@ function normalizeScenePoint(raw: unknown): ScenePoint | null { ...@@ -122,6 +128,7 @@ function normalizeScenePoint(raw: unknown): ScenePoint | null {
function normalizeDevice(raw: Partial<VideoDevice> & { id: string }): VideoDevice { function normalizeDevice(raw: Partial<VideoDevice> & { id: string }): VideoDevice {
const now = new Date().toISOString() const now = new Date().toISOString()
const streamId = String(raw.streamId || '').trim() || String(raw.id)
return { return {
id: raw.id, id: raw.id,
name: String(raw.name || ''), name: String(raw.name || ''),
...@@ -132,6 +139,7 @@ function normalizeDevice(raw: Partial<VideoDevice> & { id: string }): VideoDevic ...@@ -132,6 +139,7 @@ function normalizeDevice(raw: Partial<VideoDevice> & { id: string }): VideoDevic
enabled: raw.enabled !== false, enabled: raw.enabled !== false,
ip: String(raw.ip || ''), ip: String(raw.ip || ''),
remark: String(raw.remark || ''), remark: String(raw.remark || ''),
streamId,
scenePoint: normalizeScenePoint(raw.scenePoint), scenePoint: normalizeScenePoint(raw.scenePoint),
createdAt: raw.createdAt || now, createdAt: raw.createdAt || now,
updatedAt: raw.updatedAt || now, updatedAt: raw.updatedAt || now,
...@@ -206,15 +214,16 @@ export function ensureBriefingVideoSeed(): { created: number; updated: number; r ...@@ -206,15 +214,16 @@ export function ensureBriefingVideoSeed(): { created: number; updated: number; r
if (allPlaceholder || !briefingIpHit) { if (allPlaceholder || !briefingIpHit) {
data.devices = CAMERA_FACILITY_DEFS.map((c) => ({ data.devices = CAMERA_FACILITY_DEFS.map((c) => ({
id: randomUUID(), id: c.id,
name: c.name, name: c.name,
channel: c.channel, channel: c.nvrChannel || c.channel,
location: c.name, location: c.name,
deviceType: 'ipc' as const, deviceType: 'ipc' as const,
status: 'online' as const, status: 'online' as const,
enabled: true, enabled: true,
ip: c.ip, ip: c.ip,
remark: `交底表 · ${BRIEFING_INFRA.protocol} · ${c.ip}:${c.port}`, remark: `交底表 · ${BRIEFING_INFRA.protocol} · ${c.ip}:${c.port}`,
streamId: c.id,
scenePoint: { scenePoint: {
x: c.scene.x, x: c.scene.x,
y: c.scene.y, y: c.scene.y,
...@@ -231,18 +240,19 @@ export function ensureBriefingVideoSeed(): { created: number; updated: number; r ...@@ -231,18 +240,19 @@ export function ensureBriefingVideoSeed(): { created: number; updated: number; r
let created = 0 let created = 0
let updated = 0 let updated = 0
for (const c of CAMERA_FACILITY_DEFS) { for (const c of CAMERA_FACILITY_DEFS) {
const hit = data.devices.find((d) => d.ip === c.ip || d.name === c.name) const hit = data.devices.find((d) => d.id === c.id || d.ip === c.ip || d.name === c.name)
if (!hit) { if (!hit) {
data.devices.push({ data.devices.push({
id: randomUUID(), id: c.id,
name: c.name, name: c.name,
channel: c.channel, channel: c.nvrChannel || c.channel,
location: c.name, location: c.name,
deviceType: 'ipc', deviceType: 'ipc',
status: 'online', status: 'online',
enabled: true, enabled: true,
ip: c.ip, ip: c.ip,
remark: `交底表 · ${BRIEFING_INFRA.protocol} · ${c.ip}:${c.port}`, remark: `交底表 · ${BRIEFING_INFRA.protocol} · ${c.ip}:${c.port}`,
streamId: c.id,
scenePoint: { scenePoint: {
x: c.scene.x, x: c.scene.x,
y: c.scene.y, y: c.scene.y,
...@@ -256,6 +266,15 @@ export function ensureBriefingVideoSeed(): { created: number; updated: number; r ...@@ -256,6 +266,15 @@ export function ensureBriefingVideoSeed(): { created: number; updated: number; r
continue continue
} }
let dirty = false let dirty = false
if (hit.id !== c.id && hit.ip === c.ip) {
// 旧 UUID 台账:按 IP 对齐到稳定 cam-* id
hit.id = c.id
dirty = true
}
if (!hit.streamId || hit.streamId !== c.id) {
hit.streamId = c.id
dirty = true
}
if (!hit.ip) { if (!hit.ip) {
hit.ip = c.ip hit.ip = c.ip
dirty = true dirty = true
...@@ -371,8 +390,9 @@ export function createDevice(input: { ...@@ -371,8 +390,9 @@ export function createDevice(input: {
throw new Error(`通道 ${input.channel} 已存在`) throw new Error(`通道 ${input.channel} 已存在`)
} }
const now = new Date().toISOString() const now = new Date().toISOString()
const id = randomUUID()
const device: VideoDevice = { const device: VideoDevice = {
id: randomUUID(), id,
name: input.name.trim(), name: input.name.trim(),
channel: Number(input.channel), channel: Number(input.channel),
location: input.location.trim(), location: input.location.trim(),
...@@ -381,6 +401,7 @@ export function createDevice(input: { ...@@ -381,6 +401,7 @@ export function createDevice(input: {
enabled: input.enabled !== false, enabled: input.enabled !== false,
ip: (input.ip || '').trim(), ip: (input.ip || '').trim(),
remark: (input.remark || '').trim(), remark: (input.remark || '').trim(),
streamId: input.deviceType === 'nvr-ch' ? `nvr-${Number(input.channel)}` : `cam-${id}`,
scenePoint: normalizeScenePoint(input.scenePoint), scenePoint: normalizeScenePoint(input.scenePoint),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
...@@ -390,6 +411,37 @@ export function createDevice(input: { ...@@ -390,6 +411,37 @@ export function createDevice(input: {
return device return device
} }
/** 用网关 ACS 摄像快照覆盖本地 status(按 id / streamId / IP 名) */
export function applyCameraStatuses(
snaps: Array<{ id: string; name?: string; status: DeviceStatus; streamId?: string }>,
): number {
if (!snaps.length) return 0
const data = load()
let n = 0
const now = new Date().toISOString()
for (const s of snaps) {
const hit = data.devices.find(
(d) =>
d.id === s.id ||
d.streamId === s.id ||
d.streamId === s.streamId ||
(s.name && d.name === s.name),
)
if (!hit) continue
if (hit.status !== s.status) {
hit.status = s.status
hit.updatedAt = now
n += 1
}
if (s.streamId && hit.streamId !== s.streamId) {
hit.streamId = s.streamId
hit.updatedAt = now
}
}
if (n > 0) save(data)
return n
}
export function updateDevice( export function updateDevice(
id: string, id: string,
patch: Partial<{ patch: Partial<{
......
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import WebRtcPlayer from '@/components/video/WebRtcPlayer.vue'
const { t } = useI18n() const { t } = useI18n()
...@@ -12,6 +13,8 @@ const props = withDefaults( ...@@ -12,6 +13,8 @@ const props = withDefaults(
left: number left: number
top: number top: number
src?: string src?: string
/** 绑定视频设备 id 时走 WebRTC;否则回退 src */
deviceId?: string | null
/** video=监控预览;values=读数;switch=照明开/关 */ /** video=监控预览;values=读数;switch=照明开/关 */
kind?: 'video' | 'values' | 'switch' kind?: 'video' | 'values' | 'switch'
lines?: { label: string; value: string }[] lines?: { label: string; value: string }[]
...@@ -22,6 +25,7 @@ const props = withDefaults( ...@@ -22,6 +25,7 @@ const props = withDefaults(
}>(), }>(),
{ {
src: '/cams/lab-seq.mp4', src: '/cams/lab-seq.mp4',
deviceId: null,
kind: 'video', kind: 'video',
lines: () => [], lines: () => [],
switchOn: null, switchOn: null,
...@@ -190,8 +194,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -190,8 +194,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
}" }"
:style="{ height: `${videoH}px` }" :style="{ height: `${videoH}px` }"
> >
<div v-if="kind === 'video'" class="near-video-wrap">
<WebRtcPlayer v-if="deviceId" :key="deviceId" :device-id="deviceId" />
<video <video
v-if="kind === 'video'" v-else
class="near-video" class="near-video"
:src="src" :src="src"
autoplay autoplay
...@@ -200,6 +206,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -200,6 +206,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
playsinline playsinline
controls controls
/> />
</div>
<ul v-else-if="kind === 'values'" class="near-values"> <ul v-else-if="kind === 'values'" class="near-values">
<li v-for="row in lines" :key="row.label"> <li v-for="row in lines" :key="row.label">
<span>{{ row.label }}</span> <span>{{ row.label }}</span>
...@@ -425,6 +432,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -425,6 +432,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
text-align: right; text-align: right;
word-break: break-all; word-break: break-all;
} }
.near-video-wrap {
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
}
.near-video-wrap :deep(video),
.near-video { .near-video {
display: block; display: block;
width: 100%; width: 100%;
......
...@@ -85,6 +85,7 @@ const liveSwitchOn = computed(() => { ...@@ -85,6 +85,7 @@ const liveSwitchOn = computed(() => {
}) })
const nearSwitching = ref(false) const nearSwitching = ref(false)
const nearSwitchError = ref('') const nearSwitchError = ref('')
const nearDeviceId = ref<string | null>(null)
const nearLeft = ref(120) const nearLeft = ref(120)
const nearTop = ref(120) const nearTop = ref(120)
/** 关闭后短时忽略 Marker 点击,防止卸层点击穿透再次打开 */ /** 关闭后短时忽略 Marker 点击,防止卸层点击穿透再次打开 */
...@@ -318,6 +319,7 @@ function openNearDialog( ...@@ -318,6 +319,7 @@ function openNearDialog(
function closeNearDialog() { function closeNearDialog() {
nearOpen.value = false nearOpen.value = false
nearSensorId.value = null nearSensorId.value = null
nearDeviceId.value = null
nearSwitchError.value = '' nearSwitchError.value = ''
nearSwitching.value = false nearSwitching.value = false
nearCloseGuardUntil = Date.now() + 400 nearCloseGuardUntil = Date.now() + 400
...@@ -537,6 +539,7 @@ function onMarkerClick(payload: { ...@@ -537,6 +539,7 @@ function onMarkerClick(payload: {
nearKind.value = 'video' nearKind.value = 'video'
nearLines.value = [] nearLines.value = []
nearSensorId.value = null nearSensorId.value = null
nearDeviceId.value = cam?.id || null
openNearDialog(resolveNearTitle(cam, marker, markerId), markerId || marker.id, raw, { openNearDialog(resolveNearTitle(cam, marker, markerId), markerId || marker.id, raw, {
center: true, center: true,
}) })
...@@ -900,6 +903,7 @@ onBeforeUnmount(() => { ...@@ -900,6 +903,7 @@ onBeforeUnmount(() => {
:switch-error="nearSwitchError" :switch-error="nearSwitchError"
:left="nearLeft" :left="nearLeft"
:top="nearTop" :top="nearTop"
:device-id="nearDeviceId"
src="/cams/lab-seq.mp4" src="/cams/lab-seq.mp4"
@close="closeNearDialog" @close="closeNearDialog"
@control="onLightControl" @control="onLightControl"
......
...@@ -22,6 +22,8 @@ export interface VideoDevice { ...@@ -22,6 +22,8 @@ export interface VideoDevice {
enabled: boolean enabled: boolean
ip: string ip: string
remark: string remark: string
/** WHEP/MediaMTX 流名;与网关对齐 */
streamId?: string
scenePoint?: ScenePoint | null scenePoint?: ScenePoint | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
......
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