Replace the monolithic MainWindow with a SelectedPage-driven shell (Dashboard / Pump / Bench / Tests / Results / Settings). The Tests page gets the Plan -> Preconditions -> Running -> Done wizard from ui-structure.md \u00a74, backed by a 7-item precondition gate and shared sub-views (PhaseCardView / TestSectionView / GraphicIndicatorView) extracted from the now-deleted monolithic TestPanelView. New VMs / views: - Tests wizard: TestPreconditions, PhaseCard, GraphicIndicator, TestSection, TestPlan, TestRunning, TestDone - Dashboard panels: DashboardConnection, DashboardReadings, DashboardAlarms, InterlockBanner, ResultHistory - Pump / bench panels: PumpIdentificationPanel, PumpLiveData, UnlockPanel, BenchDriveControl, BenchReadings, RelayBank, TemperatureControl, DtcList, AuthGate - Dialogs: generic ConfirmDialog, UserManageDialog, UserPromptDialog Supporting changes: - IsOilPumpOn exposed on MainViewModel for precondition evaluation - RequiresAuth added to TestDefinition (XML round-trip) - BipStatusDefinition + CompletedTestRun models - ~35 new Test.* localization keys (en + es) - Settings moved from modal dialog to full page - Pause / Retry / Skip stubs in TestRunningView; full spec in docs/gap-test-running-controls.md for follow-up implementation - docs/ui-structure.md captures the wizard design Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1175 lines
52 KiB
C#
1175 lines
52 KiB
C#
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
|
||
{
|
||
/// <summary>
|
||
/// XML-backed implementation of <see cref="IConfigurationService"/>.
|
||
/// All configuration files live under <c>%UserProfile%\.HC_APTBS\config\</c>.
|
||
/// </summary>
|
||
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<string, string>? _clients;
|
||
private readonly Dictionary<int, PumpStatusDefinition> _statusCache = new();
|
||
|
||
// ── Constructor ───────────────────────────────────────────────────────────
|
||
|
||
/// <param name="logger">Application logger.</param>
|
||
public ConfigurationService(IAppLogger logger)
|
||
{
|
||
_log = logger;
|
||
Directory.CreateDirectory(ConfigFolder);
|
||
}
|
||
|
||
// ── IConfigurationService: Settings ───────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public AppSettings Settings
|
||
{
|
||
get
|
||
{
|
||
if (_settings == null) LoadSettings();
|
||
return _settings!;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
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 ──────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public BenchConfiguration Bench
|
||
{
|
||
get
|
||
{
|
||
if (_bench == null) LoadBench();
|
||
return _bench!;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
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 ──────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public IReadOnlyList<string> GetPumpIds()
|
||
{
|
||
var ids = new List<string>();
|
||
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;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
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 <Pump> 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 <Params> (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 <Tests> ──
|
||
if (pump.Tests.Count > 0)
|
||
{
|
||
var xtests = new XElement("Tests");
|
||
foreach (var test in pump.Tests)
|
||
xtests.Add(test.ToXml());
|
||
xpump.Add(xtests);
|
||
}
|
||
|
||
// ── Serialise <BipStatus> (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 ────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
public SortedDictionary<string, string> Clients
|
||
{
|
||
get
|
||
{
|
||
if (_clients == null) LoadClients();
|
||
return _clients!;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
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 ────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
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 ─────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
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 ───────────────────────────────────────────────
|
||
|
||
/// <inheritdoc/>
|
||
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 <PumpStatus StatusID="N"> 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<CanBusParameter>();
|
||
_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<string, string>();
|
||
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 ───────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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 <Params> ────────────────────────────────────────────────────
|
||
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<CanBusParameter>();
|
||
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 <Tests> ─────────────────────────────────────────────────────
|
||
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 <BipStatus> (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 <Bit> in BipStatus for pump '{pump.Id}': {ex.Message}");
|
||
}
|
||
}
|
||
pump.BipStatus = bipDef;
|
||
}
|
||
|
||
return pump;
|
||
}
|
||
|
||
// ── Default bench XML ─────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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".
|
||
/// </summary>
|
||
private static string DefaultBenchXml() => @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||
<Bench>
|
||
<!-- TX: values sent from software to bench controller -->
|
||
<RPM id=""10"" byteh=""1"" bytel=""0"" direction=""tx"" />
|
||
<Counter id=""11"" byteh=""1"" bytel=""0"" direction=""tx"" />
|
||
<BaudRate id=""55"" byteh=""0"" bytel=""0"" direction=""tx"" />
|
||
<EncoderResolution id=""51"" byteh=""6"" bytel=""7"" direction=""tx"" />
|
||
<ElectronicMsg id=""51"" byteh=""0"" bytel=""0"" direction=""tx"" />
|
||
|
||
<!-- RX: values received from bench controller (direction=""rx"" is the default) -->
|
||
<BenchRPM id=""13"" byteh=""1"" bytel=""0"" />
|
||
<BenchCounter id=""13"" byteh=""3"" bytel=""2"" />
|
||
<BenchTemp id=""14"" byteh=""1"" bytel=""0"" factor=""0.1"" offset=""-20"" />
|
||
<T-in id=""14"" byteh=""3"" bytel=""2"" factor=""0.1"" offset=""-20"" />
|
||
<T-out id=""14"" byteh=""5"" bytel=""4"" factor=""0.1"" offset=""-20"" />
|
||
<T4 id=""14"" byteh=""7"" bytel=""6"" factor=""0.1"" offset=""-20"" />
|
||
<QDelivery id=""8"" byteh=""5"" bytel=""3"" factor=""2030000"" type=""inverse"" filter=""0.01"" />
|
||
<QOver id=""8"" byteh=""2"" bytel=""0"" factor=""510000"" type=""inverse"" filter=""0.11"" />
|
||
<PSGEncoderValue id=""50"" byteh=""4"" bytel=""5"" />
|
||
<PSGEncoderWorking id=""50"" byteh=""7"" bytel=""7"" />
|
||
<InyectorEncoderValue id=""50"" byteh=""2"" bytel=""3"" />
|
||
<InyectorEncoderWorking id=""50"" byteh=""6"" bytel=""6"" />
|
||
<ManualEncoderValue id=""50"" byteh=""0"" bytel=""1"" />
|
||
<Alarms id=""8"" byteh=""7"" bytel=""6"" />
|
||
<Pressure id=""13"" byteh=""4"" bytel=""5"" />
|
||
<AnalogicSensor2 id=""13"" byteh=""6"" bytel=""7"" />
|
||
|
||
<Reles>
|
||
<Rele name=""Electronic"" id=""15"" bit=""0"" />
|
||
<Rele name=""OilPump"" id=""15"" bit=""4"" />
|
||
<Rele name=""DepositCooler"" id=""15"" bit=""8"" />
|
||
<Rele name=""DepositHeater"" id=""15"" bit=""12"" />
|
||
<Rele name=""Reserve"" id=""15"" bit=""16"" />
|
||
<Rele name=""Counter"" id=""15"" bit=""20"" />
|
||
<Rele name=""Direction"" id=""15"" bit=""24"" />
|
||
<Rele name=""TinCooler"" id=""15"" bit=""28"" />
|
||
<Rele name=""Pulse4Signal"" id=""15"" bit=""32"" />
|
||
<Rele name=""Flasher"" id=""15"" bit=""44"" />
|
||
</Reles>
|
||
</Bench>";
|
||
|
||
// ── XML parse helpers ─────────────────────────────────────────────────────
|
||
|
||
private void TryInt(XElement root, string name, Action<int> 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<double> 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<bool> 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<string> 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}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Clamps <paramref name="value"/> into [<paramref name="min"/>, <paramref name="max"/>].
|
||
/// Logs a warning if clamping occurred.
|
||
/// </summary>
|
||
private T ClampWithLog<T>(T value, T min, T max, string field) where T : IComparable<T>
|
||
{
|
||
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;
|
||
}
|
||
|
||
/// <summary>Floors <paramref name="value"/> to <paramref name="min"/>, logging if floored.</summary>
|
||
private T FloorWithLog<T>(T value, T min, string field) where T : IComparable<T>
|
||
{
|
||
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;
|
||
|
||
/// <summary>Generates a random salt and computes the PBKDF2-HMAC-SHA256 hash for <paramref name="password"/>.</summary>
|
||
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));
|
||
}
|
||
|
||
/// <summary>Verifies <paramref name="password"/> against the given Base64 <paramref name="salt"/> and <paramref name="expectedHash"/>.</summary>
|
||
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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Detects whether <see cref="AppSettings.Users"/> contains legacy plaintext
|
||
/// <c>user:password</c> entries and migrates them to <c>user:salt:hash</c>.
|
||
/// </summary>
|
||
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<string>(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.");
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
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;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public IReadOnlyList<string> GetUsers()
|
||
{
|
||
var names = new List<string>();
|
||
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;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public void UpdateUsers(Dictionary<string, string> users)
|
||
{
|
||
var entries = new List<string>(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();
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
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;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
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;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Parses <see cref="AppSettings.Users"/> into a list of (user, salt, hash) tuples, skipping malformed entries.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Serialises a list of (user, salt, hash) tuples back to the comma-separated storage format.</summary>
|
||
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
|
||
{
|
||
/// <summary>Minimal XPath-style element selector used to find pump elements by attribute.</summary>
|
||
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 <Config>)
|
||
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; }
|
||
}
|
||
}
|
||
}
|