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>
This commit is contained in:
2026-05-07 13:59:50 +02:00
parent da0581967b
commit 827b811b39
102 changed files with 7522 additions and 1798 deletions

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -22,13 +23,15 @@ namespace HC_APTBS.Services.Impl
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 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";
@@ -38,7 +41,21 @@ namespace HC_APTBS.Services.Impl
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 ───────────────────────────────────────────────────────────
@@ -89,6 +106,7 @@ namespace HC_APTBS.Services.Impl
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),
@@ -229,6 +247,17 @@ namespace HC_APTBS.Services.Impl
// 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)
{
@@ -255,9 +284,9 @@ namespace HC_APTBS.Services.Impl
{
var b = pump.BipStatus.Bits[i];
xbip.Add(new XElement("Bit",
new XAttribute("index", i.ToString(CultureInfo.InvariantCulture)),
new XAttribute("index", b.Index.ToString(CultureInfo.InvariantCulture)),
new XAttribute("enabled", b.Enabled.ToString().ToLowerInvariant()),
new XAttribute("pattern", "0x" + b.HexPattern.ToString("X4", CultureInfo.InvariantCulture)),
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));
@@ -282,6 +311,7 @@ namespace HC_APTBS.Services.Impl
pumpsNode.Add(xpump);
xdoc.Save(PumpsXml);
InvalidateAliasIndexes();
_log.Info(LogId, $"SavePump({pump.Id}) — saved to pumps.xml.");
}
catch (Exception ex)
@@ -290,6 +320,149 @@ namespace HC_APTBS.Services.Impl
}
}
// ── 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/>
@@ -320,6 +493,65 @@ namespace HC_APTBS.Services.Impl
}
}
// ── 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/>
@@ -365,18 +597,33 @@ namespace HC_APTBS.Services.Impl
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(StatusXml))
{
_log.Error(LogId, $"LoadPumpStatus: {StatusXml} not found.");
return null;
}
if (!File.Exists(xmlPath)) return null;
var xdoc = XDocument.Load(StatusXml);
var xdoc = XDocument.Load(xmlPath);
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"))
{
@@ -387,52 +634,107 @@ namespace HC_APTBS.Services.Impl
}
}
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;
if (xStatus == null) return null;
return ParsePumpStatusElement(xStatus, statusId);
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadPumpStatus({statusId}) failed: {ex.Message}");
_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()
@@ -466,6 +768,7 @@ namespace HC_APTBS.Services.Impl
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);
@@ -599,6 +902,63 @@ namespace HC_APTBS.Services.Impl
}
}
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();
@@ -729,6 +1089,27 @@ namespace HC_APTBS.Services.Impl
}
}
// ── 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)
@@ -752,7 +1133,8 @@ namespace HC_APTBS.Services.Impl
{
try
{
var patternStr = xbit.Attribute("pattern")?.Value ?? "0";
// 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);
@@ -765,6 +1147,7 @@ namespace HC_APTBS.Services.Impl
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),