diff --git a/docs/sql/ivs_camera.sql b/docs/sql/ivs_camera.sql index 7faa7457..d078f578 100644 --- a/docs/sql/ivs_camera.sql +++ b/docs/sql/ivs_camera.sql @@ -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 '安全等级编码', diff --git a/src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackUiConfig.java b/src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackUiConfig.java index 1abf3ea7..85be80df 100644 --- a/src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackUiConfig.java +++ b/src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackUiConfig.java @@ -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}。 + * + *

键缺失表示未知,前端按可用处理;为 null 表示尚未探测。

+ */ + private Map capabilities; } } diff --git a/src/main/java/com/inspect/nvr/hik/service/HikNvrDeviceTreeService.java b/src/main/java/com/inspect/nvr/hik/service/HikNvrDeviceTreeService.java index e087cbf0..c5aca80e 100644 --- a/src/main/java/com/inspect/nvr/hik/service/HikNvrDeviceTreeService.java +++ b/src/main/java/com/inspect/nvr/hik/service/HikNvrDeviceTreeService.java @@ -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,供页面按功能置灰。 * - *

能力值由人工维护:1 支持、0 不支持、NULL 未知。未知必须按"支持"返回, - * 否则尚未维护的通道会被整片误置灰。

+ *

解析失败或内容为空一律返回 null(未知),前端按可用处理,避免把能用的按钮误灰。

* - * @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 parseCapabilities(String capabilities) { + if (!hasText(capabilities)) { + return null; + } + try { + return OBJECT_MAPPER.readValue(capabilities, + new TypeReference>() { }); + } catch (Exception ex) { + log.warn("能力JSON解析失败,按未知处理:{}", capabilities); + return null; + } } /** diff --git a/src/main/java/com/inspect/nvr/ivs/pojo/IvsCamera.java b/src/main/java/com/inspect/nvr/ivs/pojo/IvsCamera.java index c20e566e..c1915de3 100644 --- a/src/main/java/com/inspect/nvr/ivs/pojo/IvsCamera.java +++ b/src/main/java/com/inspect/nvr/ivs/pojo/IvsCamera.java @@ -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 编码。 */ diff --git a/src/main/java/com/inspect/nvr/ivs/service/IvsCameraSyncService.java b/src/main/java/com/inspect/nvr/ivs/service/IvsCameraSyncService.java index 9b288694..ba5697ab 100644 --- a/src/main/java/com/inspect/nvr/ivs/service/IvsCameraSyncService.java +++ b/src/main/java/com/inspect/nvr/ivs/service/IvsCameraSyncService.java @@ -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 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 已落库的功能能力结论。 + * + *

同步任务每五分钟执行一次,已有结论的通道不再重复探测,否则每轮都会对每个 + * 通道发一次 ISAPI 请求。人工修正的能力值同样走这条规则——非空即不再探测, + * 因此人工值不会被覆盖;需要重新探测时把 {@code ivs_camera.capabilities} 置空。

+ * + * @param nvr 当前同步的 NVR + * @return 摄像机编码到能力 JSON 的映射,未落库或查询无结果时对应编码不出现 + */ + private Map loadKnownCapabilities(IvsNvr nvr) { + // 尚未落库的 NVR 无需查询,直接按全部未知处理。 + if (nvr == null || nvr.getNvrIp() == null) { + return Collections.emptyMap(); + } + List existing = ivsCameraMapper.selectByNvrIp(nvr.getNvrIp()); + if (existing == null || existing.isEmpty()) { + return Collections.emptyMap(); + } + Map 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 记录及主键不能为空,否则无法安全回写编码。 diff --git a/src/main/java/com/inspect/nvr/ivs/util/IvsNvrChannelClient.java b/src/main/java/com/inspect/nvr/ivs/util/IvsNvrChannelClient.java index 057c95ec..645619dc 100644 --- a/src/main/java/com/inspect/nvr/ivs/util/IvsNvrChannelClient.java +++ b/src/main/java/com/inspect/nvr/ivs/util/IvsNvrChannelClient.java @@ -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 = "= 300) { + throw new IllegalStateException("NVR ISAPI 返回 HTTP " + response.status + + ",IP=" + nvr.getNvrIp()); + } + return response.body; + } + + /** + * 使用 Digest/Basic 兼容认证请求 NVR 的 ISAPI 接口,并返回状态码与响应体。 + * + *

能力探测需要区分"设备明确拒绝"与"接口本身不可用",因此不能像 + * {@link #request} 那样把非 2xx 一律当异常抛出。

+ * + * @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 字符串。 + * + *

海康 ISAPI 的云台能力接口只对可云台通道返回能力文档;固定摄像机(枪机、半球等) + * 由设备以标准 ISAPI 错误体明确拒绝。能力文档中只有部分能力有明确取值,其余能力 + * 不写入 JSON,由前端按"未知即可用"处理——宁可少置灰,也不能把可用的按钮误灰。

+ * + * @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。 + * + *

ptz:能返回能力文档即为支持。wiper:只在设备明确表示支持时写入 true, + * false 可能只是"不支持上报雨刷状态"而非"没有雨刷",因此不写入(保持未知)。 + * position3d:true/false 语义明确,只要有该节点就写入。

+ * + * @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*") + .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 parseChannels(String xml, Map statuses) { Document document = parseXml(xml); diff --git a/src/main/resources/mapper/ivs/IvsCameraMapper.xml b/src/main/resources/mapper/ivs/IvsCameraMapper.xml index 9b7da4e6..8f953a87 100644 --- a/src/main/resources/mapper/ivs/IvsCameraMapper.xml +++ b/src/main/resources/mapper/ivs/IvsCameraMapper.xml @@ -30,7 +30,7 @@ - + @@ -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,