|
|
@ -44,14 +44,6 @@ public class IvsNvrChannelClient { |
|
|
private static final Timeout CONNECT_TIMEOUT = Timeout.ofSeconds(2); |
|
|
private static final Timeout CONNECT_TIMEOUT = Timeout.ofSeconds(2); |
|
|
/** NVR ISAPI 响应读取超时时间。 */ |
|
|
/** NVR ISAPI 响应读取超时时间。 */ |
|
|
private static final Timeout RESPONSE_TIMEOUT = Timeout.ofSeconds(5); |
|
|
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 = "<PTZCap"; |
|
|
|
|
|
/** ISAPI 标准错误体标签:设备明确拒绝时出现。 */ |
|
|
|
|
|
private static final String ISAPI_ERROR_TAG = "<ResponseStatus"; |
|
|
|
|
|
/** 鉴权失败标签:用于把"没权限"与"不支持"区分开。 */ |
|
|
|
|
|
private static final String UNAUTHORIZED_TAG = "Unauthorized"; |
|
|
|
|
|
|
|
|
|
|
|
/** 大华 NetSDK 通道读取客户端,仅在 vendor 为 DAHUA 时调用。 */ |
|
|
/** 大华 NetSDK 通道读取客户端,仅在 vendor 为 DAHUA 时调用。 */ |
|
|
@Resource |
|
|
@Resource |
|
|
@ -120,26 +112,6 @@ public class IvsNvrChannelClient { |
|
|
|
|
|
|
|
|
/** 使用 Digest/Basic 兼容认证请求 NVR 的 ISAPI XML 接口。 */ |
|
|
/** 使用 Digest/Basic 兼容认证请求 NVR 的 ISAPI XML 接口。 */ |
|
|
private String request(IvsNvr nvr, String path) { |
|
|
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。 |
|
|
// 请求必须提供 NVR IP。 |
|
|
if (nvr == null || isBlank(nvr.getNvrIp())) { |
|
|
if (nvr == null || isBlank(nvr.getNvrIp())) { |
|
|
throw new IllegalArgumentException("NVR IP 不能为空"); |
|
|
throw new IllegalArgumentException("NVR IP 不能为空"); |
|
|
@ -172,8 +144,13 @@ public class IvsNvrChannelClient { |
|
|
// 读取响应体原始字节,避免 HTTP 客户端使用平台默认字符集破坏中文名称。 |
|
|
// 读取响应体原始字节,避免 HTTP 客户端使用平台默认字符集破坏中文名称。 |
|
|
byte[] bytes = response.getEntity() == null |
|
|
byte[] bytes = response.getEntity() == null |
|
|
? new byte[0] : EntityUtils.toByteArray(response.getEntity()); |
|
|
? new byte[0] : EntityUtils.toByteArray(response.getEntity()); |
|
|
// 将设备响应解码成 XML 文本;非 2xx 的响应体同样解码,交由调用方判定。 |
|
|
|
|
|
return new IsapiResponse(status, decode(bytes)); |
|
|
|
|
|
|
|
|
// 非 2xx 状态表示 NVR 通道接口调用失败。 |
|
|
|
|
|
if (status < 200 || status >= 300) { |
|
|
|
|
|
throw new IllegalStateException("NVR ISAPI 返回 HTTP " + status |
|
|
|
|
|
+ ",IP=" + nvr.getNvrIp()); |
|
|
|
|
|
} |
|
|
|
|
|
// 将设备响应解码成 XML 文本。 |
|
|
|
|
|
return decode(bytes); |
|
|
}); |
|
|
}); |
|
|
} catch (Exception ex) { |
|
|
} catch (Exception ex) { |
|
|
// 保留已知业务异常,其他异常包装为通道读取失败。 |
|
|
// 保留已知业务异常,其他异常包装为通道读取失败。 |
|
|
@ -185,91 +162,6 @@ public class IvsNvrChannelClient { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 向 NVR 探测指定通道是否支持云台控制。 |
|
|
|
|
|
* |
|
|
|
|
|
* <p>海康 ISAPI 的云台能力接口只对可云台通道返回设备能力结构;固定摄像机(枪机、 |
|
|
|
|
|
* 半球等)由设备明确拒绝。因此判定分三种结果,其中"未探测"绝不能当成"不支持", |
|
|
|
|
|
* 否则接口本身不可用或凭据异常时会把整台 NVR 的通道全部置灰。</p> |
|
|
|
|
|
* |
|
|
|
|
|
* @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("<statusString>"); |
|
|
|
|
|
int end = body.indexOf("</statusString>"); |
|
|
|
|
|
if (start < 0 || end <= start) { |
|
|
|
|
|
return ""; |
|
|
|
|
|
} |
|
|
|
|
|
return body.substring(start + "<statusString>".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,并合并通道在线状态。 */ |
|
|
/** 解析通道 XML,并合并通道在线状态。 */ |
|
|
private List<Channel> parseChannels(String xml, Map<Integer, Boolean> statuses) { |
|
|
private List<Channel> parseChannels(String xml, Map<Integer, Boolean> statuses) { |
|
|
Document document = parseXml(xml); |
|
|
Document document = parseXml(xml); |
|
|
|