You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
640 lines
27 KiB
640 lines
27 KiB
@page "/can-monitor" |
|
@rendermode InteractiveServer |
|
@implements IDisposable |
|
|
|
@using Peak.Can.Basic |
|
|
|
@inject ICanService CanService |
|
|
|
<PageTitle>CAN Monitor</PageTitle> |
|
|
|
<h1>CAN Monitor</h1> |
|
|
|
@* ── Connection ──────────────────────────────────────────────────────────────── *@ |
|
<div class="card mb-3"> |
|
<div class="card-body py-2"> |
|
@if (CanService.IsConnected && !showConnectionForm) |
|
{ |
|
<div class="d-flex align-items-center gap-3"> |
|
<span class="badge bg-success">● Connected</span> |
|
<span class="font-monospace fw-semibold">@CanService.ChannelName</span> |
|
<span class="text-muted">@BitrateLabel(CanService.CurrentBitrate)</span> |
|
<button class="btn btn-sm btn-outline-secondary ms-auto" |
|
@onclick="() => { showConnectionForm = true; ScanChannels(); }"> |
|
Change |
|
</button> |
|
</div> |
|
} |
|
else |
|
{ |
|
<div class="row g-2 align-items-end"> |
|
<div class="col-auto"> |
|
<label class="form-label small mb-0">Channel</label> |
|
<select class="form-select form-select-sm font-monospace" @bind="selectedChannel" |
|
style="min-width:100px"> |
|
@if (availableChannels.Count == 0) |
|
{ |
|
<option value="">— none found —</option> |
|
} |
|
@foreach (var ch in availableChannels) |
|
{ |
|
<option value="@ch.ToString()">@ch.ToString()</option> |
|
} |
|
</select> |
|
</div> |
|
<div class="col-auto"> |
|
<label class="form-label small mb-0">Bitrate</label> |
|
<select class="form-select form-select-sm" @bind="selectedBitrate" |
|
style="min-width:120px"> |
|
@foreach (var (val, label) in BitrateOptions) |
|
{ |
|
<option value="@val.ToString()">@label</option> |
|
} |
|
</select> |
|
</div> |
|
<div class="col-auto d-flex gap-2"> |
|
<button class="btn btn-sm btn-outline-secondary" @onclick="ScanChannels">Scan</button> |
|
<button class="btn btn-sm btn-primary" @onclick="Connect" |
|
disabled="@(availableChannels.Count == 0 || isConnecting)"> |
|
@(isConnecting ? "Connecting…" : CanService.IsConnected ? "Reconnect" : "Connect") |
|
</button> |
|
@if (CanService.IsConnected) |
|
{ |
|
<button class="btn btn-sm btn-outline-secondary" |
|
@onclick="() => showConnectionForm = false">Cancel</button> |
|
} |
|
</div> |
|
</div> |
|
@if (availableChannels.Count == 0) |
|
{ |
|
<div class="text-warning small mt-1"> |
|
No PCAN USB channels detected. Plug in your adapter and click Scan. |
|
</div> |
|
} |
|
@if (connectError != null) |
|
{ |
|
<div class="text-danger small mt-1">@connectError</div> |
|
} |
|
} |
|
</div> |
|
</div> |
|
|
|
@* ── Live messages ──────────────────────────────────────────────────────────── *@ |
|
<div class="card mb-3"> |
|
<div class="card-header d-flex justify-content-between align-items-center py-0 ps-0"> |
|
<ul class="nav nav-tabs border-0 mb-0"> |
|
<li class="nav-item"> |
|
<button class="nav-link rounded-0 @(msgTab == "stream" ? "active" : "")" |
|
@onclick='() => msgTab = "stream"'> |
|
Stream |
|
<span class="badge bg-secondary ms-1">@messages.Count</span> |
|
</button> |
|
</li> |
|
<li class="nav-item"> |
|
<button class="nav-link rounded-0 @(msgTab == "latest" ? "active" : "")" |
|
@onclick='() => msgTab = "latest"'> |
|
Latest |
|
<span class="badge bg-secondary ms-1">@latestMessages.Count</span> |
|
</button> |
|
</li> |
|
</ul> |
|
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="ClearMessages">Clear</button> |
|
</div> |
|
|
|
@if (msgTab == "stream") |
|
{ |
|
<div style="max-height:320px; overflow-y:auto;"> |
|
<table class="table table-sm table-hover font-monospace mb-0"> |
|
<thead class="table-light sticky-top"> |
|
<tr> |
|
<th>Timestamp (µs)</th> |
|
<th>ID</th> |
|
<th>DLC</th> |
|
<th>Data</th> |
|
<th>Signals</th> |
|
</tr> |
|
</thead> |
|
<tbody> |
|
@lock (messages) |
|
{ |
|
foreach (var m in messages) |
|
{ |
|
<tr> |
|
<td>@m.TimestampUs</td> |
|
<td>@($"0x{m.Id:X3}")</td> |
|
<td>@m.Dlc</td> |
|
<td>@(m.Data.Length > 0 ? BitConverter.ToString(m.Data).Replace("-", " ") : "–")</td> |
|
<td>@SignalsText(m)</td> |
|
</tr> |
|
} |
|
} |
|
</tbody> |
|
</table> |
|
</div> |
|
} |
|
else |
|
{ |
|
<div style="max-height:320px; overflow-y:auto;"> |
|
<table class="table table-sm table-hover font-monospace mb-0"> |
|
<thead class="table-light sticky-top"> |
|
<tr> |
|
<th>ID</th> |
|
<th>DLC</th> |
|
<th>Data</th> |
|
<th>Signals</th> |
|
<th>Timestamp (µs)</th> |
|
<th class="text-end">Count</th> |
|
</tr> |
|
</thead> |
|
<tbody> |
|
@foreach (var m in GetLatestRows()) |
|
{ |
|
<tr> |
|
<td>@($"0x{m.Id:X3}")</td> |
|
<td>@m.Dlc</td> |
|
<td>@(m.Data.Length > 0 ? BitConverter.ToString(m.Data).Replace("-", " ") : "–")</td> |
|
<td>@SignalsText(m)</td> |
|
<td>@m.TimestampUs</td> |
|
<td class="text-end">@updateCounts.GetValueOrDefault(m.Id)</td> |
|
</tr> |
|
} |
|
</tbody> |
|
</table> |
|
</div> |
|
} |
|
</div> |
|
|
|
@* ── Filters + Send (two columns) ──────────────────────────────────────────── *@ |
|
<div class="row g-3 mb-3"> |
|
|
|
@* Filters *@ |
|
<div class="col-lg-7"> |
|
<div class="card h-100"> |
|
<div class="card-header"> |
|
Filters |
|
<span class="text-muted small ms-2"> |
|
@(filters.Count == 0 ? "(no filters — all messages pass)" : $"({filters.Count} active)") |
|
</span> |
|
</div> |
|
<div class="card-body"> |
|
@if (filters.Count > 0) |
|
{ |
|
<table class="table table-sm mb-2"> |
|
<thead><tr><th>ID</th><th>Mask</th><th>Description</th><th></th></tr></thead> |
|
<tbody> |
|
@foreach (var f in filters) |
|
{ |
|
<tr> |
|
<td class="font-monospace">@($"0x{f.MessageId:X3}")</td> |
|
<td class="font-monospace">@($"0x{f.Mask:X3}")</td> |
|
<td>@f.Description</td> |
|
<td> |
|
<button class="btn btn-sm btn-outline-danger py-0 px-2" |
|
@onclick="() => RemoveFilter(f.Id)">×</button> |
|
</td> |
|
</tr> |
|
} |
|
</tbody> |
|
</table> |
|
} |
|
|
|
<div class="row g-1 align-items-end"> |
|
<div class="col-3"> |
|
<label class="form-label small mb-0">ID (hex)</label> |
|
<input class="form-control form-control-sm font-monospace" |
|
placeholder="e.g. 100" @bind="newFilterId" /> |
|
</div> |
|
<div class="col-3"> |
|
<label class="form-label small mb-0">Mask (hex)</label> |
|
<input class="form-control form-control-sm font-monospace" |
|
placeholder="e.g. 7FF" @bind="newFilterMask" /> |
|
</div> |
|
<div class="col-4"> |
|
<label class="form-label small mb-0">Description</label> |
|
<input class="form-control form-control-sm" |
|
placeholder="optional" @bind="newFilterDesc" /> |
|
</div> |
|
<div class="col-2"> |
|
<button class="btn btn-sm btn-primary w-100" @onclick="AddFilter">Add</button> |
|
</div> |
|
</div> |
|
|
|
@if (filterError != null) |
|
{ |
|
<div class="text-danger small mt-1">@filterError</div> |
|
} |
|
@if (filters.Count > 0) |
|
{ |
|
<button class="btn btn-sm btn-outline-danger mt-2" @onclick="ClearFilters">Clear all</button> |
|
} |
|
</div> |
|
</div> |
|
</div> |
|
|
|
@* Send *@ |
|
<div class="col-lg-5"> |
|
<div class="card h-100"> |
|
<div class="card-header">Send Message</div> |
|
<div class="card-body"> |
|
<div class="mb-2"> |
|
<label class="form-label small mb-0">CAN ID (hex)</label> |
|
<input class="form-control form-control-sm font-monospace" |
|
placeholder="e.g. 7FF" @bind="sendIdHex" /> |
|
</div> |
|
<div class="mb-2"> |
|
<label class="form-label small mb-0">Data bytes (hex, space-separated)</label> |
|
<input class="form-control form-control-sm font-monospace" |
|
placeholder="e.g. DE AD BE EF" @bind="sendDataHex" /> |
|
</div> |
|
<div class="form-check mb-2"> |
|
<input class="form-check-input" type="checkbox" id="extFrame" @bind="sendExtended" /> |
|
<label class="form-check-label small" for="extFrame">Extended frame (29-bit ID)</label> |
|
</div> |
|
<div class="d-flex align-items-center gap-2"> |
|
<button class="btn btn-sm btn-success" @onclick="SendMessage">Send</button> |
|
@if (sendFeedback != null) |
|
{ |
|
<span class="small @(sendOk ? "text-success" : "text-danger")">@sendFeedback</span> |
|
} |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
@* ── Bitmasks ───────────────────────────────────────────────────────────────── *@ |
|
<div class="card"> |
|
<div class="card-header">Signal Bitmasks</div> |
|
<div class="card-body"> |
|
@if (bitmasks.Count == 0) |
|
{ |
|
<p class="text-muted small mb-2"> |
|
No bitmasks configured. Add one to extract named signal values from a message's data bytes.<br /> |
|
<em>Formula: physicalValue = ((rawUint64 & DataMask) >> RightShift) × Scale + Offset</em> |
|
</p> |
|
} |
|
else |
|
{ |
|
<table class="table table-sm mb-2"> |
|
<thead> |
|
<tr> |
|
<th>Msg ID</th><th>Signal</th><th>DataMask (64-bit)</th> |
|
<th>Shift</th><th>Scale</th><th>Offset</th><th>Unit</th><th></th> |
|
</tr> |
|
</thead> |
|
<tbody> |
|
@foreach (var b in bitmasks) |
|
{ |
|
<tr> |
|
<td class="font-monospace">@($"0x{b.MessageId:X3}")</td> |
|
<td>@b.SignalName</td> |
|
<td class="font-monospace small">@($"0x{b.DataMask:X16}")</td> |
|
<td>@b.RightShift</td> |
|
<td>@b.Scale</td> |
|
<td>@b.Offset</td> |
|
<td>@b.Unit</td> |
|
<td> |
|
<button class="btn btn-sm btn-outline-danger py-0 px-2" |
|
@onclick="() => RemoveBitmask(b.Id)">×</button> |
|
</td> |
|
</tr> |
|
} |
|
</tbody> |
|
</table> |
|
} |
|
|
|
<div class="row g-1 align-items-end"> |
|
<div class="col-sm-2"> |
|
<label class="form-label small mb-0">Msg ID (hex)</label> |
|
<input class="form-control form-control-sm font-monospace" placeholder="e.g. 100" @bind="newBmMsgId" /> |
|
</div> |
|
<div class="col-sm-2"> |
|
<label class="form-label small mb-0">Signal name</label> |
|
<input class="form-control form-control-sm" placeholder="e.g. EngineRPM" @bind="newBmName" /> |
|
</div> |
|
<div class="col-sm-3"> |
|
<label class="form-label small mb-0">DataMask (64-bit hex)</label> |
|
<input class="form-control form-control-sm font-monospace" placeholder="e.g. 00FF000000000000" @bind="newBmMask" /> |
|
</div> |
|
<div class="col-sm-1"> |
|
<label class="form-label small mb-0">Shift</label> |
|
<input class="form-control form-control-sm" @bind="newBmShift" /> |
|
</div> |
|
<div class="col-sm-1"> |
|
<label class="form-label small mb-0">Scale</label> |
|
<input class="form-control form-control-sm" @bind="newBmScale" /> |
|
</div> |
|
<div class="col-sm-1"> |
|
<label class="form-label small mb-0">Offset</label> |
|
<input class="form-control form-control-sm" @bind="newBmOffset" /> |
|
</div> |
|
<div class="col-sm-1"> |
|
<label class="form-label small mb-0">Unit</label> |
|
<input class="form-control form-control-sm" placeholder="rpm" @bind="newBmUnit" /> |
|
</div> |
|
<div class="col-sm-1"> |
|
<button class="btn btn-sm btn-primary w-100" @onclick="AddBitmask">Add</button> |
|
</div> |
|
</div> |
|
|
|
@if (bitmaskError != null) |
|
{ |
|
<div class="text-danger small mt-1">@bitmaskError</div> |
|
} |
|
@if (bitmasks.Count > 0) |
|
{ |
|
<button class="btn btn-sm btn-outline-danger mt-2" @onclick="ClearBitmasks">Clear all</button> |
|
} |
|
</div> |
|
</div> |
|
|
|
@code { |
|
// ── Connection form ─────────────────────────────────────────────────────── |
|
private List<PcanChannel> availableChannels = []; |
|
private string selectedChannel = ""; |
|
private string selectedBitrate = Bitrate.Pcan500.ToString(); |
|
private bool showConnectionForm; |
|
private bool isConnecting; |
|
private string? connectError; |
|
|
|
private static readonly (Bitrate Value, string Label)[] BitrateOptions = |
|
[ |
|
(Bitrate.Pcan1000, "1000 kbps"), |
|
(Bitrate.Pcan800, "800 kbps"), |
|
(Bitrate.Pcan500, "500 kbps"), |
|
(Bitrate.Pcan250, "250 kbps"), |
|
(Bitrate.Pcan125, "125 kbps"), |
|
(Bitrate.Pcan100, "100 kbps"), |
|
(Bitrate.Pcan50, "50 kbps"), |
|
(Bitrate.Pcan20, "20 kbps"), |
|
(Bitrate.Pcan10, "10 kbps"), |
|
(Bitrate.Pcan5, "5 kbps"), |
|
]; |
|
|
|
// ── Data ────────────────────────────────────────────────────────────────── |
|
private readonly List<CanMessageDto> messages = []; |
|
private readonly Dictionary<uint, CanMessageDto> latestMessages = new(); |
|
private readonly Dictionary<uint, int> updateCounts = new(); |
|
private string msgTab = "stream"; |
|
private const int MaxMessages = 200; |
|
private List<CanFilter> filters = []; |
|
private List<CanBitmask> bitmasks = []; |
|
|
|
// ── Filter form ─────────────────────────────────────────────────────────── |
|
private string newFilterId = ""; |
|
private string newFilterMask = "7FF"; |
|
private string newFilterDesc = ""; |
|
private string? filterError; |
|
|
|
// ── Bitmask form ────────────────────────────────────────────────────────── |
|
private string newBmMsgId = ""; |
|
private string newBmName = ""; |
|
private string newBmMask = ""; |
|
private int newBmShift = 0; |
|
private double newBmScale = 1.0; |
|
private double newBmOffset = 0.0; |
|
private string newBmUnit = ""; |
|
private string? bitmaskError; |
|
|
|
// ── Send form ───────────────────────────────────────────────────────────── |
|
private string sendIdHex = ""; |
|
private string sendDataHex = ""; |
|
private bool sendExtended; |
|
private string? sendFeedback; |
|
private bool sendOk; |
|
|
|
// ── Lifecycle ───────────────────────────────────────────────────────────── |
|
|
|
protected override void OnInitialized() |
|
{ |
|
filters = CanService.Filters.ToList(); |
|
bitmasks = CanService.Bitmasks.ToList(); |
|
CanService.MessageReceived += OnMessageReceived; |
|
|
|
ScanChannels(); |
|
if (CanService.IsConnected) |
|
{ |
|
selectedChannel = CanService.ChannelName; |
|
selectedBitrate = CanService.CurrentBitrate.ToString(); |
|
} |
|
else |
|
{ |
|
showConnectionForm = true; |
|
} |
|
} |
|
|
|
private void OnMessageReceived(CanMessageDto msg) |
|
{ |
|
lock (messages) |
|
{ |
|
messages.Insert(0, msg); |
|
if (messages.Count > MaxMessages) |
|
messages.RemoveAt(messages.Count - 1); |
|
} |
|
lock (latestMessages) |
|
{ |
|
if (!latestMessages.TryGetValue(msg.Id, out var existing) || |
|
!msg.Data.SequenceEqual(existing.Data)) |
|
{ |
|
latestMessages[msg.Id] = msg; |
|
updateCounts[msg.Id] = updateCounts.GetValueOrDefault(msg.Id) + 1; |
|
} |
|
} |
|
InvokeAsync(StateHasChanged); |
|
} |
|
|
|
public void Dispose() => CanService.MessageReceived -= OnMessageReceived; |
|
|
|
// ── Connection ──────────────────────────────────────────────────────────── |
|
|
|
private void ScanChannels() |
|
{ |
|
availableChannels = CanService.GetAvailableChannels().ToList(); |
|
if (availableChannels.Count > 0 && |
|
!availableChannels.Any(c => c.ToString() == selectedChannel)) |
|
selectedChannel = availableChannels[0].ToString(); |
|
} |
|
|
|
private async Task Connect() |
|
{ |
|
connectError = null; |
|
if (!Enum.TryParse<PcanChannel>(selectedChannel, out var channel)) |
|
{ connectError = "Please select a channel."; return; } |
|
if (!Enum.TryParse<Bitrate>(selectedBitrate, out var bitrate)) |
|
{ connectError = "Please select a bitrate."; return; } |
|
|
|
isConnecting = true; |
|
CanService.RequestReinitialize(channel, bitrate); |
|
|
|
// Poll until the worker completes the reinit (typically < 50 ms) |
|
for (int i = 0; i < 20; i++) |
|
{ |
|
await Task.Delay(100); |
|
if (CanService.IsConnected) break; |
|
} |
|
|
|
isConnecting = false; |
|
if (CanService.IsConnected) |
|
showConnectionForm = false; |
|
else |
|
connectError = "Connection failed. Check the adapter is plugged in and try again."; |
|
|
|
StateHasChanged(); |
|
} |
|
|
|
private static string BitrateLabel(Bitrate b) => b switch |
|
{ |
|
Bitrate.Pcan1000 => "1000 kbps", |
|
Bitrate.Pcan800 => "800 kbps", |
|
Bitrate.Pcan500 => "500 kbps", |
|
Bitrate.Pcan250 => "250 kbps", |
|
Bitrate.Pcan125 => "125 kbps", |
|
Bitrate.Pcan100 => "100 kbps", |
|
Bitrate.Pcan50 => "50 kbps", |
|
Bitrate.Pcan20 => "20 kbps", |
|
Bitrate.Pcan10 => "10 kbps", |
|
Bitrate.Pcan5 => "5 kbps", |
|
_ => b.ToString() |
|
}; |
|
|
|
// ── Messages ────────────────────────────────────────────────────────────── |
|
|
|
private void ClearMessages() |
|
{ |
|
lock (messages) messages.Clear(); |
|
lock (latestMessages) { latestMessages.Clear(); updateCounts.Clear(); } |
|
} |
|
|
|
// ── Filters ─────────────────────────────────────────────────────────────── |
|
|
|
private void AddFilter() |
|
{ |
|
filterError = null; |
|
if (!TryParseHex32(newFilterId, out uint msgId)) |
|
{ filterError = "Invalid ID — enter a hex value e.g. 100"; return; } |
|
if (!TryParseHex32(newFilterMask, out uint mask)) |
|
{ filterError = "Invalid mask — enter a hex value e.g. 7FF"; return; } |
|
|
|
var filter = new CanFilter { MessageId = msgId, Mask = mask, Description = newFilterDesc }; |
|
CanService.AddFilter(filter); |
|
filters = CanService.Filters.ToList(); |
|
newFilterId = ""; newFilterMask = "7FF"; newFilterDesc = ""; |
|
} |
|
|
|
private void RemoveFilter(Guid id) |
|
{ |
|
CanService.RemoveFilter(id); |
|
filters = CanService.Filters.ToList(); |
|
} |
|
|
|
private void ClearFilters() |
|
{ |
|
CanService.ClearFilters(); |
|
filters = []; |
|
} |
|
|
|
// ── Bitmasks ────────────────────────────────────────────────────────────── |
|
|
|
private void AddBitmask() |
|
{ |
|
bitmaskError = null; |
|
if (!TryParseHex32(newBmMsgId, out uint msgId)) |
|
{ bitmaskError = "Invalid Msg ID"; return; } |
|
if (string.IsNullOrWhiteSpace(newBmName)) |
|
{ bitmaskError = "Signal name is required"; return; } |
|
if (!TryParseHex64(newBmMask, out ulong mask)) |
|
{ bitmaskError = "Invalid 64-bit mask — enter hex e.g. 00FF000000000000"; return; } |
|
|
|
var bitmask = new CanBitmask |
|
{ |
|
MessageId = msgId, |
|
SignalName = newBmName.Trim(), |
|
DataMask = mask, |
|
RightShift = newBmShift, |
|
Scale = newBmScale, |
|
Offset = newBmOffset, |
|
Unit = string.IsNullOrWhiteSpace(newBmUnit) ? null : newBmUnit |
|
}; |
|
CanService.AddBitmask(bitmask); |
|
bitmasks = CanService.Bitmasks.ToList(); |
|
newBmMsgId = ""; newBmName = ""; newBmMask = ""; |
|
newBmShift = 0; newBmScale = 1.0; newBmOffset = 0.0; newBmUnit = ""; |
|
} |
|
|
|
private void RemoveBitmask(Guid id) |
|
{ |
|
CanService.RemoveBitmask(id); |
|
bitmasks = CanService.Bitmasks.ToList(); |
|
} |
|
|
|
private void ClearBitmasks() |
|
{ |
|
CanService.ClearBitmasks(); |
|
bitmasks = []; |
|
} |
|
|
|
// ── Send ────────────────────────────────────────────────────────────────── |
|
|
|
private void SendMessage() |
|
{ |
|
sendFeedback = null; |
|
if (!TryParseHex32(sendIdHex, out uint id)) |
|
{ sendFeedback = "Invalid ID"; sendOk = false; return; } |
|
|
|
byte[] data; |
|
try |
|
{ |
|
var hex = (sendDataHex ?? "").Replace(" ", ""); |
|
data = hex.Length > 0 ? Convert.FromHexString(hex) : []; |
|
} |
|
catch { sendFeedback = "Invalid hex data"; sendOk = false; return; } |
|
|
|
if (data.Length > 8) |
|
{ sendFeedback = "Max 8 bytes"; sendOk = false; return; } |
|
|
|
var msg = new PcanMessage |
|
{ |
|
ID = id, |
|
MsgType = sendExtended ? MessageType.Extended : MessageType.Standard, |
|
DLC = (byte)data.Length, |
|
Data = new byte[8] |
|
}; |
|
data.CopyTo(msg.Data, 0); |
|
|
|
var result = CanService.Write(msg); |
|
sendOk = result == PcanStatus.OK; |
|
sendFeedback = sendOk ? "Sent!" : $"Error: {result}"; |
|
} |
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────── |
|
|
|
private List<CanMessageDto> GetLatestRows() |
|
{ |
|
lock (latestMessages) |
|
return latestMessages.Values.OrderBy(m => m.Id).ToList(); |
|
} |
|
|
|
private static string SignalsText(CanMessageDto m) |
|
{ |
|
if (m.Signals is null || m.Signals.Count == 0) return "–"; |
|
return string.Join(", ", m.Signals.Select(kv => $"{kv.Key}={kv.Value:G4}")); |
|
} |
|
|
|
private static bool TryParseHex32(string? s, out uint value) |
|
{ |
|
value = 0; |
|
if (string.IsNullOrWhiteSpace(s)) return false; |
|
var hex = s.Trim(); |
|
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..]; |
|
return uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out value); |
|
} |
|
|
|
private static bool TryParseHex64(string? s, out ulong value) |
|
{ |
|
value = 0; |
|
if (string.IsNullOrWhiteSpace(s)) return false; |
|
var hex = s.Trim(); |
|
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..]; |
|
return ulong.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out value); |
|
} |
|
}
|
|
|