face read support added

main
SYED MUSTUFA AHMED NAQVI 2026-04-09 18:14:54 +05:00
parent 2455f71296
commit 0100f4c5f1
4 changed files with 270 additions and 2 deletions

View File

@ -7,6 +7,8 @@ using System.Globalization;
using System.Runtime.InteropServices;
using System.Data.SqlClient;
using System.Text;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
@ -2174,6 +2176,212 @@ internal sealed class HikvisionAttendanceManager : IDisposable
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)
{
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);
bool fdSearchDisabledByCap = fdCapRaw.IndexOf("\"isSuportFDSearch\":\tfalse", StringComparison.OrdinalIgnoreCase) >= 0 ||
fdCapRaw.IndexOf("\"isSuportFDSearch\": false", StringComparison.OrdinalIgnoreCase) >= 0;
userPayload.faceReadbackSupported = !fdSearchDisabledByCap;
var faceItemDoc = new FaceTemplateExportItem();
var faceDocDebug = "";
@ -3307,6 +3516,46 @@ internal sealed class HikvisionAttendanceManager : IDisposable
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)
{
// Make missing template errors explicit in the face log.

View File

@ -38,6 +38,8 @@
<Reference Include="System.ServiceProcess" />
<!-- Needed for JavaScriptSerializer JSON parsing in STDXMLConfig diagnostics -->
<Reference Include="System.Web.Extensions" />
<!-- Direct ISAPI HTTP (Digest auth) calls -->
<Reference Include="System.Net.Http" />
</ItemGroup>
</Project>

View File

@ -116,6 +116,17 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
[DataMember]
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)
{
// Minimal JSON config loader (no external packages).
@ -136,7 +147,9 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
AcsHistoryMajor = 0,
AcsHistoryMinor = 0,
HistoricalFetchIntervalMinutes = 0,
HistoricalFetchLookbackMinutes = 1440
HistoricalFetchLookbackMinutes = 1440,
UseIsapiHttpForUserInfo = true,
IsapiHttpPort = 8000
};
}
@ -180,7 +193,9 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
AcsHistoryMajor = 0,
AcsHistoryMinor = 0,
HistoricalFetchIntervalMinutes = 0,
HistoricalFetchLookbackMinutes = 1440
HistoricalFetchLookbackMinutes = 1440,
UseIsapiHttpForUserInfo = true,
IsapiHttpPort = 80
};
}
}

View File

@ -11,6 +11,8 @@ internal sealed class UserTemplateExportPayload
/// <summary>Documents the actual SDK calls used in this build.</summary>
public string sdkImplementationNote { get; set; } =
"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 List<FingerprintTemplateExportItem> fingerprints { get; set; } = new List<FingerprintTemplateExportItem>();