|
|
@ -32,6 +32,8 @@ import java.util.HashMap; |
|
|
import java.util.List; |
|
|
import java.util.List; |
|
|
import java.util.Map; |
|
|
import java.util.Map; |
|
|
import java.util.ArrayList; |
|
|
import java.util.ArrayList; |
|
|
|
|
|
import java.util.regex.Matcher; |
|
|
|
|
|
import java.util.regex.Pattern; |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 按 NVR 厂商读取摄像机通道;海康复用 ISAPI,大华转交 NetSDK 客户端。 |
|
|
* 按 NVR 厂商读取摄像机通道;海康复用 ISAPI,大华转交 NetSDK 客户端。 |
|
|
@ -44,6 +46,16 @@ 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 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 时调用。 */ |
|
|
/** 大华 NetSDK 通道读取客户端,仅在 vendor 为 DAHUA 时调用。 */ |
|
|
@Resource |
|
|
@Resource |
|
|
@ -112,6 +124,26 @@ 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 不能为空"); |
|
|
@ -144,13 +176,8 @@ 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()); |
|
|
// 非 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) { |
|
|
} 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,并合并通道在线状态。 */ |
|
|
/** 解析通道 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); |
|
|
|