Add MySQL-backed HRMS repositories and DB integration configuration.

1) Introduces DB connectivity, repository abstractions, and new config flags/env overrides.

2) Keeps architecture clean and isolated.
main
SYED MUSTUFA AHMED NAQVI 2026-04-14 17:12:10 +05:00
parent fbd4d1d313
commit 93b2bf9bd7
6 changed files with 541 additions and 6 deletions

34
Data/DbModels.cs Normal file
View File

@ -0,0 +1,34 @@
using System;
namespace HikvisionAttendanceService.Data;
internal sealed class AttendanceMachineRow
{
public string MachineId { get; set; } = "";
public string MachineIp { get; set; } = "";
public int PortNumber { get; set; } = 8000;
public string MachineName { get; set; } = "";
public int? SiteId { get; set; }
public string MachineStatus { get; set; } = "";
public string MachineType { get; set; } = "";
public string Status { get; set; } = "";
public DateTime? LastSyncDate { get; set; }
public int TotalUsers { get; set; }
}
internal sealed class AttendanceMachineUserRow
{
public string MachineId { get; set; } = "";
public string SerialNumber { get; set; } = "";
public string EmployeeName { get; set; } = "";
public bool IsDeleted { get; set; }
public bool IsDeletionRequested { get; set; }
}
internal sealed class AttendanceMachineFaceTemplateRow
{
public string SerialNo { get; set; } = "";
public byte[] Template { get; set; } = Array.Empty<byte>();
public DateTime CreatedDate { get; set; }
public bool IsActive { get; set; } = true;
}

45
Data/Interfaces.cs Normal file
View File

@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
namespace HikvisionAttendanceService.Data;
internal interface IHrmsDbConnectionFactory
{
string BuildConnectionStringMasked();
bool TryBuildConnectionString(out string connectionString, out string error);
}
internal interface IAttendanceMachineRepository
{
List<AttendanceMachineRow> GetActiveMachines(string? machineTypeFilter, out string error);
void UpdateMachineSyncState(string machineIp, string status, DateTime? lastSyncDate, int? totalUsers, out string error);
bool TryGetMachineLastSyncDate(string machineIp, out DateTime? lastSyncDate, out string error);
bool UpdateMachineLastSyncDate(string machineIp, DateTime lastSyncDate, out string error);
}
internal interface IAttendanceLogRepository
{
bool UpsertAttendance(
string acNo,
DateTime checkTime,
int processed,
string machineId,
int inOutTypeId,
string machineIp,
DateTime dateOnly,
out string error);
}
internal interface IAttendanceMachineUserRepository
{
List<AttendanceMachineUserRow> GetActiveUsersByMachine(string machineId, out string error);
List<AttendanceMachineUserRow> GetPendingDeletionUsersByMachine(string machineId, out string error);
bool UpsertMachineUser(string machineId, string serialNumber, string employeeName, out string error);
bool MarkDeleted(string machineId, string serialNumber, string updatedBy, out string error);
}
internal interface IAttendanceMachineFaceTemplateRepository
{
bool UpsertFaceTemplate(string serialNo, byte[] template, DateTime createdDate, bool isActive, out string error);
bool TryGetActiveFaceTemplateBySerial(string serialNo, out AttendanceMachineFaceTemplateRow? row, out string error);
}

View File

@ -0,0 +1,55 @@
using MySql.Data.MySqlClient;
namespace HikvisionAttendanceService.Data;
internal sealed class MySqlHrmsDbConnectionFactory : IHrmsDbConnectionFactory
{
private readonly HikvisionAttendanceWindowsService.HikvisionServiceConfig _config;
public MySqlHrmsDbConnectionFactory(HikvisionAttendanceWindowsService.HikvisionServiceConfig config)
{
_config = config;
}
public string BuildConnectionStringMasked()
{
if (TryBuildConnectionString(out var cs, out _))
{
var b = new MySqlConnectionStringBuilder(cs);
b.Password = "***";
return b.ConnectionString;
}
return "(invalid)";
}
public bool TryBuildConnectionString(out string connectionString, out string error)
{
connectionString = "";
error = "";
if (!string.IsNullOrWhiteSpace(_config.DbConnectionString))
{
connectionString = _config.DbConnectionString.Trim();
return true;
}
if (string.IsNullOrWhiteSpace(_config.DbHost) ||
string.IsNullOrWhiteSpace(_config.DbUsername) ||
string.IsNullOrWhiteSpace(_config.DbName))
{
error = "DbHost/DbUsername/DbName are required when DbConnectionString is empty.";
return false;
}
var b = new MySqlConnectionStringBuilder
{
Server = _config.DbHost.Trim(),
Port = (uint)(_config.DbPort > 0 ? _config.DbPort : 3306),
Database = _config.DbName.Trim(),
UserID = _config.DbUsername.Trim(),
Password = _config.DbPassword ?? "",
DefaultCommandTimeout = 30
};
connectionString = b.ConnectionString;
return true;
}
}

