face read support added
parent
2455f71296
commit
0100f4c5f1
|
|
@ -7,6 +7,8 @@ using System.Globalization;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Data.SqlClient;
|
using System.Data.SqlClient;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web.Script.Serialization;
|
using System.Web.Script.Serialization;
|
||||||
|
|
@ -2174,6 +2176,212 @@ internal sealed class HikvisionAttendanceManager : IDisposable
|
||||||
return !string.IsNullOrWhiteSpace(response);
|
return !string.IsNullOrWhiteSpace(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool TrySearchUserInfoByEmployeeNoIsapi(int userId, string employeeNo, out string response, out string error)
|
||||||
|
{
|
||||||
|
response = "";
|
||||||
|
error = "";
|
||||||
|
// Try targeted search first.
|
||||||
|
var body1 = "{ \"UserInfoSearchCond\": { " +
|
||||||
|
"\"searchID\": \"1\", " +
|
||||||
|
"\"searchResultPosition\": 0, " +
|
||||||
|
"\"maxResults\": 5, " +
|
||||||
|
"\"EmployeeNoList\": [ { \"employeeNo\": \"" + EscapeJsonStatic(employeeNo) + "\" } ]" +
|
||||||
|
" } }";
|
||||||
|
var raw1 = StdXmlCall(userId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body1, out var err1);
|
||||||
|
if (!string.IsNullOrWhiteSpace(raw1))
|
||||||
|
{
|
||||||
|
response = raw1;
|
||||||
|
error = err1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for firmware that expects UserInfoSearch wrapper and no employee filter.
|
||||||
|
var body2 = "{ \"UserInfoSearch\": { " +
|
||||||
|
"\"searchID\": \"1\", " +
|
||||||
|
"\"searchResultPosition\": 0, " +
|
||||||
|
"\"maxResults\": 200 " +
|
||||||
|
" } }";
|
||||||
|
var raw2 = StdXmlCall(userId, "POST", "/ISAPI/AccessControl/UserInfo/Search?format=json", body2, out var err2);
|
||||||
|
response = raw2;
|
||||||
|
error = string.IsNullOrWhiteSpace(err2) ? err1 : err2;
|
||||||
|
return !string.IsNullOrWhiteSpace(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TrySearchUserInfoByEmployeeNoIsapiHttp(DeviceSession session, string employeeNo, out string response, out string error)
|
||||||
|
{
|
||||||
|
response = "";
|
||||||
|
error = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int httpPort = _config.IsapiHttpPort > 0 ? _config.IsapiHttpPort : 80;
|
||||||
|
string url = "http://" + session.Device.Ip + ":" + httpPort + "/ISAPI/AccessControl/UserInfo/Search?format=json";
|
||||||
|
string body = "{ \"UserInfoSearchCond\": { \"searchID\": \"1\", \"searchResultPosition\": 0, \"maxResults\": 100 } }";
|
||||||
|
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
Credentials = new NetworkCredential(session.Device.Username ?? "", session.Device.Password ?? ""),
|
||||||
|
PreAuthenticate = false,
|
||||||
|
UseDefaultCredentials = false
|
||||||
|
};
|
||||||
|
|
||||||
|
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
using var res = client.SendAsync(req).GetAwaiter().GetResult();
|
||||||
|
response = res.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
if (!res.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
error = "HTTP " + (int)res.StatusCode + " " + res.ReasonPhrase;
|
||||||
|
return !string.IsNullOrWhiteSpace(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Info("ISAPI HTTP UserInfo/Search ok: device=" + session.Device.DeviceId + ", httpPort=" + httpPort +
|
||||||
|
", responseLen=" + response.Length + ", source=HttpDigest");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
error = ex.Message;
|
||||||
|
_logger.Warn("ISAPI HTTP UserInfo/Search failed: device=" + session.Device.DeviceId + ", err=" + ex.Message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TrySearchUserInfoByEmployeeNo(DeviceSession session, string employeeNo, out string response, out string error)
|
||||||
|
{
|
||||||
|
if (_config.UseIsapiHttpForUserInfo)
|
||||||
|
{
|
||||||
|
_logger.Info("UserInfo source: ISAPI HTTP Digest (UseIsapiHttpForUserInfo=true), device=" + session.Device.DeviceId);
|
||||||
|
if (TrySearchUserInfoByEmployeeNoIsapiHttp(session, employeeNo, out response, out error))
|
||||||
|
return true;
|
||||||
|
_logger.Warn("UserInfo HTTP source failed; fallback to SDK STDXML, device=" + session.Device.DeviceId +
|
||||||
|
", err=" + (string.IsNullOrEmpty(error) ? "-" : error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrySearchUserInfoByEmployeeNoIsapi(session.UserId, employeeNo, out response, out error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryDownloadFaceImageViaIsapiHttp(DeviceSession session, string faceUrl, out byte[] imageBytes, out string error)
|
||||||
|
{
|
||||||
|
imageBytes = Array.Empty<byte>();
|
||||||
|
error = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(faceUrl))
|
||||||
|
{
|
||||||
|
error = "empty faceUrl";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int httpPort = _config.IsapiHttpPort > 0 ? _config.IsapiHttpPort : 80;
|
||||||
|
string pathAndQuery = faceUrl;
|
||||||
|
if (Uri.TryCreate(faceUrl, UriKind.Absolute, out var abs))
|
||||||
|
pathAndQuery = abs.PathAndQuery;
|
||||||
|
if (!pathAndQuery.StartsWith("/"))
|
||||||
|
pathAndQuery = "/" + pathAndQuery.TrimStart('/');
|
||||||
|
|
||||||
|
// Force configured device IP for HTTP calls.
|
||||||
|
string url = "http://" + session.Device.Ip + ":" + httpPort + pathAndQuery;
|
||||||
|
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
Credentials = new NetworkCredential(session.Device.Username ?? "", session.Device.Password ?? ""),
|
||||||
|
PreAuthenticate = false,
|
||||||
|
UseDefaultCredentials = false
|
||||||
|
};
|
||||||
|
|
||||||
|
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
using var res = client.SendAsync(req).GetAwaiter().GetResult();
|
||||||
|
imageBytes = res.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
|
||||||
|
if (!res.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
error = "HTTP " + (int)res.StatusCode + " " + res.ReasonPhrase;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return imageBytes.Length > 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
error = ex.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryExtractFaceUrlFromUserInfoSearch(string userInfoJson, string employeeNo, out string faceUrl)
|
||||||
|
{
|
||||||
|
faceUrl = "";
|
||||||
|
if (string.IsNullOrWhiteSpace(userInfoJson))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ser = new JavaScriptSerializer();
|
||||||
|
object? root = ser.DeserializeObject(userInfoJson);
|
||||||
|
if (root == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var candidateKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"faceURL", "faceUrl", "pictureURL", "pictureUrl", "photoURL", "photoUrl", "imgUrl"
|
||||||
|
};
|
||||||
|
|
||||||
|
var stack = new Stack<object>();
|
||||||
|
stack.Push(root);
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
var cur = stack.Pop();
|
||||||
|
if (cur is Dictionary<string, object> d)
|
||||||
|
{
|
||||||
|
// Prefer extracting from matching user object.
|
||||||
|
bool employeeMatches = false;
|
||||||
|
if (d.TryGetValue("employeeNo", out var enoObj) && enoObj != null)
|
||||||
|
{
|
||||||
|
var eno = enoObj.ToString() ?? "";
|
||||||
|
employeeMatches = string.Equals(eno.Trim(), employeeNo?.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employeeMatches)
|
||||||
|
{
|
||||||
|
foreach (var k in candidateKeys)
|
||||||
|
{
|
||||||
|
if (d.TryGetValue(k, out var v) && v is string s && !string.IsNullOrWhiteSpace(s))
|
||||||
|
{
|
||||||
|
faceUrl = s.Trim();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kv in d)
|
||||||
|
{
|
||||||
|
if (kv.Value is Dictionary<string, object> nd)
|
||||||
|
stack.Push(nd);
|
||||||
|
else if (kv.Value is object[] arr)
|
||||||
|
foreach (var it in arr)
|
||||||
|
if (it != null)
|
||||||
|
stack.Push(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (cur is object[] arr2)
|
||||||
|
{
|
||||||
|
foreach (var it in arr2)
|
||||||
|
if (it != null)
|
||||||
|
stack.Push(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best effort only
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private string CallCardInfoApi(int userId, string uri, string jsonBody, out string sdkError)
|
private string CallCardInfoApi(int userId, string uri, string jsonBody, out string sdkError)
|
||||||
{
|
{
|
||||||
return StdXmlCall(userId, "POST", uri, jsonBody, out sdkError);
|
return StdXmlCall(userId, "POST", uri, jsonBody, out sdkError);
|
||||||
|
|
@ -3271,6 +3479,7 @@ internal sealed class HikvisionAttendanceManager : IDisposable
|
||||||
ProbeCapabilityAndLog(session.UserId, "/ISAPI/AccessControl/CaptureFaceData/capabilities?format=json", "face.CaptureFaceData", faceSw);
|
ProbeCapabilityAndLog(session.UserId, "/ISAPI/AccessControl/CaptureFaceData/capabilities?format=json", "face.CaptureFaceData", faceSw);
|
||||||
bool fdSearchDisabledByCap = fdCapRaw.IndexOf("\"isSuportFDSearch\":\tfalse", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
bool fdSearchDisabledByCap = fdCapRaw.IndexOf("\"isSuportFDSearch\":\tfalse", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||||
fdCapRaw.IndexOf("\"isSuportFDSearch\": false", StringComparison.OrdinalIgnoreCase) >= 0;
|
fdCapRaw.IndexOf("\"isSuportFDSearch\": false", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
userPayload.faceReadbackSupported = !fdSearchDisabledByCap;
|
||||||
|
|
||||||
var faceItemDoc = new FaceTemplateExportItem();
|
var faceItemDoc = new FaceTemplateExportItem();
|
||||||
var faceDocDebug = "";
|
var faceDocDebug = "";
|
||||||
|
|
@ -3307,6 +3516,46 @@ internal sealed class HikvisionAttendanceManager : IDisposable
|
||||||
userPayload.face.error = userPayload.face.error + " ; capability indicates isSuportFDSearch=false";
|
userPayload.face.error = userPayload.face.error + " ; capability indicates isSuportFDSearch=false";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: if person export/search returns a face/picture URL, download it.
|
||||||
|
if (!userPayload.face.present &&
|
||||||
|
TrySearchUserInfoByEmployeeNo(session, cardNo.Trim(), out var userInfoRaw, out var userInfoErr) &&
|
||||||
|
TryExtractFaceUrlFromUserInfoSearch(userInfoRaw, cardNo.Trim(), out var userFaceUrl))
|
||||||
|
{
|
||||||
|
var facePath = userFaceUrl;
|
||||||
|
if (facePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
facePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (Uri.TryCreate(facePath, UriKind.Absolute, out var u))
|
||||||
|
facePath = u.PathAndQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] userFaceBytes;
|
||||||
|
string userFaceErr;
|
||||||
|
bool faceDownloaded = _config.UseIsapiHttpForUserInfo
|
||||||
|
? TryDownloadFaceImageViaIsapiHttp(session, facePath, out userFaceBytes, out userFaceErr)
|
||||||
|
: (userFaceBytes = StdXmlCallBytes(session.UserId, "GET", facePath, null, out userFaceErr)).Length > 0;
|
||||||
|
|
||||||
|
if (faceDownloaded && userFaceBytes.Length > 0)
|
||||||
|
{
|
||||||
|
userPayload.face = new FaceTemplateExportItem
|
||||||
|
{
|
||||||
|
attempted = true,
|
||||||
|
present = true,
|
||||||
|
byteLength = userFaceBytes.Length,
|
||||||
|
dataBase64 = Convert.ToBase64String(userFaceBytes),
|
||||||
|
error = ""
|
||||||
|
};
|
||||||
|
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo +
|
||||||
|
" faceSource=userInfoUrlFallback faceUrl=\"" + facePath + "\" len=" + userFaceBytes.Length);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
faceSw.WriteLine(DateTime.UtcNow.ToString("o") + " device=" + deviceId + " cardNo=" + cardNo +
|
||||||
|
" faceSource=userInfoUrlFallbackFailed faceUrl=\"" + facePath + "\" err=" +
|
||||||
|
(string.IsNullOrEmpty(userFaceErr) ? "-" : userFaceErr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!userPayload.face.present)
|
if (!userPayload.face.present)
|
||||||
{
|
{
|
||||||
// Make missing template errors explicit in the face log.
|
// Make missing template errors explicit in the face log.
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@
|
||||||
<Reference Include="System.ServiceProcess" />
|
<Reference Include="System.ServiceProcess" />
|
||||||
<!-- Needed for JavaScriptSerializer JSON parsing in STDXMLConfig diagnostics -->
|
<!-- Needed for JavaScriptSerializer JSON parsing in STDXMLConfig diagnostics -->
|
||||||
<Reference Include="System.Web.Extensions" />
|
<Reference Include="System.Web.Extensions" />
|
||||||
|
<!-- Direct ISAPI HTTP (Digest auth) calls -->
|
||||||
|
<Reference Include="System.Net.Http" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,17 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public int HistoricalFetchLookbackMinutes { get; set; } = 1440;
|
public int HistoricalFetchLookbackMinutes { get; set; } = 1440;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, user-info/faceURL lookups use direct ISAPI HTTP (Digest auth) instead of SDK STDXML wrappers.
|
||||||
|
/// Default true because some terminals return richer UserInfo fields (e.g. faceURL) over HTTP.
|
||||||
|
/// </summary>
|
||||||
|
[DataMember]
|
||||||
|
public bool UseIsapiHttpForUserInfo { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>HTTP port for direct ISAPI calls. 80 by default.</summary>
|
||||||
|
[DataMember]
|
||||||
|
public int IsapiHttpPort { get; set; } = 80;
|
||||||
|
|
||||||
public static HikvisionServiceConfig Load(string configPath)
|
public static HikvisionServiceConfig Load(string configPath)
|
||||||
{
|
{
|
||||||
// Minimal JSON config loader (no external packages).
|
// Minimal JSON config loader (no external packages).
|
||||||
|
|
@ -136,7 +147,9 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
AcsHistoryMajor = 0,
|
AcsHistoryMajor = 0,
|
||||||
AcsHistoryMinor = 0,
|
AcsHistoryMinor = 0,
|
||||||
HistoricalFetchIntervalMinutes = 0,
|
HistoricalFetchIntervalMinutes = 0,
|
||||||
HistoricalFetchLookbackMinutes = 1440
|
HistoricalFetchLookbackMinutes = 1440,
|
||||||
|
UseIsapiHttpForUserInfo = true,
|
||||||
|
IsapiHttpPort = 8000
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,7 +193,9 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
AcsHistoryMajor = 0,
|
AcsHistoryMajor = 0,
|
||||||
AcsHistoryMinor = 0,
|
AcsHistoryMinor = 0,
|
||||||
HistoricalFetchIntervalMinutes = 0,
|
HistoricalFetchIntervalMinutes = 0,
|
||||||
HistoricalFetchLookbackMinutes = 1440
|
HistoricalFetchLookbackMinutes = 1440,
|
||||||
|
UseIsapiHttpForUserInfo = true,
|
||||||
|
IsapiHttpPort = 80
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ internal sealed class UserTemplateExportPayload
|
||||||
/// <summary>Documents the actual SDK calls used in this build.</summary>
|
/// <summary>Documents the actual SDK calls used in this build.</summary>
|
||||||
public string sdkImplementationNote { get; set; } =
|
public string sdkImplementationNote { get; set; } =
|
||||||
"Face/Fingerprints exported via NET_DVR_StartRemoteConfig (preferred), with ISAPI fallback via NET_DVR_STDXMLConfig.";
|
"Face/Fingerprints exported via NET_DVR_StartRemoteConfig (preferred), with ISAPI fallback via NET_DVR_STDXMLConfig.";
|
||||||
|
/// <summary>True when device capabilities indicate enrolled face search/readback is supported.</summary>
|
||||||
|
public bool? faceReadbackSupported { get; set; }
|
||||||
|
|
||||||
public FaceTemplateExportItem face { get; set; } = new FaceTemplateExportItem();
|
public FaceTemplateExportItem face { get; set; } = new FaceTemplateExportItem();
|
||||||
public List<FingerprintTemplateExportItem> fingerprints { get; set; } = new List<FingerprintTemplateExportItem>();
|
public List<FingerprintTemplateExportItem> fingerprints { get; set; } = new List<FingerprintTemplateExportItem>();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue