diff --git a/docs/sql/ivs_camera.sql b/docs/sql/ivs_camera.sql index 38205176..e5445bc3 100644 --- a/docs/sql/ivs_camera.sql +++ b/docs/sql/ivs_camera.sql @@ -26,6 +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未探测', `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 5e39b3bf..44b23bd1 100644 --- a/src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackUiConfig.java +++ b/src/main/java/com/inspect/nvr/hik/domain/HikNvrPlaybackUiConfig.java @@ -93,5 +93,14 @@ public class HikNvrPlaybackUiConfig { /** 设备分组名称。 */ private String group; + + /** + * 该通道是否支持云台控制,供前端对不可控通道置灰云台面板。 + * + *

按台账 basedata_eqpbook_channel.channel_type 静态判定,与设备树节点上的 + * 同名字段使用同一套规则:与镜头无关的 ptz、ng 通道不支持,台账查不到信息的 + * 通道保守放行。

+ */ + private Boolean ptzSupported; } } 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 c2ad2374..1887fde8 100644 --- a/src/main/java/com/inspect/nvr/hik/service/HikNvrDeviceTreeService.java +++ b/src/main/java/com/inspect/nvr/hik/service/HikNvrDeviceTreeService.java @@ -161,12 +161,27 @@ public class HikNvrDeviceTreeService { .nvrName(nvrName) .name(name) .group(group) + .ptzSupported(resolvePtzSupported(camera.getPtzSupported())) .build()); } // 将去重后的有序映射转换为响应列表。 return new ArrayList<>(channels.values()); } + /** + * 把 ivs_camera 中落库的云台能力转换为页面布尔值。 + * + *

能力值由同步任务向 NVR 探测得到:1 支持、0 不支持、NULL 未探测。未探测表示 + * 尚无结论(通道离线、探测失败、非海康厂商等),必须按"支持"返回,否则一次探测 + * 失败就会把云台面板误置灰。

+ * + * @param capability ivs_camera.ptz_supported 落库值 + * @return 支持云台控制返回 true + */ + private boolean resolvePtzSupported(Integer capability) { + return capability == null || capability.intValue() != 0; + } + /** * 判断 NVR 是否启用且具备可作为页面目标的 IP。 * 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 3240b267..a86dae49 100644 --- a/src/main/java/com/inspect/nvr/ivs/pojo/IvsCamera.java +++ b/src/main/java/com/inspect/nvr/ivs/pojo/IvsCamera.java @@ -68,6 +68,8 @@ public class IvsCamera implements Serializable { private String customFields; /** 功能类型。 */ private String functionType; + /** 云台能力:1 支持,0 不支持,NULL 未探测(通道离线或探测失败)。 */ + private Integer ptzSupported; /** 监控方向。 */ 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..7da0011a 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 knownPtzSupport = loadKnownPtzSupport(current); // 将每个通道映射成 IVS 摄像机并按 code 插入或更新。 for (IvsNvrChannelClient.Channel channel : channels) { // 构造摄像机表字段并执行 upsert。 - ivsCameraMapper.upsert(toCamera(current, channel)); + IvsCamera camera = toCamera(current, channel); + // 已有结论直接沿用,保证通道离线或探测失败时不会丢失结论。 + camera.setPtzSupported(knownPtzSupport.get(camera.getCode())); + // 仅对"尚无结论且当前在线"的通道向 NVR 探测云台能力,控制同步开销。 + if (camera.getPtzSupported() == null && channel.isOnline()) { + camera.setPtzSupported( + channelClient.probePtzSupported(current, channel.getId())); + } + ivsCameraMapper.upsert(camera); result.setCameraCount(result.getCameraCount() + 1); } result.setSuccessNvrCount(result.getSuccessNvrCount() + 1); @@ -147,6 +159,36 @@ public class IvsCameraSyncService { } } + /** + * 读取本台 NVR 已落库的云台能力结论。 + * + *

同步任务每五分钟执行一次,已有结论的通道不再重复探测,否则每轮都会对每个 + * 通道发一次 ISAPI 请求。通道离线、探测失败或需要重新探测(手工把 + * {@code ivs_camera.ptz_supported} 置空)时才会再次探测。