298
Data/MySqlRepositories.cs Normal file
View File

@ -0,0 +1,298 @@
using System;
using System.Collections.Generic;
using MySql.Data.MySqlClient;
namespace HikvisionAttendanceService.Data;
internal sealed class MySqlAttendanceMachineRepository : IAttendanceMachineRepository
{
private readonly IHrmsDbConnectionFactory _factory;
public MySqlAttendanceMachineRepository(IHrmsDbConnectionFactory factory) { _factory = factory; }
public List<AttendanceMachineRow> GetActiveMachines(string? machineTypeFilter, out string error)
{
error = "";
var rows = new List<AttendanceMachineRow>();
if (!_factory.TryBuildConnectionString(out var cs, out error))
return rows;
try
{
using var conn = new MySqlConnection(cs);
conn.Open();
string sql = "SELECT machine_id,machine_ip,port_number,machine_name,site_id,machine_status,machine_type,status,last_sync_date,total_users " +
"FROM attendance_machine WHERE (machine_status='active' OR machine_status='1' OR machine_status=1)";
if (!string.IsNullOrWhiteSpace(machineTypeFilter))
sql += " AND machine_type=@machineType";
using var cmd = new MySqlCommand(sql, conn);
if (!string.IsNullOrWhiteSpace(machineTypeFilter))
cmd.Parameters.AddWithValue("@machineType", machineTypeFilter);
using var rd = cmd.ExecuteReader();
while (rd.Read())
{
rows.Add(new AttendanceMachineRow
{
MachineId = rd["machine_id"]?.ToString() ?? "",
MachineIp = rd["machine_ip"]?.ToString() ?? "",
PortNumber = rd["port_number"] == DBNull.Value ? 8000 : Convert.ToInt32(rd["port_number"]),
MachineName = rd["machine_name"]?.ToString() ?? "",
SiteId = rd["site_id"] == DBNull.Value ? (int?)null : Convert.ToInt32(rd["site_id"]),
MachineStatus = rd["machine_status"]?.ToString() ?? "",
MachineType = rd["machine_type"]?.ToString() ?? "",
Status = rd["status"]?.ToString() ?? "",
LastSyncDate = rd["last_sync_date"] == DBNull.Value ? (DateTime?)null : Convert.ToDateTime(rd["last_sync_date"]),
TotalUsers = rd["total_users"] == DBNull.Value ? 0 : Convert.ToInt32(rd["total_users"])
});
}
}
catch (Exception ex) { error = ex.Message; }
return rows;
}
public void UpdateMachineSyncState(string machineIp, string status, DateTime? lastSyncDate, int? totalUsers, out string error)
{
error = "";
if (!_factory.TryBuildConnectionString(out var cs, out error))
return;
try
{
using var conn = new MySqlConnection(cs);
conn.Open();
using var cmd = new MySqlCommand(
"UPDATE attendance_machine SET status=@status,last_sync_date=COALESCE(@last_sync_date,last_sync_date),total_users=COALESCE(@total_users,total_users) WHERE machine_ip=@machine_ip",
conn);
cmd.Parameters.AddWithValue("@status", status ?? "");
cmd.Parameters.AddWithValue("@last_sync_date", (object?)lastSyncDate ?? DBNull.Value);
cmd.Parameters.AddWithValue("@total_users", (object?)totalUsers ?? DBNull.Value);
cmd.Parameters.AddWithValue("@machine_ip", machineIp ?? "");
cmd.ExecuteNonQuery();
}
catch (Exception ex) { error = ex.Message; }
}
public bool TryGetMachineLastSyncDate(string machineIp, out DateTime? lastSyncDate, out string error)
{
lastSyncDate = null;
error = "";
if (!_factory.TryBuildConnectionString(out var cs, out error))
return false;
try
{
using var conn = new MySqlConnection(cs);
conn.Open();
using var cmd = new MySqlCommand(
"SELECT last_sync_date FROM attendance_machine WHERE machine_ip=@machine_ip LIMIT 1",
conn);
cmd.Parameters.AddWithValue("@machine_ip", machineIp ?? "");
var obj = cmd.ExecuteScalar();
if (obj == null || obj == DBNull.Value)
return true;
lastSyncDate = Convert.ToDateTime(obj);
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
public bool UpdateMachineLastSyncDate(string machineIp, DateTime lastSyncDate, out string error)
{
error = "";
if (!_factory.TryBuildConnectionString(out var cs, out error))
return false;
try
{
using var conn = new MySqlConnection(cs);
conn.Open();
using var cmd = new MySqlCommand(
"UPDATE attendance_machine SET last_sync_date=@last_sync_date WHERE machine_ip=@machine_ip",
conn);
cmd.Parameters.AddWithValue("@last_sync_date", lastSyncDate);
cmd.Parameters.AddWithValue("@machine_ip", machineIp ?? "");
cmd.ExecuteNonQuery();
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}
internal sealed class MySqlAttendanceLogRepository : IAttendanceLogRepository
{
private readonly IHrmsDbConnectionFactory _factory;
public MySqlAttendanceLogRepository(IHrmsDbConnectionFactory factory) { _factory = factory; }
public bool UpsertAttendance(string acNo, DateTime checkTime, int processed, string machineId, int inOutTypeId, string machineIp, DateTime dateOnly, out string error)
{
error = "";
if (!_factory.TryBuildConnectionString(out var cs, out error))
return false;
try
{
using var conn = new MySqlConnection(cs);
conn.Open();
using var cmd = new MySqlCommand(
"REPLACE INTO attendance_log (ac_no,checktime,processed,machine_id,in_out_type_id,machine_ip,date) " +
"VALUES (@ac_no,@checktime,@processed,@machine_id,@in_out_type_id,@machine_ip,@date)",
conn);
cmd.Parameters.AddWithValue("@ac_no", acNo ?? "");
cmd.Parameters.AddWithValue("@checktime", checkTime);
cmd.Parameters.AddWithValue("@processed", processed);
cmd.Parameters.AddWithValue("@machine_id", machineId ?? "");
cmd.Parameters.AddWithValue("@in_out_type_id", inOutTypeId);
cmd.Parameters.AddWithValue("@machine_ip", machineIp ?? "");
cmd.Parameters.AddWithValue("@date", dateOnly.Date);
cmd.ExecuteNonQuery();
return true;
}
catch (Exception ex) { error = ex.Message; return false; }
}
}
internal sealed class MySqlAttendanceMachineUserRepository : IAttendanceMachineUserRepository
{
private readonly IHrmsDbConnectionFactory _factory;
public MySqlAttendanceMachineUserRepository(IHrmsDbConnectionFactory factory) { _factory = factory; }
public List<AttendanceMachineUserRow> GetActiveUsersByMachine(string machineId, out string error) =>
QueryUsers(machineId, false, out error);
public List<AttendanceMachineUserRow> GetPendingDeletionUsersByMachine(string machineId, out string error) =>
QueryUsers(machineId, true, out error);
private List<AttendanceMachineUserRow> QueryUsers(string machineId, bool deletionRequested, out string error)
{
error = "";
var rows = new List<AttendanceMachineUserRow>();
if (!_factory.TryBuildConnectionString(out var cs, out error))
return rows;
try
{
using var conn = new MySqlConnection(cs);
conn.Open();
using var cmd = new MySqlCommand(
"SELECT machine_id,serial_number,employee_name,is_deleted,is_deletion_requested FROM attendance_machine_user " +
"WHERE machine_id=@machine_id AND is_deleted=0 AND is_deletion_requested=@is_deletion_requested",
conn);
cmd.Parameters.AddWithValue("@machine_id", machineId ?? "");
cmd.Parameters.AddWithValue("@is_deletion_requested", deletionRequested ? 1 : 0);
using var rd = cmd.ExecuteReader();
while (rd.Read())
{
rows.Add(new AttendanceMachineUserRow
{
MachineId = rd["machine_id"]?.ToString() ?? "",
SerialNumber = rd["serial_number"]?.ToString() ?? "",
EmployeeName = rd["employee_name"]?.ToString() ?? "",
IsDeleted = Convert.ToInt32(rd["is_deleted"]) != 0,
IsDeletionRequested = Convert.ToInt32(rd["is_deletion_requested"]) != 0
});
}
}
catch (Exception ex) { error = ex.Message; }
return rows;
}
public bool UpsertMachineUser(string machineId, string serialNumber, string employeeName, out string error)
{
error = "";
if (!_factory.TryBuildConnectionString(out var cs, out error))
return false;
try
{
using var conn = new MySqlConnection(cs);
conn.Open();
using var cmd = new MySqlCommand(
"REPLACE INTO attendance_machine_user (machine_id,serial_number,employee_name,is_deleted,is_deletion_requested,updated_at) " +
"VALUES (@machine_id,@serial_number,@employee_name,0,0,NOW())", conn);
cmd.Parameters.AddWithValue("@machine_id", machineId ?? "");
cmd.Parameters.AddWithValue("@serial_number", serialNumber ?? "");
cmd.Parameters.AddWithValue("@employee_name", employeeName ?? "");
cmd.ExecuteNonQuery();
return true;
}
catch (Exception ex) { error = ex.Message; return false; }
}
public bool MarkDeleted(string machineId, string serialNumber, string updatedBy, out string error)
{
error = "";
if (!_factory.TryBuildConnectionString(out var cs, out error))
return false;
try
{
using var conn = new MySqlConnection(cs);
conn.Open();
using var cmd = new MySqlCommand(
"UPDATE attendance_machine_user SET is_deleted=1,is_deletion_requested=0,updated_by=@updated_by,updated_at=NOW() " +
"WHERE machine_id=@machine_id AND serial_number=@serial_number", conn);
cmd.Parameters.AddWithValue("@updated_by", updatedBy ?? "hikvision-service");
cmd.Parameters.AddWithValue("@machine_id", machineId ?? "");
cmd.Parameters.AddWithValue("@serial_number", serialNumber ?? "");
cmd.ExecuteNonQuery();
return true;
}
catch (Exception ex) { error = ex.Message; return false; }
}
}
internal sealed class MySqlAttendanceMachineFaceTemplateRepository : IAttendanceMachineFaceTemplateRepository
{
private readonly IHrmsDbConnectionFactory _factory;
public MySqlAttendanceMachineFaceTemplateRepository(IHrmsDbConnectionFactory factory) { _factory = factory; }
public bool UpsertFaceTemplate(string serialNo, byte[] template, DateTime createdDate, bool isActive, out string error)
{
error = "";
if (!_factory.TryBuildConnectionString(out var cs, out error))
return false;
try
{
using var conn = new MySqlConnection(cs);
conn.Open();
using var cmd = new MySqlCommand(
"REPLACE INTO attendance_machine_face_templates (serial_no,template,created_date,is_active) " +
"VALUES (@serial_no,@template,@created_date,@is_active)", conn);
cmd.Parameters.AddWithValue("@serial_no", serialNo ?? "");
cmd.Parameters.Add("@template", MySqlDbType.Blob).Value = (object?)template ?? Array.Empty<byte>();
cmd.Parameters.AddWithValue("@created_date", createdDate);
cmd.Parameters.AddWithValue("@is_active", isActive ? 1 : 0);
cmd.ExecuteNonQuery();
return true;
}
catch (Exception ex) { error = ex.Message; return false; }
}
public bool TryGetActiveFaceTemplateBySerial(string serialNo, out AttendanceMachineFaceTemplateRow? row, out string error)
{
row = null;
error = "";
if (!_factory.TryBuildConnectionString(out var cs, out error))
return false;
try
{
using var conn = new MySqlConnection(cs);
conn.Open();
using var cmd = new MySqlCommand(
"SELECT serial_no,template,created_date,is_active FROM attendance_machine_face_templates " +
"WHERE serial_no=@serial_no AND is_active=1 ORDER BY created_date DESC LIMIT 1", conn);
cmd.Parameters.AddWithValue("@serial_no", serialNo ?? "");
using var rd = cmd.ExecuteReader();
if (!rd.Read())
return false;
row = new AttendanceMachineFaceTemplateRow
{
SerialNo = rd["serial_no"]?.ToString() ?? "",
Template = rd["template"] == DBNull.Value ? Array.Empty<byte>() : (byte[])rd["template"],
CreatedDate = rd["created_date"] == DBNull.Value ? DateTime.MinValue : Convert.ToDateTime(rd["created_date"]),
IsActive = rd["is_active"] != DBNull.Value && Convert.ToInt32(rd["is_active"]) != 0
};
return true;
}
catch (Exception ex) { error = ex.Message; return false; }
}
}

View File

@ -22,13 +22,10 @@
<ItemGroup>
<!-- Hikvision interop (imported directly from the SDK demo source files) -->
<Compile Include="D:\EN-HCNetSDKV6.1.9.48_build20230410_win32\EN-HCNetSDKV6.1.9.48_build20230410_win32\C# demo\8-ACS_Optimization_ALL\EventByDeploy\EventByDeploy\HCNetSDK.cs"
Link="Interop\EventByDeploy\HCNetSDK.cs" />
<Compile Include="D:\EN-HCNetSDKV6.1.9.48_build20230410_win32\EN-HCNetSDKV6.1.9.48_build20230410_win32\C# demo\8-ACS_Optimization_ALL\EventByDeploy\EventByDeploy\TypeMap.cs"
Link="Interop\EventByDeploy\TypeMap.cs" />
<Compile Include="D:\EN-HCNetSDKV6.1.9.48_build20230410_win32\EN-HCNetSDKV6.1.9.48_build20230410_win32\C# demo\8-ACS_Optimization_ALL\EventByDeploy\EventByDeploy\HCNetSDK.cs" Link="Interop\EventByDeploy\HCNetSDK.cs" />
<Compile Include="D:\EN-HCNetSDKV6.1.9.48_build20230410_win32\EN-HCNetSDKV6.1.9.48_build20230410_win32\C# demo\8-ACS_Optimization_ALL\EventByDeploy\EventByDeploy\TypeMap.cs" Link="Interop\EventByDeploy\TypeMap.cs" />
<!-- Login_V30 + core callback helpers -->
<Compile Include="D:\EN-HCNetSDKV6.1.9.48_build20230410_win32\EN-HCNetSDKV6.1.9.48_build20230410_win32\C# demo\9-AppsDemo_build20201230191326\CommonBase\Head\HCNetSDK.cs"
Link="Interop\AppsDemo\CommonBase\HCNetSDK_LoginV30.cs" />
<Compile Include="D:\EN-HCNetSDKV6.1.9.48_build20230410_win32\EN-HCNetSDKV6.1.9.48_build20230410_win32\C# demo\9-AppsDemo_build20201230191326\CommonBase\Head\HCNetSDK.cs" Link="Interop\AppsDemo\CommonBase\HCNetSDK_LoginV30.cs" />
</ItemGroup>
<ItemGroup>
@ -51,4 +48,10 @@
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MySql.Data">
<Version>9.6.0</Version>
</PackageReference>
</ItemGroup>
</Project>

View File

@ -78,6 +78,48 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
[DataMember]
public string SqlConnectionString { get; set; } = "";
[DataMember]
public bool EnableDbIntegration { get; set; }
[DataMember]
public bool EnableDbMachineLoading { get; set; }
[DataMember]
public bool PreferDbMachinesOverConfig { get; set; }
[DataMember]
public bool EnableAttendanceDbPersistence { get; set; }
[DataMember]
public bool EnableTemplateDbPersistence { get; set; }
[DataMember]
public bool EnableTemplateDbToDeviceSync { get; set; }
[DataMember]
public bool KeepAttendanceFileExport { get; set; } = true;
[DataMember]
public bool KeepTemplateFiles { get; set; } = true;
[DataMember]
public string DbHost { get; set; } = "";
[DataMember]
public int DbPort { get; set; } = 3306;
[DataMember]
public string DbName { get; set; } = "hrms";
[DataMember]
public string DbUsername { get; set; } = "";
[DataMember]
public string DbPassword { get; set; } = "";
[DataMember]
public string DbConnectionString { get; set; } = "";
/// <summary>When true and SqlConnectionString is set, attendance rows are INSERTed. Default false until schema is finalized.</summary>
[DataMember]
public bool EnableDatabasePersistence { get; set; }
@ -177,6 +219,20 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
{
LogDirectory = @"C:\Users\Public\HikvisionAttendanceService",
SqlConnectionString = "",
EnableDbIntegration = false,
EnableDbMachineLoading = false,
PreferDbMachinesOverConfig = false,
EnableAttendanceDbPersistence = false,
EnableTemplateDbPersistence = false,
EnableTemplateDbToDeviceSync = false,
KeepAttendanceFileExport = true,
KeepTemplateFiles = true,
DbHost = "",
DbPort = 3306,
DbName = "hrms",
DbUsername = "",
DbPassword = "",
DbConnectionString = "",
EnableDatabasePersistence = false,
AttendanceTextFilePath = "",
AttendanceTableName = "dbo.HikvisionAttendanceEvents",
@ -211,6 +267,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(raw)))
{
var cfg = (HikvisionServiceConfig)serializer.ReadObject(ms);
ApplyEnvironmentOverrides(cfg);
NormalizeLoadedDeviceConfig(cfg);
return cfg;
}
@ -222,6 +279,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(sanitized)))
{
var cfg = (HikvisionServiceConfig)serializer.ReadObject(ms);
ApplyEnvironmentOverrides(cfg);
NormalizeLoadedDeviceConfig(cfg);
return cfg;
}
@ -233,6 +291,20 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
{
LogDirectory = @"C:\Users\Public\HikvisionAttendanceService",
SqlConnectionString = "",
EnableDbIntegration = false,
EnableDbMachineLoading = false,
PreferDbMachinesOverConfig = false,
EnableAttendanceDbPersistence = false,
EnableTemplateDbPersistence = false,
EnableTemplateDbToDeviceSync = false,
KeepAttendanceFileExport = true,
KeepTemplateFiles = true,
DbHost = "",
DbPort = 3306,
DbName = "hrms",
DbUsername = "",
DbPassword = "",
DbConnectionString = "",
EnableDatabasePersistence = false,
AttendanceTextFilePath = "",
AttendanceTableName = "dbo.HikvisionAttendanceEvents",
@ -347,6 +419,34 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
return sb.ToString();
}
private static void ApplyEnvironmentOverrides(HikvisionServiceConfig cfg)
{
if (cfg == null)
return;
string ReadEnv(string key) => Environment.GetEnvironmentVariable(key) ?? "";
var cs = ReadEnv("HIKVISION_DB_CONNECTION_STRING");
var host = ReadEnv("HIKVISION_DB_HOST");
var user = ReadEnv("HIKVISION_DB_USERNAME");
var pass = ReadEnv("HIKVISION_DB_PASSWORD");
var dbName = ReadEnv("HIKVISION_DB_NAME");
var dbPort = ReadEnv("HIKVISION_DB_PORT");
if (!string.IsNullOrWhiteSpace(cs))
cfg.DbConnectionString = cs.Trim();
if (!string.IsNullOrWhiteSpace(host))
cfg.DbHost = host.Trim();
if (!string.IsNullOrWhiteSpace(user))
cfg.DbUsername = user.Trim();
if (!string.IsNullOrWhiteSpace(pass))
cfg.DbPassword = pass;
if (!string.IsNullOrWhiteSpace(dbName))
cfg.DbName = dbName.Trim();
if (int.TryParse(dbPort, out var parsedPort) && parsedPort > 0)
cfg.DbPort = parsedPort;
}
private static void NormalizeLoadedDeviceConfig(HikvisionServiceConfig? cfg)
{
if (cfg == null)