Commit 92bab587 by James Yuan

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 4a3440d1
......@@ -3,6 +3,7 @@
"port": 38080,
"platformUrl": "http://127.0.0.1:3001",
"pollMs": 2000,
"mediaWhepBase": "http://127.0.0.1:8889",
"modbusAddressBase": 1,
"modbusAddressOffset": -1,
"modbusWriteAddressBase": 1,
......
......@@ -22,7 +22,7 @@ Windows 协议网关已接入 **ACS 登录 / 布防回调 / 远程开门 / 事
平台实时循环合并 `/access/snapshot`;`POST /api/realtime/access/open` 转发网关开门。
摄像登录/抓图/WebRTC 仍待后续;`config.json` 已写入交底表全部摄像头与 NVR,供管理台与后续预览使用。
摄像已接入 **登录 / JPEG 抓图 / 平台快照**;WebRTC 经 `mediaWhepBase` 反代 MediaMTX/ZLM(需现场把 RTSP 注册为 `cam-*` 流名)。
## 门禁(4)
......
......@@ -125,8 +125,15 @@ Body 与 `GET /snapshot` 相同。平台写入 `device_points.last_raw / last_en
| GET | `/doors` | 门状态列表 |
| POST | `/doors/:id/open` | 远程开门(body 可选 `mode`) |
| GET | `/events` | 最近通行事件 |
| GET | `/access/snapshot` | 平台门禁快照 |
| 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
## 现场采集机(Windows 网关)
1. 网关或旁路媒体服务提供 WHEP;或本机仍用 Docker MediaMTX,网关只负责 Modbus/门禁
2. 摄像头 RTSP 拉进 MediaMTX 流名与 `cam-{deviceId}` / `nvr-{channel}` 对齐
1. `config.json` 配置 `mediaWhepBase`(如 `http://127.0.0.1:8889`);网关 `POST /webrtc/whep/:streamId` 反代到 MediaMTX
2. 摄像头 RTSP 拉进 MediaMTX,流名与 `cameras[].streamId`(默认 `cam-142`…)对齐;网关 `GET /cameras` 可查看 `rtspUrl`
3. 浏览器需能访问 ICE/UDP(`8189`)
4. JPEG 降级:`GET /cameras/:id/preview.jpg`(HCNetSDK 抓图,需 MSVC 产物)
......@@ -32,6 +32,7 @@ endif()
add_executable(JhLabGateway
src/main.cpp
src/hik_acs.cpp
src/hik_cam.cpp
)
target_include_directories(JhLabGateway PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/third_party)
......
......@@ -6,7 +6,7 @@
- Modbus TCP 多主机采集 / 写(`.102` / `.103` / `.104` / `.105`)
- 海康 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)。
......@@ -20,6 +20,7 @@ win-protocol/
config.json
src/main.cpp
src/hik_acs.cpp / .hpp # 门禁 ACS
src/hik_cam.cpp / .hpp # 摄像登录 / 抓图
src/modbus_tcp.hpp
third_party/httplib.h
```
......@@ -68,4 +69,4 @@ JhLabGateway.exe config.json
2. 后台「设备管理」导入点位 →「下发网关」
3. `GET /health`、`GET /snapshot` 验证
Modbus 已实采;门禁 ACS 需 MSVC 产物。摄像预览 / WebRTC 媒体面仍待接入。
Modbus 已实采;门禁 ACS / 摄像抓图需 MSVC 产物。WebRTC:采集机部署 MediaMTX,`config.json` 配 `mediaWhepBase`,流名与 `cameras[].streamId`(默认 `cam-*`)对齐。
......@@ -3,6 +3,7 @@
"port": 38080,
"platformUrl": "http://192.168.32.122:3001",
"pollMs": 2000,
"mediaWhepBase": "http://127.0.0.1:8889",
"modbusAddressBase": 1,
"modbusAddressOffset": -1,
"modbusWriteAddressBase": 1,
......
#include "hik_cam.hpp"
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <mutex>
#include <sstream>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#endif
#ifdef JHLAB_HAS_HCNETSDK
#include "HCNetSDK.h"
#endif
namespace hik_cam {
namespace {
std::string jsonEscape(const std::string& s) {
std::ostringstream o;
for (unsigned char c : s) {
switch (c) {
case '"':
o << "\\\"";
break;
case '\\':
o << "\\\\";
break;
case '\n':
o << "\\n";
break;
case '\r':
o << "\\r";
break;
case '\t':
o << "\\t";
break;
default:
if (c < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", c);
o << buf;
} else {
o << static_cast<char>(c);
}
}
}
return o.str();
}
std::string urlEncodeUserinfo(const std::string& s) {
std::ostringstream o;
for (unsigned char c : s) {
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' ||
c == '_' || c == '.' || c == '~') {
o << static_cast<char>(c);
} else {
char buf[8];
std::snprintf(buf, sizeof(buf), "%%%02X", c);
o << buf;
}
}
return o.str();
}
struct CamRt {
DeviceCfg cfg;
long userId = -1;
bool loggedIn = false;
std::string status = "offline"; // online | offline | alarm
std::string lastError;
};
std::mutex gMu;
std::vector<CamRt> gCams;
CamRt gNvr;
bool gInited = false;
std::string effectiveStreamId(const DeviceCfg& cfg) {
return cfg.streamId.empty() ? cfg.id : cfg.streamId;
}
CamRt* findByIdLocked(const std::string& id) {
for (auto& c : gCams) {
if (c.cfg.id == id) return &c;
}
return nullptr;
}
CamRt* findByStreamLocked(const std::string& streamId) {
for (auto& c : gCams) {
if (effectiveStreamId(c.cfg) == streamId) return &c;
}
if (!gNvr.cfg.id.empty() && effectiveStreamId(gNvr.cfg) == streamId) return &gNvr;
return nullptr;
}
CamRt* findAnyLocked(const std::string& idOrStream) {
if (auto* c = findByIdLocked(idOrStream)) return c;
return findByStreamLocked(idOrStream);
}
#ifdef JHLAB_HAS_HCNETSDK
std::string lastSdkErr() {
return "err=" + std::to_string(NET_DVR_GetLastError());
}
void logoutOneLocked(CamRt& c) {
if (c.userId >= 0) {
NET_DVR_Logout(c.userId);
c.userId = -1;
}
c.loggedIn = false;
c.status = "offline";
}
bool loginOneLocked(CamRt& c) {
logoutOneLocked(c);
if (!c.cfg.enabled || c.cfg.host.empty()) {
c.lastError = "disabled or empty host";
return false;
}
NET_DVR_USER_LOGIN_INFO login{};
login.bUseAsynLogin = 0;
login.wPort = static_cast<WORD>(c.cfg.port > 0 ? c.cfg.port : 8000);
std::strncpy(login.sDeviceAddress, c.cfg.host.c_str(), sizeof(login.sDeviceAddress) - 1);
std::strncpy(login.sUserName, c.cfg.username.c_str(), sizeof(login.sUserName) - 1);
std::strncpy(login.sPassword, c.cfg.password.c_str(), sizeof(login.sPassword) - 1);
NET_DVR_DEVICEINFO_V40 dev{};
const LONG uid = NET_DVR_Login_V40(&login, &dev);
if (uid < 0) {
c.lastError = "login failed: " + lastSdkErr();
c.loggedIn = false;
c.status = "offline";
std::cerr << "[hik-cam] login fail " << c.cfg.id << " " << c.cfg.host << " " << c.lastError
<< "\n";
return false;
}
c.userId = uid;
c.loggedIn = true;
c.status = "online";
c.lastError.clear();
std::cout << "[hik-cam] login ok " << c.cfg.id << " " << c.cfg.host << ":" << c.cfg.port
<< " userId=" << uid << " stream=" << effectiveStreamId(c.cfg) << "\n";
return true;
}
bool captureOneLocked(CamRt& c, std::vector<uint8_t>* jpegOut, std::string* errMsg) {
if (!c.loggedIn || c.userId < 0) {
if (errMsg) *errMsg = "摄像未登录: " + (c.lastError.empty() ? c.cfg.id : c.lastError);
return false;
}
NET_DVR_JPEGPARA para{};
para.wPicSize = 0xff; // 使用当前码流分辨率
para.wPicQuality = 2; // 较好
constexpr DWORD kBuf = 4 * 1024 * 1024;
std::vector<uint8_t> buf(kBuf);
DWORD got = 0;
const LONG ch = c.cfg.channel > 0 ? c.cfg.channel : 1;
if (!NET_DVR_CaptureJPEGPicture_NEW(c.userId, ch, &para, reinterpret_cast<char*>(buf.data()),
kBuf, &got) ||
got == 0) {
if (errMsg) *errMsg = "抓图失败: " + lastSdkErr();
return false;
}
buf.resize(got);
if (jpegOut) *jpegOut = std::move(buf);
return true;
}
#endif
void applySyncList(const std::vector<DeviceCfg>& cameras) {
#ifdef JHLAB_HAS_HCNETSDK
for (auto it = gCams.begin(); it != gCams.end();) {
const bool keep = std::any_of(cameras.begin(), cameras.end(),
[&](const DeviceCfg& cfg) { return cfg.id == it->cfg.id; });
if (!keep) {
logoutOneLocked(*it);
it = gCams.erase(it);
} else {
++it;
}
}
for (const auto& cfg : cameras) {
CamRt* existing = findByIdLocked(cfg.id);
if (!existing) {
CamRt c;
c.cfg = cfg;
gCams.push_back(c);
existing = &gCams.back();
} else {
const bool changed = existing->cfg.host != cfg.host || existing->cfg.port != cfg.port ||
existing->cfg.username != cfg.username ||
existing->cfg.password != cfg.password ||
existing->cfg.enabled != cfg.enabled ||
existing->cfg.channel != cfg.channel;
existing->cfg = cfg;
if (changed && existing->loggedIn) logoutOneLocked(*existing);
}
if (cfg.enabled) {
if (!existing->loggedIn) loginOneLocked(*existing);
} else {
logoutOneLocked(*existing);
existing->lastError = "disabled";
}
}
#else
gCams.clear();
for (const auto& cfg : cameras) {
CamRt c;
c.cfg = cfg;
c.loggedIn = false;
c.status = "offline";
c.lastError = cfg.enabled ? "HCNetSDK not linked (need MSVC build)" : "disabled";
gCams.push_back(c);
}
#endif
}
} // namespace
std::string defaultRtspUrl(const DeviceCfg& cfg) {
if (!cfg.rtspUrl.empty()) return cfg.rtspUrl;
if (cfg.host.empty()) return {};
const int ch = cfg.channel > 0 ? cfg.channel : 1;
const int stream = cfg.streamType == 0 ? 1 : 2; // 1=主 2=子
const int pathCh = ch * 100 + stream;
return "rtsp://" + urlEncodeUserinfo(cfg.username) + ":" + urlEncodeUserinfo(cfg.password) +
"@" + cfg.host + ":554/Streaming/Channels/" + std::to_string(pathCh);
}
void init() {
std::lock_guard<std::mutex> lock(gMu);
if (gInited) return;
gInited = true;
#ifdef JHLAB_HAS_HCNETSDK
std::cout << "[hik-cam] ready (SDK lifecycle owned by hik_acs)\n";
#else
std::cout << "[hik-cam] stub mode (no HCNetSDK)\n";
#endif
}
void shutdown() {
std::lock_guard<std::mutex> lock(gMu);
#ifdef JHLAB_HAS_HCNETSDK
for (auto& c : gCams) logoutOneLocked(c);
logoutOneLocked(gNvr);
#endif
gCams.clear();
gNvr = CamRt{};
gInited = false;
}
void syncDevices(const std::vector<DeviceCfg>& cameras) {
init();
std::lock_guard<std::mutex> lock(gMu);
applySyncList(cameras);
}
void syncNvr(const DeviceCfg& nvr) {
init();
std::lock_guard<std::mutex> lock(gMu);
#ifdef JHLAB_HAS_HCNETSDK
const bool changed = gNvr.cfg.host != nvr.host || gNvr.cfg.port != nvr.port ||
gNvr.cfg.username != nvr.username || gNvr.cfg.password != nvr.password ||
gNvr.cfg.enabled != nvr.enabled || gNvr.cfg.id != nvr.id;
gNvr.cfg = nvr;
if (changed && gNvr.loggedIn) logoutOneLocked(gNvr);
if (nvr.enabled && !nvr.host.empty()) {
if (!gNvr.loggedIn) loginOneLocked(gNvr);
} else {
logoutOneLocked(gNvr);
gNvr.lastError = nvr.enabled ? "empty host" : "disabled";
}
#else
gNvr.cfg = nvr;
gNvr.loggedIn = false;
gNvr.status = "offline";
gNvr.lastError = nvr.enabled ? "HCNetSDK not linked (need MSVC build)" : "disabled";
#endif
}
int loggedInCount() {
std::lock_guard<std::mutex> lock(gMu);
int n = 0;
for (const auto& c : gCams)
if (c.loggedIn) ++n;
if (gNvr.loggedIn) ++n;
return n;
}
bool sdkLinked() {
#ifdef JHLAB_HAS_HCNETSDK
return true;
#else
return false;
#endif
}
std::string healthFieldsJson() {
std::ostringstream o;
o << "\"loggedIn\":" << loggedInCount() << ",\"sdk\":" << (sdkLinked() ? "true" : "false")
<< ",\"note\":\""
<< jsonEscape(sdkLinked() ? "HCNetSDK camera active" : "HCNetSDK pending (MSVC build required)")
<< "\"";
return o.str();
}
std::string camerasJsonArray() {
std::lock_guard<std::mutex> lock(gMu);
std::ostringstream o;
o << '[';
for (size_t i = 0; i < gCams.size(); ++i) {
if (i) o << ',';
const auto& c = gCams[i];
const auto sid = effectiveStreamId(c.cfg);
const auto rtsp = defaultRtspUrl(c.cfg);
o << "{\"id\":\"" << jsonEscape(c.cfg.id) << "\",\"name\":\"" << jsonEscape(c.cfg.name)
<< "\",\"host\":\"" << jsonEscape(c.cfg.host) << "\",\"port\":" << c.cfg.port
<< ",\"channel\":" << c.cfg.channel << ",\"streamId\":\"" << jsonEscape(sid)
<< "\",\"rtspUrl\":\"" << jsonEscape(rtsp) << "\",\"enabled\":"
<< (c.cfg.enabled ? "true" : "false") << ",\"loggedIn\":" << (c.loggedIn ? "true" : "false")
<< ",\"status\":\"" << jsonEscape(c.status) << "\",\"lastError\":\""
<< jsonEscape(c.lastError) << "\"}";
}
o << ']';
return o.str();
}
std::string cameraSnapshotJson() {
std::lock_guard<std::mutex> lock(gMu);
int online = 0;
std::ostringstream o;
o << "{\"cameras\":[";
for (size_t i = 0; i < gCams.size(); ++i) {
if (i) o << ',';
const auto& c = gCams[i];
if (c.status == "online") ++online;
o << "{\"id\":\"" << jsonEscape(c.cfg.id) << "\",\"name\":\"" << jsonEscape(c.cfg.name)
<< "\",\"status\":\"" << jsonEscape(c.status) << "\",\"streamId\":\""
<< jsonEscape(effectiveStreamId(c.cfg)) << "\"}";
}
o << "],\"online\":" << online << ",\"total\":" << gCams.size() << "}";
return o.str();
}
bool captureJpeg(const std::string& idOrStream, std::vector<uint8_t>* jpegOut, std::string* errMsg) {
#ifdef JHLAB_HAS_HCNETSDK
std::lock_guard<std::mutex> lock(gMu);
CamRt* c = findAnyLocked(idOrStream);
if (!c) {
if (errMsg) *errMsg = "摄像不存在: " + idOrStream;
return false;
}
return captureOneLocked(*c, jpegOut, errMsg);
#else
(void)idOrStream;
(void)jpegOut;
if (errMsg) *errMsg = "当前网关未链接 HCNetSDK(请用 VS2022 MSVC 编译)";
return false;
#endif
}
std::string resolveIdByStream(const std::string& streamId) {
std::lock_guard<std::mutex> lock(gMu);
CamRt* c = findByStreamLocked(streamId);
return c ? c->cfg.id : std::string{};
}
} // namespace hik_cam
#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
......@@ -6,6 +6,7 @@
// Winsock2 必须先于 windows.h / httplib
#include "modbus_tcp.hpp"
#include "hik_acs.hpp"
#include "hik_cam.hpp"
#include "httplib.h"
#include <algorithm>
......@@ -83,6 +84,9 @@ struct HikDevice {
std::string username = "admin";
std::string password;
int channel = 1;
std::string streamId; // WHEP/MediaMTX 流名;空则用 id
std::string rtspUrl; // 可选覆盖
int streamType = 1; // 0=主码流 1=子码流
bool enabled = true;
};
......@@ -105,6 +109,8 @@ struct AppState {
int modbusWriteAddressOffset = -1;
std::string adminUser = "admin";
std::string adminPass = "Admin@123";
/** MediaMTX / ZLM WHEP 基址,如 http://127.0.0.1:8889;空则 /webrtc/whep 返回 503 */
std::string mediaWhepBase;
std::vector<ModbusHost> modbusHosts;
HikDevice nvr;
std::vector<HikDevice> cameras;
......@@ -114,7 +120,7 @@ struct AppState {
std::map<std::string, PointValue> values; // key = point id
std::map<std::string, bool> hostOk;
std::map<std::string, int> hostLatencyMs;
std::string version = "0.4.0-hcnet-acs";
std::string version = "0.5.0-hcnet-cam";
std::map<std::string, Session> sessions;
bool pollRunning = false;
};
......@@ -141,6 +147,8 @@ void saveConfigLocked();
void pushTelemetryToPlatform();
void pullPointsFromPlatform();
void syncDoorsToHik();
void syncCamerasToHik();
hik_cam::DeviceCfg toCamCfg(const HikDevice& d);
std::string jsonEscape(const std::string& s) {
std::ostringstream o;
......@@ -268,17 +276,23 @@ HikDevice parseHik(const std::string& obj) {
if (d.username.empty()) d.username = "admin";
d.password = extractString(obj, "password");
d.channel = extractInt(obj, "channel", 1);
d.streamId = extractString(obj, "streamId");
d.rtspUrl = extractString(obj, "rtspUrl");
d.streamType = extractInt(obj, "streamType", 1);
d.enabled = extractBool(obj, "enabled", true);
return d;
}
std::string hikToJson(const HikDevice& d) {
const std::string sid = d.streamId.empty() ? d.id : d.streamId;
std::ostringstream o;
o << "{\"id\":\"" << jsonEscape(d.id) << "\",\"name\":\"" << jsonEscape(d.name)
<< "\",\"host\":\"" << jsonEscape(d.host) << "\",\"port\":" << d.port
<< ",\"username\":\"" << jsonEscape(d.username) << "\",\"password\":\""
<< jsonEscape(d.password) << "\",\"channel\":" << d.channel
<< ",\"enabled\":" << (d.enabled ? "true" : "false") << "}";
<< jsonEscape(d.password) << "\",\"channel\":" << d.channel << ",\"streamId\":\""
<< jsonEscape(sid) << "\",\"rtspUrl\":\"" << jsonEscape(d.rtspUrl)
<< "\",\"streamType\":" << d.streamType << ",\"enabled\":" << (d.enabled ? "true" : "false")
<< "}";
return o.str();
}
......@@ -294,6 +308,7 @@ std::string configToJsonLocked() {
std::ostringstream o;
o << "{\"ok\":true,\"listen\":\"" << jsonEscape(g.listenHost) << "\",\"port\":" << g.listenPort
<< ",\"platformUrl\":\"" << jsonEscape(g.platformUrl) << "\",\"pollMs\":" << g.pollMs
<< ",\"mediaWhepBase\":\"" << jsonEscape(g.mediaWhepBase) << "\""
<< ",\"modbusAddressBase\":" << modbusAddressBaseLocked()
<< ",\"modbusAddressOffset\":" << g.modbusAddressOffset
<< ",\"modbusWriteAddressBase\":" << modbusWriteAddressBaseLocked()
......@@ -324,6 +339,7 @@ void saveConfigLocked() {
std::ostringstream o;
o << "{\n \"listen\": \"" << jsonEscape(g.listenHost) << "\",\n \"port\": " << g.listenPort
<< ",\n \"platformUrl\": \"" << jsonEscape(g.platformUrl) << "\",\n \"pollMs\": " << g.pollMs
<< ",\n \"mediaWhepBase\": \"" << jsonEscape(g.mediaWhepBase) << "\""
<< ",\n \"modbusAddressBase\": " << modbusAddressBaseLocked()
<< ",\n \"modbusAddressOffset\": " << g.modbusAddressOffset
<< ",\n \"modbusWriteAddressBase\": " << modbusWriteAddressBaseLocked()
......@@ -382,6 +398,10 @@ void loadConfigFile(const std::string& path) {
if (!listen.empty()) g.listenHost = listen;
auto platform = extractString(body, "platformUrl");
if (!platform.empty()) g.platformUrl = platform;
// 允许显式清空:键存在即覆盖(含空字符串)
if (body.find("\"mediaWhepBase\"") != std::string::npos) {
g.mediaWhepBase = extractString(body, "mediaWhepBase");
}
auto user = extractString(body, "adminUsername");
if (!user.empty()) g.adminUser = user;
auto pass = extractString(body, "adminPassword");
......@@ -911,10 +931,27 @@ std::string buildHealthJson() {
<< ",\"ok\":" << (h.enabled ? (ok ? "true" : "false") : "null")
<< ",\"latencyMs\":" << lat << "}";
}
o << "],\"hikvision\":{" << hik_acs::healthFieldsJson() << "}}";
o << "],\"hikvision\":{" << hik_acs::healthFieldsJson()
<< "},\"cameras\":{" << hik_cam::healthFieldsJson() << "}}";
return o.str();
}
hik_cam::DeviceCfg toCamCfg(const HikDevice& d) {
hik_cam::DeviceCfg c;
c.id = d.id;
c.name = d.name;
c.host = d.host;
c.port = d.port;
c.username = d.username;
c.password = d.password;
c.channel = d.channel > 0 ? d.channel : 1;
c.streamId = d.streamId.empty() ? d.id : d.streamId;
c.rtspUrl = d.rtspUrl;
c.streamType = d.streamType;
c.enabled = d.enabled;
return c;
}
void syncDoorsToHik() {
std::vector<hik_acs::DeviceCfg> list;
{
......@@ -936,6 +973,19 @@ void syncDoorsToHik() {
hik_acs::syncDevices(list);
}
void syncCamerasToHik() {
std::vector<hik_cam::DeviceCfg> list;
HikDevice nvr;
{
std::lock_guard<std::mutex> lock(g.mu);
list.reserve(g.cameras.size());
for (const auto& d : g.cameras) list.push_back(toCamCfg(d));
nvr = g.nvr;
}
hik_cam::syncDevices(list);
if (!nvr.id.empty() || !nvr.host.empty()) hik_cam::syncNvr(toCamCfg(nvr));
}
std::string buildAdminHealthJson() {
std::lock_guard<std::mutex> lock(g.mu);
std::ostringstream o;
......@@ -1013,11 +1063,39 @@ std::string previewSvg(const HikDevice& cam) {
"font-family='Segoe UI,Microsoft YaHei,sans-serif'>"
<< jsonEscape(cam.host) << ":" << cam.port << " ch" << cam.channel << "</text>"
<< "<text x='50%' y='68%' text-anchor='middle' fill='#f5a524' font-size='14' "
"font-family='Segoe UI,Microsoft YaHei,sans-serif'>HCNetSDK preview pending</text>"
"font-family='Segoe UI,Microsoft YaHei,sans-serif'>JPEG preview unavailable</text>"
<< "</svg>";
return o.str();
}
/** 抓图:成功返回 JPEG bytes;失败返回 SVG 占位 */
void respondCameraPreview(const std::string& id, httplib::Response& res) {
std::vector<uint8_t> jpeg;
std::string err;
if (hik_cam::captureJpeg(id, &jpeg, &err) && !jpeg.empty()) {
res.set_content(std::string(reinterpret_cast<const char*>(jpeg.data()), jpeg.size()),
"image/jpeg");
return;
}
HikDevice cam;
{
std::lock_guard<std::mutex> lock(g.mu);
for (const auto& c : g.cameras) {
if (c.id == id || (!c.streamId.empty() && c.streamId == id) ||
(c.streamId.empty() && c.id == id)) {
cam = c;
break;
}
}
}
if (cam.id.empty()) {
cam.id = id;
cam.name = id;
cam.host = err.empty() ? "unknown" : err;
}
res.set_content(previewSvg(cam), "image/svg+xml; charset=utf-8");
}
std::string exeDir() {
#ifdef _WIN32
char buf[MAX_PATH] = {0};
......@@ -1048,9 +1126,14 @@ int main(int argc, char** argv) {
}
}
std::atexit([]() { hik_acs::shutdown(); });
std::atexit([]() {
hik_cam::shutdown();
hik_acs::shutdown();
});
hik_acs::init();
hik_cam::init();
syncDoorsToHik();
syncCamerasToHik();
httplib::Server svr;
......@@ -1194,18 +1277,43 @@ int main(int argc, char** argv) {
/**
* WebRTC WHEP(Egress):浏览器 POST application/sdp offer
* 当前返回 503 说明;接入 ZLMediaKit / 内嵌 WebRTC 后在此回 answer SDP
* 有 mediaWhepBase 时反代到 MediaMTX/ZLM:POST {base}/{streamId}/whep
*/
svr.Post(R"(/webrtc/whep/([^/]+))", [](const httplib::Request& req, httplib::Response& res) {
const std::string streamId = req.matches[1];
std::cout << "[gateway] WHEP offer streamId=" << streamId
<< " sdpBytes=" << req.body.size() << "\n";
std::string whepBase;
{
std::lock_guard<std::mutex> lock(g.mu);
whepBase = g.mediaWhepBase;
}
std::cout << "[gateway] WHEP offer streamId=" << streamId << " sdpBytes=" << req.body.size()
<< " media=" << (whepBase.empty() ? "(unset)" : whepBase) << "\n";
if (whepBase.empty()) {
res.status = 503;
res.set_content(
"{\"ok\":false,\"message\":\"WebRTC 媒体面未就绪:请在采集机部署 ZLMediaKit(或网关内嵌 "
"WebRTC)并配置 RTSP 拉流。streamId=" +
"{\"ok\":false,\"message\":\"未配置 mediaWhepBase:请在 config.json 填写 MediaMTX/ZLM "
"WHEP 基址(如 http://127.0.0.1:8889),并将 RTSP 源注册为流名 " +
streamId + "\",\"streamId\":\"" + jsonEscape(streamId) + "\"}",
"application/json; charset=utf-8");
return;
}
while (!whepBase.empty() && whepBase.back() == '/') whepBase.pop_back();
httplib::Client cli(whepBase);
cli.set_connection_timeout(5, 0);
cli.set_read_timeout(15, 0);
const std::string path = "/" + streamId + "/whep";
auto up = cli.Post(path.c_str(), req.body, "application/sdp");
if (!up) {
res.status = 502;
res.set_content("{\"ok\":false,\"message\":\"媒体面无响应: " + jsonEscape(whepBase + path) +
"\",\"streamId\":\"" + jsonEscape(streamId) + "\"}",
"application/json; charset=utf-8");
return;
}
res.status = up->status;
if (up->has_header("Location")) res.set_header("Location", up->get_header_value("Location"));
const auto& ct = up->get_header_value("Content-Type");
res.set_content(up->body, ct.empty() ? "application/sdp" : ct.c_str());
});
svr.Get("/doors", [](const httplib::Request&, httplib::Response& res) {
......@@ -1243,6 +1351,21 @@ int main(int argc, char** argv) {
res.set_content(hik_acs::accessSnapshotJson(), "application/json; charset=utf-8");
});
svr.Get("/cameras", [](const httplib::Request&, httplib::Response& res) {
res.set_content("{\"cameras\":" + hik_cam::camerasJsonArray() + "}",
"application/json; charset=utf-8");
});
svr.Get("/cameras/snapshot", [](const httplib::Request&, httplib::Response& res) {
res.set_content(hik_cam::cameraSnapshotJson(), "application/json; charset=utf-8");
});
auto cameraPreviewHandler = [](const httplib::Request& req, httplib::Response& res) {
respondCameraPreview(req.matches[1], res);
};
svr.Get(R"(/cameras/([^/]+)/preview.jpg)", cameraPreviewHandler);
svr.Get(R"(/cameras/([^/]+)/snapshot.jpg)", cameraPreviewHandler);
// ---- Admin API ----
svr.Post("/admin/api/login", [](const httplib::Request& req, httplib::Response& res) {
auto user = extractString(req.body, "username");
......@@ -1292,6 +1415,9 @@ int main(int argc, char** argv) {
if (port > 0) g.listenPort = port;
auto platform = extractString(req.body, "platformUrl");
if (!platform.empty()) g.platformUrl = platform;
if (req.body.find("\"mediaWhepBase\"") != std::string::npos) {
g.mediaWhepBase = extractString(req.body, "mediaWhepBase");
}
int poll = extractInt(req.body, "pollMs", 0);
if (poll > 0) g.pollMs = poll;
if (req.body.find("\"modbusAddressBase\"") != std::string::npos) {
......@@ -1332,6 +1458,7 @@ int main(int argc, char** argv) {
return;
}
bool doorChanged = false;
bool camChanged = false;
std::string body;
{
std::lock_guard<std::mutex> lock(g.mu);
......@@ -1345,6 +1472,7 @@ int main(int argc, char** argv) {
list.push_back(d);
};
doorChanged = (type == "door");
camChanged = (type == "camera" || type == "nvr" || type.empty());
if (type == "door") upsert(g.doors);
else if (type == "nvr") {
g.nvr = d;
......@@ -1354,6 +1482,7 @@ int main(int argc, char** argv) {
body = configToJsonLocked();
}
if (doorChanged) syncDoorsToHik();
if (camChanged) syncCamerasToHik();
res.set_content(body, "application/json; charset=utf-8");
});
......@@ -1367,6 +1496,7 @@ int main(int argc, char** argv) {
return;
}
bool doorChanged = false;
bool camChanged = false;
std::string body;
{
std::lock_guard<std::mutex> lock(g.mu);
......@@ -1376,6 +1506,7 @@ int main(int argc, char** argv) {
list.end());
};
doorChanged = (type == "door");
camChanged = (type == "camera" || type == "nvr" || type.empty());
if (type == "door") eraseId(g.doors);
else if (type == "nvr") {
if (g.nvr.id == id) g.nvr = HikDevice{};
......@@ -1384,30 +1515,14 @@ int main(int argc, char** argv) {
body = configToJsonLocked();
}
if (doorChanged) syncDoorsToHik();
if (camChanged) syncCamerasToHik();
res.set_content(body, "application/json; charset=utf-8");
});
svr.Get(R"(/admin/api/cameras/([^/]+)/preview.jpg)",
[](const httplib::Request& req, httplib::Response& res) {
// preview can be opened in <img>; allow token via query for simplicity optional
// Prefer Authorization; if missing, still show placeholder (LAN gateway)
std::string id = req.matches[1];
HikDevice cam;
{
std::lock_guard<std::mutex> lock(g.mu);
for (const auto& c : g.cameras) {
if (c.id == id) {
cam = c;
break;
}
}
}
if (cam.id.empty()) {
cam.id = id;
cam.name = id;
cam.host = "unknown";
}
res.set_content(previewSvg(cam), "image/svg+xml; charset=utf-8");
// <img> 可无 Authorization;局域网网关允许匿名抓图
respondCameraPreview(req.matches[1], res);
});
int port = 0;
......@@ -1436,6 +1551,9 @@ int main(int argc, char** argv) {
std::cout << "[gateway] Modbus poll thread started\n";
std::cout << "[gateway] HCNetSDK ACS: " << (hik_acs::sdkLinked() ? "linked" : "stub")
<< " doorsLoggedIn=" << hik_acs::loggedInCount() << "\n";
std::cout << "[gateway] HCNetSDK CAM: " << (hik_cam::sdkLinked() ? "linked" : "stub")
<< " camsLoggedIn=" << hik_cam::loggedInCount()
<< " mediaWhepBase=" << (g.mediaWhepBase.empty() ? "(unset)" : g.mediaWhepBase) << "\n";
if (!svr.listen(host.c_str(), port)) {
std::cerr << "[gateway] failed to bind\n";
return 1;
......
......@@ -9,9 +9,16 @@ export function resolveWhepBase(): string {
return (config.webrtcWhepBase || resolveGatewayBase()).replace(/\/$/, '')
}
/** 将平台设备映射为媒体流 ID */
export function deviceStreamId(device: { id: string; channel?: number; deviceType?: string }): string {
/** 将平台设备映射为媒体流 ID(优先 streamId,与网关/MediaMTX 对齐) */
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 (/^(cam|nvr)-/i.test(device.id)) return 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 }
......@@ -15,11 +15,15 @@ function uv(u: number, v: number): { x: number; y: number; z: number } {
}
export type CameraFacilityDef = {
/** 稳定设备 id,同时作为默认 WHEP streamId */
id: string
name: string
ip: string
port: number
/** IPC 抓图通道(通常 1) */
channel: number
/** NVR 通道号(可选,用于 nvr-{ch} 流) */
nvrChannel?: number
scene: { x: number; y: number; z: number }
}
......@@ -31,31 +35,31 @@ export type AccessFacilityDef = {
scene: { x: number; y: number; z: number }
}
/** 交底表 IPC(海康威视 · 8000 · admin) */
/** 交底表 IPC(海康威视 · 8000 · admin);id=网关 cam-* */
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-sample2-front', name: '样本制备区2房前面', ip: '192.168.10.144', port: 8000, channel: 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-sample2-back', name: '样本制备区2房后面', ip: '192.168.10.152', port: 8000, channel: 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-front-corridor', name: '前走廊', ip: '192.168.10.153', port: 8000, channel: 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-report', name: '生信分析/报告室', ip: '192.168.10.151', port: 8000, channel: 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-reagent', name: '试剂准备间', ip: '192.168.10.142', port: 8000, channel: 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-sample1-front', name: '样本制备区1房前面', ip: '192.168.10.154', port: 8000, channel: 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-lib1-back', name: '文库制备区1房后面', ip: '192.168.10.149', port: 8000, channel: 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-150', name: '收样咨询室', ip: '192.168.10.150', port: 8000, channel: 1, nvrChannel: 1, scene: uv(0.88, 0.88) },
{ id: 'cam-144', name: '样本制备区2房前面', ip: '192.168.10.144', port: 8000, channel: 1, nvrChannel: 2, scene: uv(0.55, 0.42) },
{ id: 'cam-146', name: '文库制备2房后面', ip: '192.168.10.146', port: 8000, channel: 1, nvrChannel: 3, scene: uv(0.25, 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-147', name: '文库制备2房前面', ip: '192.168.10.147', port: 8000, channel: 1, nvrChannel: 5, scene: uv(0.25, 0.42) },
{ id: 'cam-153', name: '前走廊', ip: '192.168.10.153', port: 8000, channel: 1, nvrChannel: 6, scene: uv(0.62, 0.12) },
{ id: 'cam-148', name: '测序区后', ip: '192.168.10.148', port: 8000, channel: 1, nvrChannel: 7, scene: uv(0.1, 0.72) },
{ id: 'cam-151', name: '生信分析/报告室', ip: '192.168.10.151', port: 8000, channel: 1, nvrChannel: 8, scene: uv(0.88, 0.38) },
{ id: 'cam-145', name: '样本制备区1房后面', ip: '192.168.10.145', port: 8000, channel: 1, nvrChannel: 9, scene: uv(0.7, 0.72) },
{ id: 'cam-142', name: '试剂准备间', ip: '192.168.10.142', port: 8000, channel: 1, nvrChannel: 10, scene: uv(0.85, 0.55) },
{ id: 'cam-156', name: '文库制备区1房前面', ip: '192.168.10.156', port: 8000, channel: 1, nvrChannel: 11, scene: uv(0.4, 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-155', name: '测序区前', ip: '192.168.10.155', port: 8000, channel: 1, nvrChannel: 13, scene: uv(0.1, 0.42) },
{ id: 'cam-149', name: '文库制备区1房后面', ip: '192.168.10.149', port: 8000, channel: 1, nvrChannel: 14, scene: uv(0.4, 0.72) },
{ 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[] = [
{ id: 'door-recv', 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-report', 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-120', name: '咨询室门禁', ip: '192.168.10.120', port: 8000, scene: uv(0.9, 0.92) },
{ id: 'door-121', name: '后走廊门禁', ip: '192.168.10.121', port: 8000, scene: uv(0.28, 0.1) },
{ id: 'door-122', name: '报告室门禁', ip: '192.168.10.122', port: 8000, scene: uv(0.9, 0.32) },
{ id: 'door-123', name: '前走廊门禁', ip: '192.168.10.123', port: 8000, scene: uv(0.7, 0.1) },
]
/** NVR / 交换机(写入视频通信配置,不作为大屏设施点) */
......
......@@ -13,6 +13,8 @@ import {
} from '../devices/store.js'
import { buildSnapshotFromDevices } from './fromDevices.js'
import { fetchAccessSnapshot } from '../access/adapter.js'
import { fetchCameraSnapshot } from '../video/cameraAdapter.js'
import { applyCameraStatuses } from '../video/store.js'
let snapshot: RealtimeSnapshot = {
ts: 0,
......@@ -196,6 +198,34 @@ async function tick() {
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()
publish()
void persistSafe(snapshot)
......
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { deviceStreamId, proxyWhepOffer } from '../gateway/client.js'
import { fetchCameraPreviewJpeg } from '../video/cameraAdapter.js'
import {
createDevice,
deleteDevice,
......@@ -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
* 媒体面由 Windows 网关 / ZLMediaKit 提供
......
......@@ -560,9 +560,11 @@ export async function ensureCameraSensorSeed(): Promise<{ created: number; skipp
ip: def.ip,
port: def.port,
channel: def.channel,
nvrChannel: def.nvrChannel,
protocol: BRIEFING_INFRA.protocol,
username: BRIEFING_INFRA.username,
videoDeviceId: video?.id || null,
videoDeviceId: video?.id || def.id,
streamId: def.id,
icon: 'cctv-blue.png',
},
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 {
enabled: boolean
ip: string
remark: string
/** WHEP/MediaMTX 流名;与网关 cameras[].streamId 对齐 */
streamId: string
/** 三维场景坐标;未打点为 null */
scenePoint: ScenePoint | null
createdAt: string
......@@ -85,7 +87,7 @@ function defaultConfig(): VideoCommConfig {
function seedDevices(): VideoDevice[] {
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: '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 },
......@@ -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: 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,
id: randomUUID(),
id,
streamId: r.deviceType === 'nvr-ch' ? `nvr-${r.channel}` : `cam-${id}`,
createdAt: now,
updatedAt: now,
}))
}
})
}
function normalizeProtocol(p: string | undefined): VideoProtocol {
......@@ -122,6 +128,7 @@ function normalizeScenePoint(raw: unknown): ScenePoint | null {
function normalizeDevice(raw: Partial<VideoDevice> & { id: string }): VideoDevice {
const now = new Date().toISOString()
const streamId = String(raw.streamId || '').trim() || String(raw.id)
return {
id: raw.id,
name: String(raw.name || ''),
......@@ -132,6 +139,7 @@ function normalizeDevice(raw: Partial<VideoDevice> & { id: string }): VideoDevic
enabled: raw.enabled !== false,
ip: String(raw.ip || ''),
remark: String(raw.remark || ''),
streamId,
scenePoint: normalizeScenePoint(raw.scenePoint),
createdAt: raw.createdAt || now,
updatedAt: raw.updatedAt || now,
......@@ -206,15 +214,16 @@ export function ensureBriefingVideoSeed(): { created: number; updated: number; r
if (allPlaceholder || !briefingIpHit) {
data.devices = CAMERA_FACILITY_DEFS.map((c) => ({
id: randomUUID(),
id: c.id,
name: c.name,
channel: c.channel,
channel: c.nvrChannel || c.channel,
location: c.name,
deviceType: 'ipc' as const,
status: 'online' as const,
enabled: true,
ip: c.ip,
remark: `交底表 · ${BRIEFING_INFRA.protocol} · ${c.ip}:${c.port}`,
streamId: c.id,
scenePoint: {
x: c.scene.x,
y: c.scene.y,
......@@ -231,18 +240,19 @@ export function ensureBriefingVideoSeed(): { created: number; updated: number; r
let created = 0
let updated = 0
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) {
data.devices.push({
id: randomUUID(),
id: c.id,
name: c.name,
channel: c.channel,
channel: c.nvrChannel || c.channel,
location: c.name,
deviceType: 'ipc',
status: 'online',
enabled: true,
ip: c.ip,
remark: `交底表 · ${BRIEFING_INFRA.protocol} · ${c.ip}:${c.port}`,
streamId: c.id,
scenePoint: {
x: c.scene.x,
y: c.scene.y,
......@@ -256,6 +266,15 @@ export function ensureBriefingVideoSeed(): { created: number; updated: number; r
continue
}
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) {
hit.ip = c.ip
dirty = true
......@@ -371,8 +390,9 @@ export function createDevice(input: {
throw new Error(`通道 ${input.channel} 已存在`)
}
const now = new Date().toISOString()
const id = randomUUID()
const device: VideoDevice = {
id: randomUUID(),
id,
name: input.name.trim(),
channel: Number(input.channel),
location: input.location.trim(),
......@@ -381,6 +401,7 @@ export function createDevice(input: {
enabled: input.enabled !== false,
ip: (input.ip || '').trim(),
remark: (input.remark || '').trim(),
streamId: input.deviceType === 'nvr-ch' ? `nvr-${Number(input.channel)}` : `cam-${id}`,
scenePoint: normalizeScenePoint(input.scenePoint),
createdAt: now,
updatedAt: now,
......@@ -390,6 +411,37 @@ export function createDevice(input: {
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(
id: string,
patch: Partial<{
......
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WebRtcPlayer from '@/components/video/WebRtcPlayer.vue'
const { t } = useI18n()
......@@ -12,6 +13,8 @@ const props = withDefaults(
left: number
top: number
src?: string
/** 绑定视频设备 id 时走 WebRTC;否则回退 src */
deviceId?: string | null
/** video=监控预览;values=读数;switch=照明开/关 */
kind?: 'video' | 'values' | 'switch'
lines?: { label: string; value: string }[]
......@@ -22,6 +25,7 @@ const props = withDefaults(
}>(),
{
src: '/cams/lab-seq.mp4',
deviceId: null,
kind: 'video',
lines: () => [],
switchOn: null,
......@@ -190,8 +194,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
}"
:style="{ height: `${videoH}px` }"
>
<div v-if="kind === 'video'" class="near-video-wrap">
<WebRtcPlayer v-if="deviceId" :key="deviceId" :device-id="deviceId" />
<video
v-if="kind === 'video'"
v-else
class="near-video"
:src="src"
autoplay
......@@ -200,6 +206,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
playsinline
controls
/>
</div>
<ul v-else-if="kind === 'values'" class="near-values">
<li v-for="row in lines" :key="row.label">
<span>{{ row.label }}</span>
......@@ -425,6 +432,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
text-align: right;
word-break: break-all;
}
.near-video-wrap {
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
}
.near-video-wrap :deep(video),
.near-video {
display: block;
width: 100%;
......
......@@ -85,6 +85,7 @@ const liveSwitchOn = computed(() => {
})
const nearSwitching = ref(false)
const nearSwitchError = ref('')
const nearDeviceId = ref<string | null>(null)
const nearLeft = ref(120)
const nearTop = ref(120)
/** 关闭后短时忽略 Marker 点击,防止卸层点击穿透再次打开 */
......@@ -318,6 +319,7 @@ function openNearDialog(
function closeNearDialog() {
nearOpen.value = false
nearSensorId.value = null
nearDeviceId.value = null
nearSwitchError.value = ''
nearSwitching.value = false
nearCloseGuardUntil = Date.now() + 400
......@@ -537,6 +539,7 @@ function onMarkerClick(payload: {
nearKind.value = 'video'
nearLines.value = []
nearSensorId.value = null
nearDeviceId.value = cam?.id || null
openNearDialog(resolveNearTitle(cam, marker, markerId), markerId || marker.id, raw, {
center: true,
})
......@@ -900,6 +903,7 @@ onBeforeUnmount(() => {
:switch-error="nearSwitchError"
:left="nearLeft"
:top="nearTop"
:device-id="nearDeviceId"
src="/cams/lab-seq.mp4"
@close="closeNearDialog"
@control="onLightControl"
......
......@@ -22,6 +22,8 @@ export interface VideoDevice {
enabled: boolean
ip: string
remark: string
/** WHEP/MediaMTX 流名;与网关对齐 */
streamId?: string
scenePoint?: ScenePoint | null
createdAt: 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