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);
}
// ── Serialise (pre-injection pumps only) ──
if (pump.BipStatus != null && pump.BipStatus.Bits.Count > 0)
{
var xbip = new XElement("BipStatus");
for (int i = 0; i < pump.BipStatus.Bits.Count; i++)
{
var b = pump.BipStatus.Bits[i];
xbip.Add(new XElement("Bit",
new XAttribute("index", i.ToString(CultureInfo.InvariantCulture)),
new XAttribute("enabled", b.Enabled.ToString().ToLowerInvariant()),
new XAttribute("pattern", "0x" + b.HexPattern.ToString("X4", CultureInfo.InvariantCulture)),
new XAttribute("reaction", b.Reaction.ToString(CultureInfo.InvariantCulture)),
new XAttribute("specialFunction", b.SpecialFunction.ToString(CultureInfo.InvariantCulture)),
b.Description));
}
xpump.Add(xbip);
}
// ── 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", CultureInfo.InvariantCulture),
Color = xVal.Attribute("color")?.Value ?? "26C200",
Description = xVal.Value.Trim(),
Reaction = int.Parse(xVal.Attribute("reaction")?.Value ?? "0", CultureInfo.InvariantCulture)
});
}
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);
// ── Bounds enforcement (see docs/gap-config-validation.md) ───────
_settings.RefreshCanBusReadMs = FloorWithLog(_settings.RefreshCanBusReadMs, 1, nameof(_settings.RefreshCanBusReadMs));
_settings.RefreshPumpParamsMs = FloorWithLog(_settings.RefreshPumpParamsMs, 1, nameof(_settings.RefreshPumpParamsMs));
_settings.SecurityRpmLimit = ClampWithLog(_settings.SecurityRpmLimit, 100, 5000, nameof(_settings.SecurityRpmLimit));
_settings.MaxPressureBar = ClampWithLog(_settings.MaxPressureBar, 1, 100, nameof(_settings.MaxPressureBar));
_settings.PidP = FloorWithLog(_settings.PidP, 0.0, nameof(_settings.PidP));
_settings.PidI = FloorWithLog(_settings.PidI, 0.0, nameof(_settings.PidI));
_settings.PidD = FloorWithLog(_settings.PidD, 0.0, nameof(_settings.PidD));
}
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;
}
CanBusParameter? param;
try
{
param = ParseParamElement(xe);
}
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed param '{xe.Name.LocalName}': {ex.Message}");
continue;
}
if (param == null) continue;
_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.
/// Returns null (and logs a warning) if byteh/bytel are outside 0-7.
///
private CanBusParameter? ParseParamElement(XElement xe)
{
string name = xe.Name.LocalName;
ushort byteh = ushort.Parse(xe.Attribute("byteh")?.Value ?? "0");
ushort bytel = ushort.Parse(xe.Attribute("bytel")?.Value ?? "0");
if (byteh > 7 || bytel > 7)
{
_log.Warning(LogId, $"Rejected param '{name}': byteh={byteh} bytel={bytel} out of 0-7.");
return null;
}
string direction = xe.Attribute("direction")?.Value ?? "rx";
return new CanBusParameter
{
Name = name,
MessageId = Convert.ToUInt32(xe.Attribute("id")?.Value ?? "0", 16),
ByteH = byteh,
ByteL = bytel,
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)
{
string name = xr.Attribute("name")?.Value ?? "";
int bit = int.Parse(xr.Attribute("bit")?.Value ?? "0");
if (bit < 0 || bit > 63)
{
_log.Warning(LogId, $"Rejected relay '{name}': bit={bit} out of 0-63.");
return;
}
var relay = new Relay(
name,
Convert.ToUInt32(xr.Attribute("id")?.Value ?? "0", 16),
bit);
_bench!.Relays[relay.Name] = relay;
}
private 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())
{
CanBusParameter param;
try
{
param = CanBusParameter.FromXml(xe);
}
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed pump param '{xe.Name.LocalName}' for pump '{pump.Id}': {ex.Message}");
continue;
}
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);
}
}
// ── Parse (optional — pre-injection pumps only) ───────────
var xbip = xpump.Element("BipStatus");
if (xbip != null)
{
var bipDef = new PumpBipDefinition();
foreach (var xbit in xbip.Elements("Bit"))
{
try
{
var patternStr = xbit.Attribute("pattern")?.Value ?? "0";
if (patternStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
patternStr = patternStr.Substring(2);
var sfStr = xbit.Attribute("specialFunction")?.Value ?? "9";
int specialFn;
if (sfStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
specialFn = int.Parse(sfStr.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
else
specialFn = int.Parse(sfStr, CultureInfo.InvariantCulture);
bipDef.Bits.Add(new BipStatusDefinition
{
Enabled = !string.Equals(xbit.Attribute("enabled")?.Value, "false",
StringComparison.OrdinalIgnoreCase),
HexPattern = ushort.Parse(patternStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture),
Reaction = int.Parse(xbit.Attribute("reaction")?.Value ?? "0", CultureInfo.InvariantCulture),
SpecialFunction = specialFn,
Description = xbit.Value.Trim()
});
}
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed in BipStatus for pump '{pump.Id}': {ex.Message}");
}
}
pump.BipStatus = bipDef;
}
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 void TryInt(XElement root, string name, Action assign)
{
try { if (int.TryParse(root.Element(name)?.Value, out int v)) assign(v); }
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
}
}
private 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 (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
}
}
private void TryBool(XElement root, string name, Action assign)
{
try { if (bool.TryParse(root.Element(name)?.Value, out bool v)) assign(v); }
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
}
}
private void TryString(XElement root, string name, Action assign)
{
try { var v = root.Element(name)?.Value; if (v != null) assign(v); }
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
}
}
///
/// Clamps into [, ].
/// Logs a warning if clamping occurred.
///
private T ClampWithLog(T value, T min, T max, string field) where T : IComparable
{
if (value.CompareTo(min) < 0)
{
_log.Warning(LogId, $"{field}={value} below min {min}, clamped.");
return min;
}
if (value.CompareTo(max) > 0)
{
_log.Warning(LogId, $"{field}={value} above max {max}, clamped.");
return max;
}
return value;
}
/// Floors to , logging if floored.
private T FloorWithLog(T value, T min, string field) where T : IComparable
{
if (value.CompareTo(min) < 0)
{
_log.Warning(LogId, $"{field}={value} below min {min}, floored.");
return min;
}
return value;
}
// ── 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();
}
///
public bool AddUser(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
{
_log.Warning(LogId, "AddUser rejected: empty username or password.");
return false;
}
if (username.IndexOfAny(new[] { ':', ',' }) >= 0)
{
_log.Warning(LogId, $"AddUser rejected: username '{username}' contains reserved separator character.");
return false;
}
var entries = ParseUserEntries();
if (entries.Any(e => e.user == username))
{
_log.Warning(LogId, $"AddUser rejected: user '{username}' already exists.");
return false;
}
var (salt, hash) = HashPassword(password);
entries.Add((username, salt, hash));
Settings.Users = FormatUserEntries(entries);
SaveSettings();
_log.Info(LogId, $"Added user '{username}'.");
return true;
}
///
public bool RemoveUser(string username)
{
if (string.IsNullOrWhiteSpace(username))
return false;
var entries = ParseUserEntries();
if (entries.Count <= 1)
{
_log.Warning(LogId, $"RemoveUser rejected: cannot remove '{username}' — at least one user must remain.");
return false;
}
int idx = entries.FindIndex(e => e.user == username);
if (idx < 0)
{
_log.Warning(LogId, $"RemoveUser rejected: user '{username}' does not exist.");
return false;
}
entries.RemoveAt(idx);
Settings.Users = FormatUserEntries(entries);
SaveSettings();
_log.Info(LogId, $"Removed user '{username}'.");
return true;
}
///
public bool ChangeUserPassword(string username, string newPassword)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(newPassword))
{
_log.Warning(LogId, "ChangeUserPassword rejected: empty username or password.");
return false;
}
var entries = ParseUserEntries();
int idx = entries.FindIndex(e => e.user == username);
if (idx < 0)
{
_log.Warning(LogId, $"ChangeUserPassword rejected: user '{username}' does not exist.");
return false;
}
var (salt, hash) = HashPassword(newPassword);
entries[idx] = (username, salt, hash);
Settings.Users = FormatUserEntries(entries);
SaveSettings();
_log.Info(LogId, $"Changed password for user '{username}'.");
return true;
}
/// Parses into a list of (user, salt, hash) tuples, skipping malformed entries.
private List<(string user, string salt, string hash)> ParseUserEntries()
{
var list = new List<(string, string, string)>();
if (string.IsNullOrEmpty(Settings.Users)) return list;
foreach (string entry in Settings.Users.Split(','))
{
string[] parts = entry.Split(':');
if (parts.Length == 3 && parts[0].Length > 0)
list.Add((parts[0], parts[1], parts[2]));
}
return list;
}
/// Serialises a list of (user, salt, hash) tuples back to the comma-separated storage format.
private static string FormatUserEntries(List<(string user, string salt, string hash)> entries)
=> string.Join(",", entries.Select(e => $"{e.user}:{e.salt}:{e.hash}"));
}
// ── 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; }
}
}
}