diff --git a/EmployeePhotoSourceSettings.cs b/EmployeePhotoSourceSettings.cs
new file mode 100644
index 0000000..9a352ec
--- /dev/null
+++ b/EmployeePhotoSourceSettings.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Configuration;
+
+namespace HanvonF710XAttendanceService
+{
+ internal static class EmployeePhotoSourceSettings
+ {
+ public static bool IsEnabled()
+ {
+ return GetBool("EnableEmployeePhotoSource", false);
+ }
+
+ public static string GetBaseUrl()
+ {
+ return ConfigurationManager.AppSettings["EmployeePhotoBaseUrl"]?.Trim() ?? "";
+ }
+
+ public static int GetMinShortSide()
+ {
+ return GetInt("HRMS_PHOTO_MIN_SHORT_SIDE", 80);
+ }
+
+ public static int GetMaxShortSide()
+ {
+ return GetInt("HRMS_PHOTO_MAX_SHORT_SIDE", 4096);
+ }
+
+ public static int GetTimeoutMs()
+ {
+ return GetInt("HRMS_PHOTO_HTTP_TIMEOUT_MS", 30000);
+ }
+
+ private static bool GetBool(string key, bool defaultValue)
+ {
+ try
+ {
+ var raw = ConfigurationManager.AppSettings[key];
+ if (string.IsNullOrWhiteSpace(raw)) return defaultValue;
+ return bool.TryParse(raw.Trim(), out var v) ? v : defaultValue;
+ }
+ catch
+ {
+ return defaultValue;
+ }
+ }
+
+ private static int GetInt(string key, int defaultValue)
+ {
+ try
+ {
+ var raw = ConfigurationManager.AppSettings[key];
+ if (string.IsNullOrWhiteSpace(raw)) return defaultValue;
+ return int.TryParse(raw.Trim(), out var v) && v > 0 ? v : defaultValue;
+ }
+ catch
+ {
+ return defaultValue;
+ }
+ }
+ }
+}
diff --git a/HrmsEmployeePhotoClient.cs b/HrmsEmployeePhotoClient.cs
new file mode 100644
index 0000000..75a36a8
--- /dev/null
+++ b/HrmsEmployeePhotoClient.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Drawing;
+using System.IO;
+using System.Net;
+
+namespace HanvonF710XAttendanceService
+{
+ internal sealed class HrmsEmployeePhotoDownloadResult
+ {
+ public bool Success { get; set; }
+ public int StatusCode { get; set; }
+ public string ContentType { get; set; }
+ public string Url { get; set; }
+ public byte[] ImageBytes { get; set; }
+ public int Width { get; set; }
+ public int Height { get; set; }
+ public int Base64Length { get; set; }
+ public string Error { get; set; }
+ }
+
+ internal static class HrmsEmployeePhotoClient
+ {
+ /// employees.id used in the portal photo path. Not the device serial.
+ public static string BuildPhotoUrl(string baseUrl, string hrmsEmployeeId)
+ {
+ if (string.IsNullOrWhiteSpace(baseUrl))
+ {
+ throw new ArgumentException("Employee photo base URL is required.");
+ }
+
+ if (string.IsNullOrWhiteSpace(hrmsEmployeeId))
+ {
+ throw new ArgumentException("HRMS employee ID (employees.id) is required.");
+ }
+
+ string trimmedBase = baseUrl.Trim();
+ if (!trimmedBase.EndsWith("/", StringComparison.Ordinal))
+ {
+ trimmedBase += "/";
+ }
+
+ return trimmedBase + hrmsEmployeeId.Trim() + ".jpeg";
+ }
+
+ /// employees.id for EmployeePhotoBaseUrl + id + ".jpeg".
+ public static bool TryDownloadEmployeePhoto(string hrmsEmployeeId, out HrmsEmployeePhotoDownloadResult result)
+ {
+ result = new HrmsEmployeePhotoDownloadResult();
+ if (!EmployeePhotoSourceSettings.IsEnabled())
+ {
+ result.Error = "employee_photo_source_disabled";
+ return false;
+ }
+
+ string baseUrl = EmployeePhotoSourceSettings.GetBaseUrl();
+ if (string.IsNullOrWhiteSpace(baseUrl))
+ {
+ result.Error = "employee_photo_base_url_missing";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(hrmsEmployeeId))
+ {
+ result.Error = "hrms_employee_id_missing";
+ return false;
+ }
+
+ result.Url = BuildPhotoUrl(baseUrl, hrmsEmployeeId);
+ int timeoutMs = EmployeePhotoSourceSettings.GetTimeoutMs();
+
+ try
+ {
+ var request = (HttpWebRequest)WebRequest.Create(result.Url);
+ request.Method = "GET";
+ request.Timeout = timeoutMs;
+ request.ReadWriteTimeout = timeoutMs;
+ request.AllowAutoRedirect = true;
+ request.UserAgent = "HanvonF710XAttendanceService/1.0";
+
+ using (var response = (HttpWebResponse)request.GetResponse())
+ using (var stream = response.GetResponseStream())
+ using (var memory = new MemoryStream())
+ {
+ result.StatusCode = (int)response.StatusCode;
+ result.ContentType = response.ContentType;
+
+ if (response.StatusCode != HttpStatusCode.OK)
+ {
+ result.Error = "http_status_" + result.StatusCode;
+ return false;
+ }
+
+ if (stream == null)
+ {
+ result.Error = "empty_response_stream";
+ return false;
+ }
+
+ stream.CopyTo(memory);
+ result.ImageBytes = memory.ToArray();
+ }
+ }
+ catch (WebException wex)
+ {
+ if (wex.Response is HttpWebResponse errResp)
+ {
+ result.StatusCode = (int)errResp.StatusCode;
+ result.ContentType = errResp.ContentType;
+ }
+
+ result.Error = wex.Message;
+ return false;
+ }
+ catch (Exception ex)
+ {
+ result.Error = ex.Message;
+ return false;
+ }
+
+ if (!TryValidateDownloadedImage(result, out string validationError))
+ {
+ result.Error = validationError;
+ result.ImageBytes = null;
+ return false;
+ }
+
+ result.Base64Length = Convert.ToBase64String(result.ImageBytes).Length;
+ result.Success = true;
+ return true;
+ }
+
+ public static bool TryValidateDownloadedImage(HrmsEmployeePhotoDownloadResult result, out string error)
+ {
+ error = null;
+ byte[] bytes = result?.ImageBytes;
+ if (bytes == null || bytes.Length == 0)
+ {
+ error = "empty_image";
+ return false;
+ }
+
+ if (bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8)
+ {
+ error = "not_jpeg";
+ return false;
+ }
+
+ if (!NedoPhotoPreprocessor.IsCompleteJpeg(bytes))
+ {
+ error = "incomplete_jpeg";
+ return false;
+ }
+
+ string contentType = result.ContentType ?? "";
+ if (!string.IsNullOrWhiteSpace(contentType)
+ && contentType.IndexOf("image", StringComparison.OrdinalIgnoreCase) < 0
+ && contentType.IndexOf("jpeg", StringComparison.OrdinalIgnoreCase) < 0
+ && contentType.IndexOf("octet-stream", StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ error = "invalid_content_type";
+ return false;
+ }
+
+ try
+ {
+ using (var input = new MemoryStream(bytes))
+ using (var image = Image.FromStream(input))
+ {
+ result.Width = image.Width;
+ result.Height = image.Height;
+ }
+ }
+ catch (Exception ex)
+ {
+ error = "invalid_image_data:" + ex.Message;
+ return false;
+ }
+
+ int minShort = EmployeePhotoSourceSettings.GetMinShortSide();
+ int maxShort = EmployeePhotoSourceSettings.GetMaxShortSide();
+ int shortSide = Math.Min(result.Width, result.Height);
+ int longSide = Math.Max(result.Width, result.Height);
+
+ if (shortSide < minShort)
+ {
+ error = "image_too_small";
+ return false;
+ }
+
+ if (longSide > maxShort)
+ {
+ error = "image_too_large";
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/HrmsEmployeePhotoClientTests.cs b/HrmsEmployeePhotoClientTests.cs
new file mode 100644
index 0000000..50b340b
--- /dev/null
+++ b/HrmsEmployeePhotoClientTests.cs
@@ -0,0 +1,52 @@
+using System;
+
+namespace HanvonF710XAttendanceService
+{
+ internal static class HrmsEmployeePhotoClientTests
+ {
+ public static int RunAll()
+ {
+ int failed = 0;
+ failed += Run("1 build portal photo URL", TestBuildPhotoUrl);
+ failed += Run("2 disabled source returns false", TestDisabledSource);
+ return failed;
+ }
+
+ private static int Run(string name, Func test)
+ {
+ try
+ {
+ if (!test())
+ {
+ Console.WriteLine("FAIL: " + name);
+ return 1;
+ }
+
+ Console.WriteLine("PASS: " + name);
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("FAIL: " + name + " err=" + ex.Message);
+ return 1;
+ }
+ }
+
+ private static bool TestBuildPhotoUrl()
+ {
+ // Photo path uses employees.id (84321), not device serial (15057).
+ string url = HrmsEmployeePhotoClient.BuildPhotoUrl(
+ "https://portal.utopiaindustries.pk/uind/employee-photo/",
+ "84321");
+ return string.Equals(
+ url,
+ "https://portal.utopiaindustries.pk/uind/employee-photo/84321.jpeg",
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool TestDisabledSource()
+ {
+ return !string.IsNullOrWhiteSpace(EmployeePhotoSourceSettings.GetBaseUrl());
+ }
+ }
+}