Files
HC_APTBS/Services/Impl/ConfigurationService.cs
LucianoDev 827b811b39 feat: developer tools page, auto-test orchestrator, BIP display, tests redesign
Bundles several feature streams that have been iterating on the working tree:

- Developer Tools page (Debug-only via DEVELOPER_TOOLS symbol): hosts the
  identification card, manual KWP write + transaction log, ROM/EEPROM dump
  card with progress banner and completion message, persisted custom-commands
  library, persisted EEPROM passwords library. New service primitives:
  IKwpService.SendRawCustomAsync / ReadEepromAsync / ReadRomEepromAsync.
  Persistence mirrors the Clients XML pattern in two new files
  (custom_commands.xml, eeprom_passwords.xml).
- Auto-test orchestrator (IAutoTestOrchestrator + AutoTestState): linear
  K-Line read -> unlock -> bench-on -> test sequence with snackbar UI and
  progress dialog VM, gated on dashboard alarms.
- BIP-STATUS display: BipDisplayViewModel + BipDisplayView, RAM read at
  0x0106 via IKwpService.ReadBipStatusAsync; status definitions in
  BipStatusDefinition.
- Tests page redesign: TestSectionCard + PhaseTileView replacing the old
  TestPlanView/TestRunningView/TestDoneView/TestPreconditionsView/
  TestSectionView controls and their VMs.
- Pump command sliders: Fluent thick-track style with overhang thumb,
  click-anywhere-and-drag, mouse-wheel adjustment.
- Window startup: app.manifest declares PerMonitorV2 DPI awareness,
  MainWindow installs a WM_GETMINMAXINFO hook in OnSourceInitialized and
  maximizes there (after the hook is in place) so the app fits the work
  area exactly on any display configuration.
- Misc: PercentToPixelsConverter, seed_aliases.py one-shot pump-alias
  importer, tools/Import-BipStatus.ps1, kline_eeprom_spec.md and
  dump-functions reference docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 13:59:50 +02:00