+ * + * @param nvr 当前同步的 NVR + * @return 摄像机编码到已落库云台能力的映射,未落库或查询无结果时对应编码不出现 + */ + private Map loadKnownPtzSupport(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) { + continue; + } + known.put(camera.getCode().trim(), camera.getPtzSupported()); + } + 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..cb5cff58 100644 --- a/src/main/java/com/inspect/nvr/ivs/util/IvsNvrChannelClient.java +++ b/src/main/java/com/inspect/nvr/ivs/util/IvsNvrChannelClient.java @@ -44,6 +44,14 @@ 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 PTZ_CAPABILITIES_PATH = "/ISAPI/PTZCtrl/channels/%d/capabilities"; + /** 云台能力结构标签:出现即代表该通道具备云台能力。 */ + private static final String PTZ_CAPABILITY_TAG = "= 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 +172,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); + // 将设备响应解码成 XML 文本;非 2xx 的响应体同样解码,交由调用方判定。 + return new IsapiResponse(status, decode(bytes)); }); } catch (Exception ex) { // 保留已知业务异常,其他异常包装为通道读取失败。 @@ -162,6 +185,91 @@ public class IvsNvrChannelClient { } } + /** + * 向 NVR 探测指定通道是否支持云台控制。 + * + *

海康 ISAPI 的云台能力接口只对可云台通道返回设备能力结构;固定摄像机(枪机、 + * 半球等)由设备明确拒绝。因此判定分三种结果,其中"未探测"绝不能当成"不支持", + * 否则接口本身不可用或凭据异常时会把整台 NVR 的通道全部置灰。

+ * + * @param nvr NVR 连接与凭据 + * @param channelId NVR 通道号,空值或无意义通道号直接返回未探测 + * @return 1 支持,0 不支持,null 未探测(接口不可用、鉴权失败或响应无法判定) + */ + public Integer probePtzSupported(IvsNvr nvr, Integer channelId) { + // 云台能力接口由海康 ISAPI 提供,其他厂商保持"未探测",不发无效请求。 + if (nvr == null || !"HIKVISION".equals(normalizeVendor(nvr.getVendor()))) { + log.info("该厂商暂不支持云台能力探测,nvrIp={},vendor={}", + nvr == null ? null : nvr.getNvrIp(), + nvr == null ? null : nvr.getVendor()); + return null; + } + // 通道号缺失时无法拼出能力查询地址,保持"未探测"而不是让同步任务失败。 + if (channelId == null || channelId <= 0) { + log.info("云台能力探测缺少有效通道号,保持未探测,nvrIp={},channel={}", + nvr.getNvrIp(), channelId); + return null; + } + IsapiResponse response; + try { + response = exchange(nvr, String.format(PTZ_CAPABILITIES_PATH, channelId)); + } catch (Exception ex) { + // 网络异常或参数异常只记录原因,保持"未探测",下一轮同步重试。 + log.error("云台能力探测失败,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(PTZ_CAPABILITY_TAG)) { + log.info("云台能力探测:支持云台,nvrIp={},channel={}", + nvr.getNvrIp(), channelId); + return 1; + } + // 设备用标准 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, extractStatusString(body)); + return 0; + } + // 其余情况(接口不存在、网关错误、空响应等)无法判定,保持"未探测"。 + log.info("云台能力探测结果无法判定,保持未探测,nvrIp={},channel={},http={},body={}", + nvr.getNvrIp(), channelId, response.status, abbreviate(body)); + return null; + } + + /** 提取 ISAPI 错误体中的 statusString,便于日志直接看出设备拒绝原因。 */ + private String extractStatusString(String body) { + int start = body.indexOf(""); + int end = body.indexOf(""); + if (start < 0 || end <= start) { + return ""; + } + return body.substring(start + "".length(), end).trim(); + } + + /** 截断过长响应体,避免无法判定的设备响应把日志刷爆。 */ + private String abbreviate(String body) { + String singleLine = body.replaceAll("\\s+", " ").trim(); + return singleLine.length() <= 200 ? singleLine : singleLine.substring(0, 200) + "..."; + } + + /** 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 addb25de..cada9514 100644 --- a/src/main/resources/mapper/ivs/IvsCameraMapper.xml +++ b/src/main/resources/mapper/ivs/IvsCameraMapper.xml @@ -30,6 +30,7 @@ + @@ -64,6 +65,7 @@ reserve, custom_fields, function_type, + ptz_supported, monitor_direction, owner_aps_id, security_level_code_string, @@ -95,6 +97,7 @@ #{reserve}, #{customFields}, #{functionType}, + #{ptzSupported}, #{monitorDirection}, #{ownerApsId}, #{securityLevelCodeString}, @@ -126,6 +129,7 @@ reserve = VALUES(reserve), custom_fields = VALUES(custom_fields), function_type = VALUES(function_type), + ptz_supported = COALESCE(VALUES(ptz_supported), ptz_supported), monitor_direction = VALUES(monitor_direction), owner_aps_id = VALUES(owner_aps_id), security_level_code_string = VALUES(security_level_code_string), @@ -184,6 +188,7 @@ camera.reserve, camera.custom_fields, camera.function_type, + camera.ptz_supported, camera.monitor_direction, camera.owner_aps_id, camera.security_level_code_string, @@ -228,6 +233,7 @@ reserve, custom_fields, function_type, + ptz_supported, monitor_direction, owner_aps_id, security_level_code_string,