Browse Source

feat: 云台控制探测云台功能,雨刮器,3D定位,支持人工维护capabilities

master
WangGuangYuan 3 weeks ago
parent
commit
27fac97a75
7 changed files with 228 additions and 24 deletions
  1. +1
    -1
      docs/sql/ivs_camera.sql
  2. +7
    -2
      src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackUiConfig.java
  3. +20
    -8
      src/main/java/com/inspect/nvr/hik/service/HikNvrDeviceTreeService.java
  4. +2
    -2
      src/main/java/com/inspect/nvr/ivs/pojo/IvsCamera.java
  5. +44
    -1
      src/main/java/com/inspect/nvr/ivs/service/IvsCameraSyncService.java
  6. +148
    -7
      src/main/java/com/inspect/nvr/ivs/util/IvsNvrChannelClient.java
  7. +6
    -3
      src/main/resources/mapper/ivs/IvsCameraMapper.xml

+ 1
- 1
docs/sql/ivs_camera.sql View File

@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS `ivs_camera` (
`reserve` varchar(500) DEFAULT NULL COMMENT '预留字段',
`custom_fields` text COMMENT '自定义字段',
`function_type` varchar(255) DEFAULT NULL COMMENT '功能类型',
`ptz_supported` tinyint DEFAULT NULL COMMENT '云台能力:1支持,0不支持,NULL未知',
`capabilities` varchar(1000) DEFAULT NULL COMMENT '功能能力JSON:{"ptz":true,"wiper":true,"position3d":false},缺失键表示未知',
`monitor_direction` varchar(32) DEFAULT NULL COMMENT '监控方向',
`owner_aps_id` varchar(128) DEFAULT NULL COMMENT '所属 APS 编码',
`security_level_code_string` varchar(32) DEFAULT NULL COMMENT '安全等级编码',


+ 7
- 2
src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackUiConfig.java View File

@ -6,6 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@Builder
@ -94,7 +95,11 @@ public class HikNvrPlaybackUiConfig {
/** 设备分组名称。 */
private String group;
/** 该通道是否支持云台控制,供前端对不可控通道置灰云台面板。 */
private Boolean ptzSupported;
/**
* 功能能力,供前端按功能置灰:如 {"ptz":true,"wiper":true,"position3d":false}。
*
* <p>键缺失表示未知,前端按可用处理;为 null 表示尚未探测。</p>
*/
private Map<String, Object> capabilities;
}
}

+ 20
- 8
src/main/java/com/inspect/nvr/hik/service/HikNvrDeviceTreeService.java View File