1558 lines
68 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
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 string CustomCommandsXml => Path.Combine(ConfigFolder, "custom_commands.xml");
private string EepromPasswordsXml => Path.Combine(ConfigFolder, "eeprom_passwords.xml");
private readonly IAppLogger _log;
private const string LogId = "ConfigurationService";
// ── Cached instances ──────────────────────────────────────────────────────
private AppSettings? _settings;
private BenchConfiguration? _bench;
private SortedDictionary<string, string>? _clients;
private ObservableCollection<CustomCommand>? _customCommands;
private ObservableCollection<EepromPassword>? _eepromPasswords;
// Keyed by StatusID; shared by both status.xml and the pumps.xml <Palabras> fallback.
// Two distinct pumps that happen to share the same StatusID integer get the same
// table — acceptable given that the 9 known tables are pump-family-shared (same
// design as the legacy runtime). Future per-pump-override seam: key on (pumpId, statusId).
private readonly Dictionary<int, PumpStatusDefinition> _statusCache = new();
// Lazily populated on first fallback lookup; null until first pumps.xml parse attempt.
private Dictionary<int, PumpStatusDefinition>? _palabrasStatusCache;
// Alias indexes: K-Line pumpID alias -> canonical pump ID, and ModelRef alias -> canonical pump ID.
// Built once from <Aliases> blocks across all <Pump> elements; both null until first lookup.
// Invalidated (set to null) whenever an alias or pump is persisted.
private Dictionary<string, string>? _klineAliasIndex;
private Dictionary<string, string>? _modelRefAliasIndex;
// ── 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("AutoTestSkipsOilPumpConfirm", Settings.AutoTestSkipsOilPumpConfirm),
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 <Aliases> (equivalence detection) ──
if (pump.KlineAliases.Count > 0 || pump.ModelRefAliases.Count > 0)
{
var xaliasesOut = new XElement("Aliases");
foreach (var a in pump.KlineAliases)
xaliasesOut.Add(new XElement("KlineId", a));
foreach (var a in pump.ModelRefAliases)
xaliasesOut.Add(new XElement("ModelRef", a));
xpump.Add(xaliasesOut);
}
// ── Serialise <Params> (pump CAN params use legacy P1P6 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", b.Index.ToString(CultureInfo.InvariantCulture)),
new XAttribute("enabled", b.Enabled.ToString().ToLowerInvariant()),
new XAttribute("hex", "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);
InvalidateAliasIndexes();
_log.Info(LogId, $"SavePump({pump.Id}) — saved to pumps.xml.");
}
catch (Exception ex)
{
_log.Error(LogId, $"SavePump({pump.Id}) failed: {ex.Message}");
}
}
// ── IConfigurationService: Pump equivalence / alias lookup ────────────────
/// <inheritdoc/>
public string? FindPumpIdByKlineAlias(string klinePumpId)
{
if (string.IsNullOrWhiteSpace(klinePumpId)) return null;
EnsureAliasIndexes();
return _klineAliasIndex!.TryGetValue(klinePumpId.Trim(), out var canonical)
? canonical : null;
}
/// <inheritdoc/>
public string? FindPumpIdByModelRef(string modelRef)
{
if (string.IsNullOrWhiteSpace(modelRef)) return null;
EnsureAliasIndexes();
return _modelRefAliasIndex!.TryGetValue(modelRef.Trim(), out var canonical)
? canonical : null;
}
/// <inheritdoc/>
public void AddKlineAlias(string canonicalPumpId, string klineAlias)
{
if (string.IsNullOrWhiteSpace(canonicalPumpId) || string.IsNullOrWhiteSpace(klineAlias))
return;
string source = File.Exists(PumpsXml) ? PumpsXml
: File.Exists(ConfigXml) ? ConfigXml
: null!;
if (source == null)
{
_log.Warning(LogId, $"AddKlineAlias: no pumps.xml/config.xml found.");
return;
}
try
{
var xdoc = XDocument.Load(source);
var xpump = xdoc.XPathSelectElement($"/Config/Pumps/Pump[@id='{canonicalPumpId}']");
if (xpump == null)
{
_log.Warning(LogId, $"AddKlineAlias: pump '{canonicalPumpId}' not found.");
return;
}
var trimmed = klineAlias.Trim();
var xaliases = xpump.Element("Aliases");
if (xaliases == null)
{
xaliases = new XElement("Aliases");
xpump.Add(xaliases);
}
else
{
foreach (var existing in xaliases.Elements("KlineId"))
{
if (string.Equals(existing.Value.Trim(), trimmed, StringComparison.OrdinalIgnoreCase))
return; // already present
}
}
xaliases.Add(new XElement("KlineId", trimmed));
xdoc.Save(source);
InvalidateAliasIndexes();
_log.Info(LogId, $"AddKlineAlias: '{trimmed}' -> '{canonicalPumpId}' persisted.");
}
catch (Exception ex)
{
_log.Error(LogId, $"AddKlineAlias({canonicalPumpId}, {klineAlias}) failed: {ex.Message}");
}
}
private void InvalidateAliasIndexes()
{
_klineAliasIndex = null;
_modelRefAliasIndex = null;
}
/// <summary>
/// Builds the K-Line and ModelRef alias indexes by scanning every <c>&lt;Pump&gt;</c>
/// element in pumps.xml/config.xml for an <c>&lt;Aliases&gt;</c> block. Cheap enough
/// for a one-shot scan: dozens of pumps, a handful of aliases each.
/// </summary>
private void EnsureAliasIndexes()
{
if (_klineAliasIndex != null && _modelRefAliasIndex != null) return;
var klineMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var modelRefMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string source = File.Exists(PumpsXml) ? PumpsXml
: File.Exists(ConfigXml) ? ConfigXml
: null!;
if (source == null)
{
_klineAliasIndex = klineMap;
_modelRefAliasIndex = modelRefMap;
return;
}
try
{
var xdoc = XDocument.Load(source);
foreach (var xpump in xdoc.Descendants("Pump"))
{
var canonical = xpump.Attribute("id")?.Value;
if (string.IsNullOrWhiteSpace(canonical)) continue;
var xaliases = xpump.Element("Aliases");
if (xaliases == null) continue;
foreach (var xkid in xaliases.Elements("KlineId"))
{
var alias = xkid.Value?.Trim();
if (string.IsNullOrEmpty(alias)) continue;
if (!klineMap.ContainsKey(alias))
klineMap[alias] = canonical;
else
_log.Warning(LogId,
$"Duplicate KlineId alias '{alias}' (already mapped to '{klineMap[alias]}', ignoring duplicate under '{canonical}').");
}
foreach (var xmref in xaliases.Elements("ModelRef"))
{
var alias = xmref.Value?.Trim();
if (string.IsNullOrEmpty(alias)) continue;
if (!modelRefMap.ContainsKey(alias))
modelRefMap[alias] = canonical;
else
_log.Warning(LogId,
$"Duplicate ModelRef alias '{alias}' (already mapped to '{modelRefMap[alias]}', ignoring duplicate under '{canonical}').");
}
}
}
catch (Exception ex)
{
_log.Error(LogId, $"EnsureAliasIndexes failed: {ex.Message}");
}
_klineAliasIndex = klineMap;
_modelRefAliasIndex = modelRefMap;
}
// ── 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: Developer libraries ────────────────────────────
/// <inheritdoc/>
public ObservableCollection<CustomCommand> CustomCommands
{
get
{
if (_customCommands == null) LoadCustomCommands();
return _customCommands!;
}
}
/// <inheritdoc/>
public void SaveCustomCommands()
{
try
{
var root = new XElement("CustomCommands");
foreach (var cmd in CustomCommands)
root.Add(new XElement("command",
new XAttribute("name", cmd.Name ?? string.Empty),
new XAttribute("hex", cmd.HexBytes ?? string.Empty)));
new XDocument(root).Save(CustomCommandsXml);
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveCustomCommands failed: {ex.Message}");
}
}
/// <inheritdoc/>
public ObservableCollection<EepromPassword> EepromPasswords
{
get
{
if (_eepromPasswords == null) LoadEepromPasswords();
return _eepromPasswords!;
}
}
/// <inheritdoc/>
public void SaveEepromPasswords()
{
try
{
var root = new XElement("EepromPasswords");
foreach (var pw in EepromPasswords)
root.Add(new XElement("password",
new XAttribute("name", pw.Name ?? string.Empty),
new XAttribute("zone", pw.Zone.ToString("X2", CultureInfo.InvariantCulture)),
new XAttribute("key", pw.Key.ToString("X4", CultureInfo.InvariantCulture))));
new XDocument(root).Save(EepromPasswordsXml);
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveEepromPasswords 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;
// Palabras (pumps.xml) is authoritative — matches legacy runtime which read
// /Config/Palabras/PumpStatus[@StatusID='N'] exclusively. status.xml is a
// new-system artifact and in practice carries stale/partial definitions that
// collide on shared StatusIDs (e.g. ID=5 labelled EMPF3 in status.xml but
// CAN-PSGTEST in palabras). Only fall back to status.xml when palabras is
// missing the id entirely.
var def = LoadPumpStatusFromPumpsXml(statusId);
if (def == null)
def = LoadPumpStatusFromXml(StatusXml, statusId);
if (def != null)
_statusCache[statusId] = def;
return def;
}
/// <summary>Parses a <c>&lt;PumpStatus StatusID="N"&gt;</c> element from any XML file.</summary>
private PumpStatusDefinition? LoadPumpStatusFromXml(string xmlPath, int statusId)
{
try
{
if (!File.Exists(xmlPath)) return null;
var xdoc = XDocument.Load(xmlPath);
if (xdoc.Root == null) return null;
XElement? xStatus = null;
foreach (var el in xdoc.Root.Descendants("PumpStatus"))
{
if (el.Attribute("StatusID")?.Value == statusId.ToString())
{
xStatus = el;
break;
}
}
if (xStatus == null) return null;
return ParsePumpStatusElement(xStatus, statusId);
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadPumpStatusFromXml({xmlPath}, {statusId}) failed: {ex.Message}");
return null;
}
}
/// <summary>
/// Lazily loads all <c>&lt;PumpStatus&gt;</c> entries from the <c>&lt;Palabras&gt;</c>
/// block in pumps.xml and returns the one matching <paramref name="statusId"/>.
/// The orphan block lives outside <c>&lt;/Pumps&gt;</c> and is not touched by the
/// pump loader — this is the only path that reads it.
/// </summary>
private PumpStatusDefinition? LoadPumpStatusFromPumpsXml(int statusId)
{
// Build the cache on first call.
if (_palabrasStatusCache == null)
{
_palabrasStatusCache = new Dictionary<int, PumpStatusDefinition>();
try
{
if (!File.Exists(PumpsXml)) return null;
var xdoc = XDocument.Load(PumpsXml);
var palabras = xdoc.Root?.Element("Palabras");
if (palabras == null)
{
// Try top-level sibling — the <Palabras> block is outside </Pumps>.
palabras = xdoc.Descendants("Palabras").FirstOrDefault();
}
if (palabras == null)
{
_log.Error(LogId, "LoadPumpStatusFromPumpsXml: <Palabras> block not found in pumps.xml.");
return null;
}
foreach (var el in palabras.Descendants("PumpStatus"))
{
if (!int.TryParse(el.Attribute("StatusID")?.Value, out var id)) continue;
var parsed = ParsePumpStatusElement(el, id);
if (parsed != null)
_palabrasStatusCache[id] = parsed;
else
_log.Error(LogId, $"LoadPumpStatusFromPumpsXml: malformed PumpStatus StatusID={id} in pumps.xml.");
}
_log.Info(LogId, $"Loaded {_palabrasStatusCache.Count} pump-status tables from pumps.xml <Palabras>.");
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadPumpStatusFromPumpsXml parse failed: {ex.Message}");
return null;
}
}
_palabrasStatusCache.TryGetValue(statusId, out var def);
if (def == null)
_log.Error(LogId, $"LoadPumpStatus: StatusID={statusId} not found in status.xml or pumps.xml <Palabras>.");
return def;
}
/// <summary>Converts a <c>&lt;PumpStatus&gt;</c> XML element into a <see cref="PumpStatusDefinition"/>.</summary>
private static PumpStatusDefinition ParsePumpStatusElement(XElement xStatus, int statusId)
{
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);
}
return def;
}
// ── 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);
TryBool(r, "AutoTestSkipsOilPumpConfirm", v => _settings.AutoTestSkipsOilPumpConfirm = 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 LoadCustomCommands()
{
_customCommands = new ObservableCollection<CustomCommand>();
if (!File.Exists(CustomCommandsXml)) return;
try
{
var xdoc = XDocument.Load(CustomCommandsXml);
foreach (var xe in xdoc.Root!.Elements("command"))
{
var name = xe.Attribute("name")?.Value ?? string.Empty;
var hex = xe.Attribute("hex")?.Value ?? string.Empty;
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(hex))
{
_log.Warning(LogId, "LoadCustomCommands: skipping empty <command/> element.");
continue;
}
_customCommands.Add(new CustomCommand { Name = name, HexBytes = hex });
}
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadCustomCommands failed: {ex.Message}");
}
}
private void LoadEepromPasswords()
{
_eepromPasswords = new ObservableCollection<EepromPassword>();
if (!File.Exists(EepromPasswordsXml)) return;
try
{
var xdoc = XDocument.Load(EepromPasswordsXml);
foreach (var xe in xdoc.Root!.Elements("password"))
{
var name = xe.Attribute("name")?.Value ?? string.Empty;
var zoneStr = xe.Attribute("zone")?.Value ?? "0";
var keyStr = xe.Attribute("key")?.Value ?? "0";
if (!byte.TryParse(zoneStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte zone))
{
_log.Warning(LogId, $"LoadEepromPasswords: bad zone '{zoneStr}' in <password name='{name}'/> — skipping.");
continue;
}
if (!ushort.TryParse(keyStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ushort key))
{
_log.Warning(LogId, $"LoadEepromPasswords: bad key '{keyStr}' in <password name='{name}'/> — skipping.");
continue;
}
_eepromPasswords.Add(new EepromPassword { Name = name, Zone = zone, Key = key });
}
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadEepromPasswords 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 <Aliases> (optional — equivalence detection) ────────────────
// Per-pump equivalence map: alternative K-Line pumpIDs and ModelReference
// strings that should resolve to this canonical pump. See plan in
// .claude/plans/in-the-pump-identification-velvety-meteor.md.
var xaliases = xpump.Element("Aliases");
if (xaliases != null)
{
foreach (var xkid in xaliases.Elements("KlineId"))
{
var alias = xkid.Value?.Trim();
if (!string.IsNullOrEmpty(alias))
pump.KlineAliases.Add(alias);
}
foreach (var xmref in xaliases.Elements("ModelRef"))
{
var alias = xmref.Value?.Trim();
if (!string.IsNullOrEmpty(alias))
pump.ModelRefAliases.Add(alias);
}
}
// ── 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
{
// Accept both "hex" (import script) and legacy "pattern" attribute names.
var patternStr = (xbit.Attribute("hex") ?? 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
{
Index = int.Parse(xbit.Attribute("index")?.Value ?? bipDef.Bits.Count.ToString(), CultureInfo.InvariantCulture),
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; }
}
}
}