using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Xml.Linq;
using HC_APTBS.Models;
using Peak.Can.Basic;
namespace HC_APTBS.Services.Impl
{
///
/// XML-backed implementation of .
/// All configuration files live under %UserProfile%\.HC_APTBS\config\.
///
public sealed class ConfigurationService : IConfigurationService
{
// ── Paths ─────────────────────────────────────────────────────────────────
private static readonly string ConfigFolder =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".HC_APTBS", "config");
private string ConfigXml => Path.Combine(ConfigFolder, "config.xml");
private string PumpsXml => Path.Combine(ConfigFolder, "pumps.xml");
private string BenchXml => Path.Combine(ConfigFolder, "bench.xml");
private string SensorsXml => Path.Combine(ConfigFolder, "sensors.xml");
private string ClientsXml => Path.Combine(ConfigFolder, "clients.xml");
private string AlarmsXml => Path.Combine(ConfigFolder, "alarms.xml");
private string StatusXml => Path.Combine(ConfigFolder, "status.xml");
private readonly IAppLogger _log;
private const string LogId = "ConfigurationService";
// ── Cached instances ──────────────────────────────────────────────────────
private AppSettings? _settings;
private BenchConfiguration? _bench;
private SortedDictionary? _clients;
private readonly Dictionary _statusCache = new();
// ── Constructor ───────────────────────────────────────────────────────────
/// Application logger.
public ConfigurationService(IAppLogger logger)
{
_log = logger;
Directory.CreateDirectory(ConfigFolder);
}
// ── IConfigurationService: Settings ───────────────────────────────────────
///
public AppSettings Settings
{
get
{
if (_settings == null) LoadSettings();
return _settings!;
}
}
///
public void SaveSettings()
{
try
{
var root = new XElement("Config",
new XElement("TMax", Settings.TempMax),
new XElement("TMin", Settings.TempMin),
new XElement("RefreshBenchInterface", Settings.RefreshBenchInterfaceMs),
new XElement("RefreshWhileReading", Settings.RefreshWhileReadingMs),
new XElement("RefreshCanBusRead", Settings.RefreshCanBusReadMs),
new XElement("RefreshPumpRequest", Settings.RefreshPumpRequestMs),
new XElement("RefreshPumpParams", Settings.RefreshPumpParamsMs),
new XElement("BlinkInterval", Settings.BlinkIntervalMs),
new XElement("FlasherInterval", Settings.FlasherIntervalMs),
new XElement("PidP", Settings.PidP),
new XElement("PidI", Settings.PidI),
new XElement("PidD", Settings.PidD),
new XElement("PidMs", Settings.PidLoopMs),
new XElement("SecurityRpm", Settings.SecurityRpmLimit),
new XElement("MaxPressure", Settings.MaxPressureBar),
new XElement("ToleranceUp", Settings.ToleranceUpExtension),
new XElement("TolerancePfp", Settings.TolerancePfpExtension),
new XElement("EncoderResolution", Settings.EncoderResolution),
new XElement("VoltageForMaxRpm", Settings.VoltageForMaxRpm),
new XElement("MaxRpm", Settings.MaxRpm),
new XElement("RightRelayValue", Settings.RightRelayValue),
new XElement("DefaultIgnoreTin", Settings.DefaultIgnoreTin),
new XElement("LastRotationDir", Settings.LastRotationDirection),
new XElement("DaysKeepLogs", Settings.DaysKeepLogs),
new XElement("CompanyName", Settings.CompanyName),
new XElement("CompanyInfo", Settings.CompanyInfo),
new XElement("ReportLogoPath", Settings.ReportLogoPath),
new XElement("KLinePort", Settings.KLinePort),
new XElement("Language", Settings.Language),
new XElement("Relations", RpmVoltageRelation.Serialise(Settings.Relations)),
new XElement("Users", Settings.Users)
);
new XDocument(root).Save(ConfigXml);
SaveSensors();
SaveClients();
_log.Info(LogId, "Settings saved.");
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveSettings failed: {ex.Message}");
}
}
// ── IConfigurationService: Bench ──────────────────────────────────────────
///
public BenchConfiguration Bench
{
get
{
if (_bench == null) LoadBench();
return _bench!;
}
}
///
public void SaveBench()
{
try
{
var root = new XElement("Bench");
foreach (var param in Bench.ParametersByName.Values)
root.Add(param.ToXml());
var relesNode = new XElement("Reles");
foreach (var relay in Bench.Relays.Values)
relesNode.Add(relay.ToXml());
root.Add(relesNode);
new XDocument(root).Save(BenchXml);
_log.Info(LogId, "Bench configuration saved.");
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveBench failed: {ex.Message}");
}
}
// ── IConfigurationService: Pumps ──────────────────────────────────────────
///
public IReadOnlyList GetPumpIds()
{
var ids = new List();
string source = File.Exists(PumpsXml) ? PumpsXml
: File.Exists(ConfigXml) ? ConfigXml
: null!;
if (source == null) return ids;
try
{
var xdoc = XDocument.Load(source);
foreach (var xid in xdoc.Descendants("PumpID"))
ids.Add(xid.Value);
}
catch (Exception ex)
{
_log.Error(LogId, $"GetPumpIds failed: {ex.Message}");
}
return ids;
}
///
public PumpDefinition? LoadPump(string pumpId)
{
string source = File.Exists(PumpsXml) ? PumpsXml
: File.Exists(ConfigXml) ? ConfigXml
: null!;
if (source == null) return null;
try
{
var xdoc = XDocument.Load(source);
var xpump = xdoc.XPathSelectElement($"/Config/Pumps/Pump[@id='{pumpId}']");
if (xpump == null) return null;
return ParsePumpElement(xpump);
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadPump({pumpId}) failed: {ex.Message}");
return null;
}
}
///
public void SavePump(PumpDefinition pump)
{
try
{
XDocument xdoc;
if (File.Exists(PumpsXml))
{
xdoc = XDocument.Load(PumpsXml);
}
else
{
xdoc = new XDocument(new XElement("Config", new XElement("Pumps")));
}
var root = xdoc.Root!;
var pumpsNode = root.Element("Pumps");
if (pumpsNode == null)
{
pumpsNode = new XElement("Pumps");
root.Add(pumpsNode);
}
// Build the element mirroring ParsePumpElement's expected format.
var xpump = new XElement("Pump",
new XAttribute("id", pump.Id),
new XAttribute("model", pump.Model),
new XAttribute("text", pump.EcuText),
new XAttribute("chaveta", pump.Chaveta),
new XAttribute("rotation", pump.Rotation),
new XAttribute("info", pump.Info),
new XAttribute("preinjection", pump.HasPreInjection.ToString().ToLowerInvariant()),
new XAttribute("cilinders4", pump.Is4Cylinder.ToString().ToLowerInvariant()),
new XAttribute("unlock", pump.UnlockType),
new XAttribute("baudrate", pump.CanBaudrate == Peak.Can.Basic.TPCANBaudrate.PCAN_BAUD_250K ? "250" : "500"),
new XAttribute("lockangle", pump.LockAngle.ToString(CultureInfo.InvariantCulture)));
// PumpID child element — GetPumpIds() finds these via Descendants("PumpID").
xpump.Add(new XElement("PumpID", pump.Id));
// ── Serialise (pump CAN params use legacy P1–P6 format) ──
if (pump.ParametersByName.Count > 0)
{
var xparams = new XElement("Params");
foreach (var param in pump.ParametersByName.Values)
xparams.Add(param.ToPumpXml());
xpump.Add(xparams);
}
// ── Serialise ──
if (pump.Tests.Count > 0)
{
var xtests = new XElement("Tests");
foreach (var test in pump.Tests)
xtests.Add(test.ToXml());
xpump.Add(xtests);
}
// ── Find existing pump by ID and replace, or append ──
XElement? existing = null;
foreach (var child in pumpsNode.Elements("Pump"))
{
if (child.Attribute("id")?.Value == pump.Id)
{
existing = child;
break;
}
}
if (existing != null)
existing.ReplaceWith(xpump);
else
pumpsNode.Add(xpump);
xdoc.Save(PumpsXml);
_log.Info(LogId, $"SavePump({pump.Id}) — saved to pumps.xml.");
}
catch (Exception ex)
{
_log.Error(LogId, $"SavePump({pump.Id}) failed: {ex.Message}");
}
}
// ── IConfigurationService: Clients ────────────────────────────────────────
///
public SortedDictionary Clients
{
get
{
if (_clients == null) LoadClients();
return _clients!;
}
}
///
public void SaveClients()
{
try
{
var root = new XElement("Clients");
foreach (var kv in Clients)
root.Add(new XElement("client",
new XAttribute("name", kv.Key),
new XAttribute("contact", kv.Value)));
new XDocument(root).Save(ClientsXml);
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveClients failed: {ex.Message}");
}
}
// ── IConfigurationService: Sensors ────────────────────────────────────────
///
public void SaveSensors()
{
try
{
var root = new XElement("Sensors");
foreach (var s in Settings.Sensors.Values)
root.Add(s.ToXml());
new XDocument(root).Save(SensorsXml);
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveSensors failed: {ex.Message}");
}
}
// ── IConfigurationService: Alarms ─────────────────────────────────────────
///
public void SaveAlarms()
{
try
{
var root = new XElement("Alarms");
foreach (var alarm in Settings.Alarms)
root.Add(alarm.ToXml());
new XDocument(root).Save(AlarmsXml);
_log.Info(LogId, "Alarms saved.");
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveAlarms failed: {ex.Message}");
}
}
// ── Pump status definitions ───────────────────────────────────────────────
///
public PumpStatusDefinition? LoadPumpStatus(int statusId)
{
if (_statusCache.TryGetValue(statusId, out var cached))
return cached;
try
{
if (!File.Exists(StatusXml))
{
_log.Error(LogId, $"LoadPumpStatus: {StatusXml} not found.");
return null;
}
var xdoc = XDocument.Load(StatusXml);
if (xdoc.Root == null) return null;
// Search for in the document.
XElement? xStatus = null;
foreach (var el in xdoc.Root.Descendants("PumpStatus"))
{
if (el.Attribute("StatusID")?.Value == statusId.ToString())
{
xStatus = el;
break;
}
}
if (xStatus == null)
{
_log.Error(LogId, $"LoadPumpStatus: StatusID={statusId} not found in status.xml.");
return null;
}
var def = new PumpStatusDefinition
{
Id = statusId,
Name = xStatus.Attribute("name")?.Value ?? "-"
};
foreach (var xState in xStatus.Elements("State"))
{
var bit = new StatusBit
{
Bit = int.Parse(xState.Attribute("bit")?.Value ?? "0"),
Enabled = string.Equals(
xState.Attribute("enabled")?.Value, "true",
StringComparison.OrdinalIgnoreCase)
};
foreach (var xVal in xState.Elements("StateValue"))
{
bit.Values.Add(new StatusBitValue
{
State = int.Parse(xVal.Attribute("value")?.Value ?? "0"),
Color = xVal.Attribute("color")?.Value ?? "26C200",
Description = xVal.Value.Trim()
});
}
def.Bits.Add(bit);
}
_statusCache[statusId] = def;
return def;
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadPumpStatus({statusId}) failed: {ex.Message}");
return null;
}
}
// ── Private loaders ───────────────────────────────────────────────────────
private void LoadSettings()
{
_settings = new AppSettings();
if (!File.Exists(ConfigXml)) return;
try
{
var xdoc = XDocument.Load(ConfigXml);
var r = xdoc.Root!;
TryInt(r, "TMax", v => _settings.TempMax = v);
TryInt(r, "TMin", v => _settings.TempMin = v);
TryInt(r, "RefreshBenchInterface", v => _settings.RefreshBenchInterfaceMs = v);
TryInt(r, "RefreshWhileReading", v => _settings.RefreshWhileReadingMs = v);
TryInt(r, "RefreshCanBusRead", v => _settings.RefreshCanBusReadMs = v);
TryInt(r, "RefreshPumpRequest", v => _settings.RefreshPumpRequestMs = v);
TryInt(r, "RefreshPumpParams", v => _settings.RefreshPumpParamsMs = v);
TryInt(r, "BlinkInterval", v => _settings.BlinkIntervalMs = v);
TryInt(r, "FlasherInterval", v => _settings.FlasherIntervalMs = v);
TryDouble(r, "PidP", v => _settings.PidP = v);
TryDouble(r, "PidI", v => _settings.PidI = v);
TryDouble(r, "PidD", v => _settings.PidD = v);
TryInt(r, "PidMs", v => _settings.PidLoopMs = v);
TryInt(r, "SecurityRpm", v => _settings.SecurityRpmLimit = v);
TryInt(r, "MaxPressure", v => _settings.MaxPressureBar = v);
TryDouble(r, "ToleranceUp", v => _settings.ToleranceUpExtension = v);
TryDouble(r, "TolerancePfp", v => _settings.TolerancePfpExtension = v);
TryInt(r, "EncoderResolution", v => _settings.EncoderResolution = v);
TryDouble(r, "VoltageForMaxRpm", v => _settings.VoltageForMaxRpm = v);
TryInt(r, "MaxRpm", v => _settings.MaxRpm = v);
TryBool(r, "RightRelayValue", v => _settings.RightRelayValue = v);
TryBool(r, "DefaultIgnoreTin", v => _settings.DefaultIgnoreTin = v);
TryInt(r, "LastRotationDir", v => _settings.LastRotationDirection = (short)v);
TryInt(r, "DaysKeepLogs", v => _settings.DaysKeepLogs = v);
TryString(r, "CompanyName", v => _settings.CompanyName = v);
TryString(r, "CompanyInfo", v => _settings.CompanyInfo = v);
TryString(r, "ReportLogoPath", v => _settings.ReportLogoPath = v);
TryString(r, "KLinePort", v => _settings.KLinePort = v);
TryString(r, "Language", v => _settings.Language = v);
TryString(r, "Relations", v => _settings.Relations = RpmVoltageRelation.Deserialise(v));
TryString(r, "Users", v => _settings.Users = v);
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadSettings failed: {ex.Message}");
}
// Seed default admin account if no users are configured.
if (string.IsNullOrEmpty(_settings.Users))
{
var (salt, hash) = HashPassword("admin");
_settings.Users = $"admin:{salt}:{hash}";
_log.Info(LogId, "No users configured — created default 'admin' account.");
SaveSettings();
}
// Migrate plaintext user:password entries to hashed format.
MigrateUsersIfNeeded();
LoadSensors();
LoadClients();
LoadAlarms();
}
private void LoadBench()
{
_bench = new BenchConfiguration();
string xml = File.Exists(BenchXml)
? File.ReadAllText(BenchXml)
: DefaultBenchXml();
try
{
var xdoc = XDocument.Parse(xml);
foreach (var xe in xdoc.Root!.Elements())
{
if (xe.Name.LocalName == "Reles")
{
foreach (var xr in xe.Elements("Rele"))
ParseRelayElement(xr);
continue;
}
var param = ParseParamElement(xe);
_bench.ParametersByName[param.Name] = param;
if (!_bench.ParametersById.TryGetValue(param.MessageId, out var list))
{
list = new List();
_bench.ParametersById[param.MessageId] = list;
}
list.Add(param);
}
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadBench failed: {ex.Message}");
}
}
private void LoadSensors()
{
_settings ??= new AppSettings();
if (File.Exists(SensorsXml))
{
try
{
var xdoc = XDocument.Load(SensorsXml);
foreach (var xs in xdoc.Root!.Elements("sensor"))
{
var sc = SensorConfiguration.FromXml(xs);
_settings.Sensors[sc.Number] = sc;
}
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadSensors failed: {ex.Message}");
}
}
// Ensure default calibrations exist for the two analogue channels.
if (!_settings.Sensors.ContainsKey(1))
_settings.Sensors[1] = SensorConfiguration.DefaultPressureSensor();
if (!_settings.Sensors.ContainsKey(2))
_settings.Sensors[2] = new SensorConfiguration { Number = 2, SensorName = "AnalogSensor2" };
}
private void LoadClients()
{
_clients = new SortedDictionary();
if (!File.Exists(ClientsXml)) return;
try
{
var xdoc = XDocument.Load(ClientsXml);
foreach (var xc in xdoc.Root!.Elements("client"))
_clients[xc.Attribute("name")?.Value ?? ""] = xc.Attribute("contact")?.Value ?? "";
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadClients failed: {ex.Message}");
}
}
private void LoadAlarms()
{
_settings ??= new AppSettings();
if (!File.Exists(AlarmsXml)) return;
try
{
var xdoc = XDocument.Load(AlarmsXml);
foreach (var xa in xdoc.Root!.Elements("Alarm"))
_settings.Alarms.Add(Alarm.FromXml(xa));
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadAlarms failed: {ex.Message}");
}
}
// ── Parsing helpers ───────────────────────────────────────────────────────
///
/// Parses a bench CAN parameter from an XML element.
/// Uses the clean factor/offset calibration model with explicit direction flags.
///
private static CanBusParameter ParseParamElement(XElement xe)
{
string direction = xe.Attribute("direction")?.Value ?? "rx";
return new CanBusParameter
{
Name = xe.Name.LocalName,
MessageId = Convert.ToUInt32(xe.Attribute("id")?.Value ?? "0", 16),
ByteH = ushort.Parse(xe.Attribute("byteh")?.Value ?? "0"),
ByteL = ushort.Parse(xe.Attribute("bytel")?.Value ?? "0"),
Alpha = CanBusParameter.ParseDecimal(xe.Attribute("filter")?.Value, 1.0),
IsReceive = string.Equals(direction, "rx", StringComparison.OrdinalIgnoreCase),
Factor = CanBusParameter.ParseDecimal(xe.Attribute("factor")?.Value, 1.0),
Offset = CanBusParameter.ParseDecimal(xe.Attribute("offset")?.Value, 0.0),
IsInverse = string.Equals(xe.Attribute("type")?.Value, "inverse",
StringComparison.OrdinalIgnoreCase),
UseLegacyTransform = false,
};
}
private void ParseRelayElement(XElement xr)
{
var relay = new Relay(
xr.Attribute("name")?.Value ?? "",
Convert.ToUInt32(xr.Attribute("id")?.Value ?? "0", 16),
int.Parse(xr.Attribute("bit")?.Value ?? "0"));
_bench!.Relays[relay.Name] = relay;
}
private static PumpDefinition? ParsePumpElement(XElement xpump)
{
var pump = new PumpDefinition
{
Id = xpump.Attribute("id")?.Value ?? string.Empty,
Model = xpump.Attribute("model")?.Value ?? string.Empty,
EcuText = xpump.Attribute("text")?.Value ?? string.Empty,
Chaveta = xpump.Attribute("chaveta")?.Value ?? string.Empty,
Rotation = xpump.Attribute("rotation")?.Value ?? RotationDirection.RightName,
Info = xpump.Attribute("info")?.Value ?? string.Empty,
HasPreInjection = string.Equals(xpump.Attribute("preinjection")?.Value,
"true", StringComparison.OrdinalIgnoreCase),
Is4Cylinder = string.Equals(xpump.Attribute("cilinders4")?.Value,
"true", StringComparison.OrdinalIgnoreCase),
UnlockType = int.Parse(xpump.Attribute("unlock")?.Value ?? "0"),
CanBaudrate = xpump.Attribute("baudrate")?.Value == "250"
? TPCANBaudrate.PCAN_BAUD_250K
: TPCANBaudrate.PCAN_BAUD_500K,
};
// Parse lockangle — may use comma as decimal separator.
var lockStr = xpump.Attribute("lockangle")?.Value;
if (!string.IsNullOrEmpty(lockStr) &&
double.TryParse(lockStr.Replace(',', '.'),
NumberStyles.Float, CultureInfo.InvariantCulture, out var la))
{
pump.LockAngle = la;
}
// ── Parse ────────────────────────────────────────────────────
var xparams = xpump.Element("Params");
if (xparams != null)
{
foreach (var xe in xparams.Elements())
{
var param = CanBusParameter.FromXml(xe);
pump.ParametersByName[param.Name] = param;
if (!pump.ParametersById.ContainsKey(param.MessageId))
pump.ParametersById[param.MessageId] = new List();
pump.ParametersById[param.MessageId].Add(param);
}
// Safety net: pump RPM is always a receive parameter.
if (pump.ParametersByName.TryGetValue(PumpParameterNames.Rpm, out var pumpRpm)
&& !pumpRpm.IsReceive)
{
pumpRpm.IsReceive = true;
}
}
// ── Parse ─────────────────────────────────────────────────────
var xtests = xpump.Element("Tests");
if (xtests != null)
{
foreach (var xtest in xtests.Elements("Test"))
{
var test = TestDefinition.FromXml(xtest);
if (test.Name == TestType.Wl)
pump.CombineTestWL(test);
else
pump.Tests.Add(test);
}
}
return pump;
}
// ── Default bench XML ─────────────────────────────────────────────────────
///
/// Returns the factory-default bench parameter XML used when bench.xml is absent.
/// Uses direction/factor/offset calibration model. Defaults: direction="rx", factor=1, offset=0, type="linear".
///
private static string DefaultBenchXml() => @"
";
// ── XML parse helpers ─────────────────────────────────────────────────────
private static void TryInt(XElement root, string name, Action assign)
{
try { if (int.TryParse(root.Element(name)?.Value, out int v)) assign(v); }
catch { /* ignore malformed XML */ }
}
private static void TryDouble(XElement root, string name, Action assign)
{
try
{
var val = root.Element(name)?.Value;
if (val != null && double.TryParse(val,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double v)) assign(v);
}
catch { }
}
private static void TryBool(XElement root, string name, Action assign)
{
try { if (bool.TryParse(root.Element(name)?.Value, out bool v)) assign(v); }
catch { }
}
private static void TryString(XElement root, string name, Action assign)
{
try { var v = root.Element(name)?.Value; if (v != null) assign(v); }
catch { }
}
// ── Users (PBKDF2-HMAC-SHA256 hashed credentials) ─────────────────────────
private const int SaltBytes = 16;
private const int HashBytes = 32;
private const int Pbkdf2Iterations = 600_000;
/// Generates a random salt and computes the PBKDF2-HMAC-SHA256 hash for .
private static (string salt, string hash) HashPassword(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(SaltBytes);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
password, salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashBytes);
return (Convert.ToBase64String(salt), Convert.ToBase64String(hash));
}
/// Verifies against the given Base64 and .
private static bool VerifyPassword(string password, string salt, string expectedHash)
{
byte[] saltBytes = Convert.FromBase64String(salt);
byte[] computed = Rfc2898DeriveBytes.Pbkdf2(
password, saltBytes, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashBytes);
return CryptographicOperations.FixedTimeEquals(computed, Convert.FromBase64String(expectedHash));
}
///
/// Detects whether contains legacy plaintext
/// user:password entries and migrates them to user:salt:hash.
///
private void MigrateUsersIfNeeded()
{
if (string.IsNullOrEmpty(Settings.Users))
return;
string[] entries = Settings.Users.Split(',');
bool hasLegacy = false;
foreach (string entry in entries)
{
// New format always has exactly 3 colon-separated parts (user:salt:hash).
// Legacy format has exactly 2 parts (user:password).
// Base64 salt/hash never contain commas but may contain '=' padding —
// they will NOT contain additional colons, so Split(':') count is reliable.
string[] parts = entry.Split(':');
if (parts.Length == 2)
{
hasLegacy = true;
break;
}
}
if (!hasLegacy) return;
var migrated = new List(entries.Length);
foreach (string entry in entries)
{
string[] parts = entry.Split(':');
if (parts.Length == 2 && parts[0].Length > 0)
{
// Legacy entry — hash the plaintext password.
var (salt, hash) = HashPassword(parts[1]);
migrated.Add($"{parts[0]}:{salt}:{hash}");
}
else if (parts.Length == 3 && parts[0].Length > 0)
{
// Already migrated entry — keep as-is.
migrated.Add(entry);
}
else
{
_log.Warning(LogId, $"Skipped malformed user entry during migration: '{entry}'");
}
}
Settings.Users = string.Join(",", migrated);
SaveSettings();
_log.Info(LogId, $"Migrated {entries.Length} user credential(s) from plaintext to PBKDF2 hashed format.");
}
///
public bool ValidateUser(string username, string password)
{
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
return false;
foreach (string entry in Settings.Users.Split(','))
{
string[] parts = entry.Split(':');
if (parts.Length == 3 && parts[0] == username)
return VerifyPassword(password, parts[1], parts[2]);
}
return false;
}
///
public IReadOnlyList GetUsers()
{
var names = new List();
foreach (string entry in Settings.Users.Split(','))
{
string[] parts = entry.Split(':');
if (parts.Length == 3 && parts[0].Length > 0)
names.Add(parts[0]);
}
return names;
}
///
public void UpdateUsers(Dictionary users)
{
var entries = new List(users.Count);
foreach (var kv in users)
{
var (salt, hash) = HashPassword(kv.Value);
entries.Add($"{kv.Key}:{salt}:{hash}");
}
Settings.Users = string.Join(",", entries);
SaveSettings();
}
}
// ── XPath extension shim ──────────────────────────────────────────────────────
internal static class XDocumentExtensions
{
/// Minimal XPath-style element selector used to find pump elements by attribute.
internal static XElement? XPathSelectElement(this XDocument doc, string xpath)
{
// Parse "/Config/Pumps/Pump[@id='xxx']"
// Sufficient for the pump-lookup use case; not a general XPath engine.
try
{
var parts = xpath.TrimStart('/').Split('/');
XElement? current = doc.Root;
// Skip the first part when it names the root element (e.g. "/Config/..." with root )
int startIndex = (parts.Length > 0 && current?.Name.LocalName == parts[0]) ? 1 : 0;
for (int pi = startIndex; pi < parts.Length; pi++)
{
if (current == null) return null;
var part = parts[pi];
int attrStart = part.IndexOf('[');
if (attrStart < 0)
{
current = current.Element(part);
}
else
{
string elemName = part[..attrStart];
string attrExpr = part[(attrStart + 1)..^1]; // strip [ and ]
if (attrExpr.StartsWith("@"))
{
var eqIdx = attrExpr.IndexOf('=');
string attrName = attrExpr[1..eqIdx];
string attrValue = attrExpr[(eqIdx + 1)..].Trim('\'', '"');
var parent = current;
current = null;
foreach (var child in parent.Elements(elemName))
{
if (child.Attribute(attrName)?.Value == attrValue)
{ current = child; break; }
}
}
}
}
return current;
}
catch { return null; }
}
}
}