@ -6,6 +6,8 @@ import com.inspect.nvr.ivs.mapper.IvsNvrMapper;
import com.inspect.nvr.ivs.pojo.IvsCamera;
import com.inspect.nvr.ivs.pojo.IvsNvr;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -27,6 +29,8 @@ public class HikNvrDeviceTreeService {
private static final int CHANNEL_CODE_LENGTH = 3;
/** IVS 摄像机没有位置和型号时使用的页面分组名称。 */
private static final String DEFAULT_GROUP = "摄像机";
/** 解析能力 JSON 的共享 ObjectMapper,线程安全。 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** 查询已由定时同步任务写入 ivs_camera 的摄像机记录。 */
@Resource
@ -161,7 +165,7 @@ public class HikNvrDeviceTreeService {
.nvrName(nvrName)
.name(name)
.group(group)
.ptzSupported(resolvePtzSupported(camera.getPtzSupported()))
.capabilities(parseCapabilities(camera.getCapabilities()))
.build());
}
// 将去重后的有序映射转换为响应列表。
@ -169,16 +173,24 @@ public class HikNvrDeviceTreeService {
}
/**
* 把 ivs_camera 中落库的云台能力转换为页面布尔值。
* 解析 ivs_camera 中落库的功能能力 JSON,供页面按功能置灰。
*
* <p>能力值由人工维护:1 支持、0 不支持、NULL 未知。未知必须按"支持"返回,
* 否则尚未维护的通道会被整片误置灰。</p>
* <p>解析失败或内容为空一律返回 null(未知),前端按可用处理,避免把能用的按钮误灰。</p>
*
* @param capability ivs_camera.ptz_supported 落库值
* @return 支持云台控制返回 true
* @param capabilities ivs_camera.capabilities 落库值
* @return 能力键值对;无法解析返回 null
*/
private boolean resolvePtzSupported(Integer capability) {
return capability == null || capability.intValue() != 0;
private Map<String, Object> parseCapabilities(String capabilities) {
if (!hasText(capabilities)) {
return null;
}
try {
return OBJECT_MAPPER.readValue(capabilities,
new TypeReference<Map<String, Object>>() { });
} catch (Exception ex) {
log.warn("能力JSON解析失败,按未知处理:{}", capabilities);
return null;
}
}
/**


+ 2
- 2
src/main/java/com/inspect/nvr/ivs/pojo/IvsCamera.java View File

@ -68,8 +68,8 @@ public class IvsCamera implements Serializable {
private String customFields;
/** 功能类型。 */
private String functionType;
/** 云台能力:1 支持,0 不支持,NULL 未知;人工维护,同步任务不回写该列。 */
private Integer ptzSupported;
/** 功能能力 JSON:{"ptz":true,"wiper":true,"position3d":false},缺失键表示未知。 */
private String capabilities;
/** 监控方向。 */
private String monitorDirection;
/** 所属 APS 编码。 */


+ 44
- 1
src/main/java/com/inspect/nvr/ivs/service/IvsCameraSyncService.java View File

@ -15,8 +15,10 @@ import org.springframework.scheduling.annotation.Scheduled;
import javax.annotation.Resource;
import java.security.SecureRandom;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
/**
@ -89,10 +91,20 @@ public class IvsCameraSyncService {
ivsNvrMapper.updateChannelCount(current.getNvrId(), channels.size());
current.setChannelCount(channels.size());
}
// 读取本台 NVR 已落库的能力结论,已探测过的通道不再重复请求设备。
Map<String, String> knownCapabilities = loadKnownCapabilities(current);
// 将每个通道映射成 IVS 摄像机并按 code 插入或更新。
for (IvsNvrChannelClient.Channel channel : channels) {
// 构造摄像机表字段并执行 upsert。
ivsCameraMapper.upsert(toCamera(current, channel));
IvsCamera camera = toCamera(current, channel);
// 已有结论沿用,保证通道离线或探测失败时不会丢失结论。
camera.setCapabilities(knownCapabilities.get(camera.getCode()));
// 仅对"尚无结论且当前在线"的通道探测能力,控制同步开销。
if (camera.getCapabilities() == null && channel.isOnline()) {
camera.setCapabilities(
channelClient.probeCapabilities(current, channel.getId()));
}
ivsCameraMapper.upsert(camera);
result.setCameraCount(result.getCameraCount() + 1);
}
result.setSuccessNvrCount(result.getSuccessNvrCount() + 1);
@ -147,6 +159,37 @@ public class IvsCameraSyncService {
}
}
/**
* 读取本台 NVR 已落库的功能能力结论。
*
* <p>同步任务每五分钟执行一次,已有结论的通道不再重复探测,否则每轮都会对每个
* 通道发一次 ISAPI 请求。人工修正的能力值同样走这条规则——非空即不再探测,
* 因此人工值不会被覆盖;需要重新探测时把 {@code ivs_camera.capabilities} 置空。</p>
*
* @param nvr 当前同步的 NVR
* @return 摄像机编码到能力 JSON 的映射,未落库或查询无结果时对应编码不出现
*/
private Map<String, String> loadKnownCapabilities(IvsNvr nvr) {
// 尚未落库的 NVR 无需查询,直接按全部未知处理。
if (nvr == null || nvr.getNvrIp() == null) {
return Collections.emptyMap();
}
List<IvsCamera> existing = ivsCameraMapper.selectByNvrIp(nvr.getNvrIp());
if (existing == null || existing.isEmpty()) {
return Collections.emptyMap();
}
Map<String, String> known = new HashMap<>(existing.size());
for (IvsCamera camera : existing) {
// 空记录、空编码或尚无结论的通道都跳过。
if (camera == null || camera.getCode() == null
|| camera.getCapabilities() == null) {
continue;
}
known.put(camera.getCode().trim(), camera.getCapabilities());
}
return known;
}
/** 确保 NVR 具有符合 IVS 规则且全局唯一的编码。 */
private IvsNvr ensureNvrCode(IvsNvr nvr) {
// NVR 记录及主键不能为空,否则无法安全回写编码。


+ 148
- 7
src/main/java/com/inspect/nvr/ivs/util/IvsNvrChannelClient.java View File

@ -32,6 +32,8 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 按 NVR 厂商读取摄像机通道;海康复用 ISAPI,大华转交 NetSDK 客户端。
@ -44,6 +46,16 @@ public class IvsNvrChannelClient {
private static final Timeout CONNECT_TIMEOUT = Timeout.ofSeconds(2);
/** NVR ISAPI 响应读取超时时间。 */
private static final Timeout RESPONSE_TIMEOUT = Timeout.ofSeconds(5);
/** 海康云台能力查询路径,通道号由调用方填入。 */
private static final String CAPABILITIES_PATH = "/ISAPI/PTZCtrl/channels/%d/capabilities";
/** 云台能力文档根节点:新固件为 PTZChanelCap,旧固件为 PTZCap。 */
private static final String CAP_ROOT = "<PTZChanelCap";
/** 旧固件的云台能力文档根节点。 */
private static final String CAP_ROOT_LEGACY = "<PTZCap";
/** ISAPI 标准错误体标签:设备明确拒绝时出现。 */
private static final String ISAPI_ERROR_TAG = "<ResponseStatus";
/** 鉴权失败标签:用于把"没权限"与"不支持"区分开。 */
private static final String UNAUTHORIZED_TAG = "Unauthorized";
/** 大华 NetSDK 通道读取客户端,仅在 vendor 为 DAHUA 时调用。 */
@Resource
@ -112,6 +124,26 @@ public class IvsNvrChannelClient {
/** 使用 Digest/Basic 兼容认证请求 NVR 的 ISAPI XML 接口。 */
private String request(IvsNvr nvr, String path) {
IsapiResponse response = exchange(nvr, path);
// 非 2xx 状态表示 NVR 通道接口调用失败。
if (response.status < 200 || response.status >= 300) {
throw new IllegalStateException("NVR ISAPI 返回 HTTP " + response.status
+ ",IP=" + nvr.getNvrIp());
}
return response.body;
}
/**
* 使用 Digest/Basic 兼容认证请求 NVR 的 ISAPI 接口,并返回状态码与响应体。
*
* <p>能力探测需要区分"设备明确拒绝"与"接口本身不可用",因此不能像
* {@link #request} 那样把非 2xx 一律当异常抛出。</p>
*
* @param nvr NVR 连接与凭据
* @param path ISAPI 路径(含前导斜杠)
* @return 状态码与解码后的响应体
*/
private IsapiResponse exchange(IvsNvr nvr, String path) {
// 请求必须提供 NVR IP。
if (nvr == null || isBlank(nvr.getNvrIp())) {
throw new IllegalArgumentException("NVR IP 不能为空");
@ -144,13 +176,8 @@ public class IvsNvrChannelClient {
// 读取响应体原始字节,避免 HTTP 客户端使用平台默认字符集破坏中文名称。
byte[] bytes = response.getEntity() == null
? new byte[0] : EntityUtils.toByteArray(response.getEntity());
// 非 2xx 状态表示 NVR 通道接口调用失败。
if (status < 200 || status >= 300) {
throw new IllegalStateException("NVR ISAPI 返回 HTTP " + status
+ ",IP=" + nvr.getNvrIp());
}
// 将设备响应解码成 XML 文本。
return decode(bytes);
// 非 2xx 的响应体同样解码,交由调用方判定。
return new IsapiResponse(status, decode(bytes));
});
} catch (Exception ex) {
// 保留已知业务异常,其他异常包装为通道读取失败。
@ -162,6 +189,120 @@ public class IvsNvrChannelClient {
}
}
/**
* 向 NVR 探测指定通道的功能能力,返回 JSON 字符串。
*
* <p>海康 ISAPI 的云台能力接口只对可云台通道返回能力文档;固定摄像机(枪机、半球等)
* 由设备以标准 ISAPI 错误体明确拒绝。能力文档中只有部分能力有明确取值,其余能力
* 不写入 JSON,由前端按"未知即可用"处理——宁可少置灰,也不能把可用的按钮误灰。</p>
*
* @param nvr NVR 连接与凭据
* @param channelId NVR 通道号,空值直接返回 null
* @return 能力 JSON,如 {"ptz":true,"wiper":true,"position3d":false};无法判定返回 null
*/
public String probeCapabilities(IvsNvr nvr, Integer channelId) {
// 云台能力接口由海康 ISAPI 提供,其他厂商保持未知,不发无效请求。
if (nvr == null || !"HIKVISION".equals(normalizeVendor(nvr.getVendor()))) {
return null;
}
// 通道号缺失时无法拼出能力查询地址,保持未知而不是让同步任务失败。
if (channelId == null || channelId <= 0) {
log.warn("能力探测缺少有效通道号,保持未知,nvrIp={}", nvr.getNvrIp());
return null;
}
IsapiResponse response;
try {
response = exchange(nvr, String.format(CAPABILITIES_PATH, channelId));
} catch (Exception ex) {
// 网络异常或参数异常只记录原因,保持未知,下一轮同步重试。
log.info("功能能力探测失败,nvrIp={},channel={}:{}",
nvr.getNvrIp(), channelId, ex.getMessage());
return null;
}
String body = response.body == null ? "" : response.body;
// 返回能力文档即代表该通道可云台控制。
if ((response.status >= 200 && response.status < 300)
|| body.contains(CAP_ROOT) || body.contains(CAP_ROOT_LEGACY)) {
String capabilities = buildCapabilities(body);
log.info("功能能力探测:nvrIp={},channel={},capabilities={}",
nvr.getNvrIp(), channelId, capabilities);
return capabilities;
}
// 设备用标准 ISAPI 错误体明确拒绝时判为不支持云台;鉴权失败必须排除在外。
if (response.status != 401 && !body.contains(UNAUTHORIZED_TAG)
&& body.contains(ISAPI_ERROR_TAG)) {
log.info("功能能力探测:不支持云台,nvrIp={},channel={},http={},statusString={}",
nvr.getNvrIp(), channelId, response.status, readXmlText(body, "statusString"));
return "{\"ptz\":false}";
}
// 其余情况(接口不存在、网关错误、空响应等)无法判定,保持未知。
log.info("功能能力探测结果无法判定,保持未知,nvrIp={},channel={},http={}",
nvr.getNvrIp(), channelId, response.status);
return null;
}
/**
* 从云台能力文档中提取可判定的能力,拼成 JSON。
*
* <p>ptz:能返回能力文档即为支持。wiper:只在设备明确表示支持时写入 true,
* false 可能只是"不支持上报雨刷状态"而非"没有雨刷",因此不写入(保持未知)。
* position3d:true/false 语义明确,只要有该节点就写入。</p>
*
* @param xml 能力文档
* @return 定长顺序的 JSON 字符串,便于按字符串比对变化
*/
private String buildCapabilities(String xml) {
StringBuilder json = new StringBuilder(64);
json.append("{\"ptz\":true");
// 雨刷:不同固件字段名不同,按候选标签依次查找。
if (Boolean.TRUE.equals(readXmlBoolean(xml,
"wiperStatusSupport", "supportWiper", "wiperSupport"))) {
json.append(",\"wiper\":true");
}
// 3D 定位:设备给出明确布尔值时写入。
Boolean position3d = readXmlBoolean(xml,
"isSupportPosition3D", "supportPosition3D", "position3d");
if (position3d != null) {
json.append(",\"position3d\":").append(position3d.booleanValue());
}
return json.append('}').toString();
}
/** 按候选标签名读取 XML 中的布尔值,全部缺失返回 null。 */
private Boolean readXmlBoolean(String xml, String... tags) {
for (String tag : tags) {
String value = readXmlText(xml, tag);
if ("true".equalsIgnoreCase(value)) {
return Boolean.TRUE;
}
if ("false".equalsIgnoreCase(value)) {
return Boolean.FALSE;
}
}
return null;
}
/** 读取 XML 中指定标签的文本内容,标签不存在返回空串。 */
private String readXmlText(String xml, String tag) {
Matcher matcher = Pattern.compile("<" + tag + "\\s*>\\s*([^<]*?)\\s*</" + tag + ">")
.matcher(xml);
return matcher.find() ? matcher.group(1) : "";
}
/** ISAPI 响应:状态码与解码后的响应体。 */
private static final class IsapiResponse {
/** HTTP 状态码。 */
private final int status;
/** 解码后的响应体,空响应体为空串。 */
private final String body;
private IsapiResponse(int status, String body) {
this.status = status;
this.body = body == null ? "" : body;
}
}
/** 解析通道 XML,并合并通道在线状态。 */
private List<Channel> parseChannels(String xml, Map<Integer, Boolean> statuses) {
Document document = parseXml(xml);


+ 6
- 3
src/main/resources/mapper/ivs/IvsCameraMapper.xml View File

@ -30,7 +30,7 @@
<result property="reserve" column="reserve"/>
<result property="customFields" column="custom_fields"/>
<result property="functionType" column="function_type"/>
<result property="ptzSupported" column="ptz_supported"/>
<result property="capabilities" column="capabilities"/>
<result property="monitorDirection" column="monitor_direction"/>
<result property="ownerApsId" column="owner_aps_id"/>
<result property="securityLevelCodeString" column="security_level_code_string"/>
@ -65,6 +65,7 @@
reserve,
custom_fields,
function_type,
capabilities,
monitor_direction,
owner_aps_id,
security_level_code_string,
@ -96,6 +97,7 @@
#{reserve},
#{customFields},
#{functionType},
#{capabilities},
#{monitorDirection},
#{ownerApsId},
#{securityLevelCodeString},
@ -127,6 +129,7 @@
reserve = VALUES(reserve),
custom_fields = VALUES(custom_fields),
function_type = VALUES(function_type),
capabilities = COALESCE(VALUES(capabilities), capabilities),
monitor_direction = VALUES(monitor_direction),
owner_aps_id = VALUES(owner_aps_id),
security_level_code_string = VALUES(security_level_code_string),
@ -185,7 +188,7 @@
camera.reserve,
camera.custom_fields,
camera.function_type,
camera.ptz_supported,
camera.capabilities,
camera.monitor_direction,
camera.owner_aps_id,
camera.security_level_code_string,
@ -230,7 +233,7 @@
reserve,
custom_fields,
function_type,
ptz_supported,
capabilities,
monitor_direction,
owner_aps_id,
security_level_code_string,


Loading…
Cancel
Save