Compare commits
24 Commits
focas-pr2-
...
diff-viewe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a38bc57b | ||
| cecb84fa5d | |||
|
|
13d5a7968b | ||
| d1686ed82d | |||
|
|
ac69a1c39d | ||
| 30714831fa | |||
|
|
44d4448b37 | ||
| 572f8887e4 | |||
|
|
2acea08ced | ||
| 49f6c9484e | |||
|
|
d06cc01a48 | ||
| 5536e96b46 | |||
|
|
ece530d133 | ||
| b55cef5f8b | |||
|
|
088c4817fe | ||
| 91e6153b5d | |||
|
|
00a428c444 | ||
| 07fd105ffc | |||
|
|
8c309aebf3 | ||
| d1ca0817e9 | |||
|
|
c95228391d | ||
| 9ca80fd450 | |||
|
|
1d6015bc87 | ||
| 5cfb0fc6d0 |
@@ -10,6 +10,7 @@
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/role-grants">Role grants</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5">
|
||||
|
||||
@@ -52,6 +52,7 @@ else
|
||||
<li class="nav-item"><button class="nav-link @Tab("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("redundancy")" @onclick='() => _tab = "redundancy"'>Redundancy</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("audit")" @onclick='() => _tab = "audit"'>Audit</button></li>
|
||||
</ul>
|
||||
|
||||
@@ -92,6 +93,10 @@ else
|
||||
{
|
||||
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "redundancy")
|
||||
{
|
||||
<RedundancyTab ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "audit")
|
||||
{
|
||||
<AuditTab ClusterId="@ClusterId"/>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
|
||||
@* Per-section diff renderer — the base used by DiffViewer for every known TableName. Caps
|
||||
output at RowCap rows so a pathological draft (e.g. 20k tags churned) can't freeze the
|
||||
Blazor render; overflow banner tells operator how many rows were hidden. *@
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>@Title</strong>
|
||||
<small class="text-muted ms-2">@Description</small>
|
||||
</div>
|
||||
<div>
|
||||
@if (_added > 0) { <span class="badge bg-success me-1">+@_added</span> }
|
||||
@if (_removed > 0) { <span class="badge bg-danger me-1">−@_removed</span> }
|
||||
@if (_modified > 0) { <span class="badge bg-warning text-dark me-1">~@_modified</span> }
|
||||
@if (_total == 0) { <span class="badge bg-secondary">no changes</span> }
|
||||
</div>
|
||||
</div>
|
||||
@if (_total == 0)
|
||||
{
|
||||
<div class="card-body text-muted small">No changes in this section.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_total > RowCap)
|
||||
{
|
||||
<div class="alert alert-warning mb-0 small rounded-0">
|
||||
Showing the first @RowCap of @_total rows — cap protects the browser from megabyte-class
|
||||
diffs. Inspect the remainder via the SQL <code>sp_ComputeGenerationDiff</code> directly.
|
||||
</div>
|
||||
}
|
||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>LogicalId</th><th style="width: 120px;">Change</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _visibleRows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.LogicalId</code></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Default row-cap per section — matches task #156's acceptance criterion.</summary>
|
||||
public const int DefaultRowCap = 1000;
|
||||
|
||||
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
|
||||
[Parameter] public string Description { get; set; } = string.Empty;
|
||||
[Parameter, EditorRequired] public IReadOnlyList<DiffRow> Rows { get; set; } = [];
|
||||
[Parameter] public int RowCap { get; set; } = DefaultRowCap;
|
||||
|
||||
private int _total;
|
||||
private int _added;
|
||||
private int _removed;
|
||||
private int _modified;
|
||||
private List<DiffRow> _visibleRows = [];
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_total = Rows.Count;
|
||||
_added = 0; _removed = 0; _modified = 0;
|
||||
foreach (var r in Rows)
|
||||
{
|
||||
switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": _added++; break;
|
||||
case "Removed": _removed++; break;
|
||||
case "Modified": _modified++; break;
|
||||
}
|
||||
}
|
||||
_visibleRows = _total > RowCap ? Rows.Take(RowCap).ToList() : Rows.ToList();
|
||||
}
|
||||
}
|
||||
@@ -28,36 +28,44 @@ else if (_rows.Count == 0)
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-hover table-sm">
|
||||
<thead><tr><th>Table</th><th>LogicalId</th><th>ChangeKind</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td>@r.TableName</td>
|
||||
<td><code>@r.LogicalId</code></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small text-muted mb-3">
|
||||
@_rows.Count row@(_rows.Count == 1 ? "" : "s") across @_sectionsWithChanges of @Sections.Count sections.
|
||||
Each section is capped at @DiffSection.DefaultRowCap rows to keep the browser responsive on pathological drafts.
|
||||
</p>
|
||||
|
||||
@foreach (var sec in Sections)
|
||||
{
|
||||
<DiffSection Title="@sec.Title"
|
||||
Description="@sec.Description"
|
||||
Rows="@RowsFor(sec.TableName)"/>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered section definitions — each maps a <c>TableName</c> emitted by
|
||||
/// <c>sp_ComputeGenerationDiff</c> to a human label + description. The proc currently
|
||||
/// emits Namespace/DriverInstance/Equipment/Tag; UnsLine + NodeAcl entries render as
|
||||
/// empty "no changes" cards until the proc is extended (tracked in tasks #196 + #156
|
||||
/// follow-up). Six sections total matches the task #156 target.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyList<SectionDef> Sections = new[]
|
||||
{
|
||||
new SectionDef("Namespace", "Namespaces", "OPC UA namespace URIs + enablement"),
|
||||
new SectionDef("DriverInstance", "Driver instances","Per-cluster driver configuration rows"),
|
||||
new SectionDef("Equipment", "Equipment", "UNS level-5 rows + identification fields"),
|
||||
new SectionDef("Tag", "Tags", "Per-device tag definitions + poll-group binding"),
|
||||
new SectionDef("UnsLine", "UNS structure", "Site / Area / Line hierarchy (proc-extension pending)"),
|
||||
new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (proc-extension pending)"),
|
||||
};
|
||||
|
||||
private List<DiffRow>? _rows;
|
||||
private string _fromLabel = "(empty)";
|
||||
private string? _error;
|
||||
private int _sectionsWithChanges;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
@@ -67,7 +75,13 @@ else
|
||||
var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||
_fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}";
|
||||
_rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None);
|
||||
_sectionsWithChanges = Sections.Count(s => _rows.Any(r => r.TableName == s.TableName));
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private IReadOnlyList<DiffRow> RowsFor(string tableName) =>
|
||||
_rows?.Where(r => r.TableName == tableName).ToList() ?? [];
|
||||
|
||||
private sealed record SectionDef(string TableName, string Title, string Description);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
@if (_tab == "equipment") { <EquipmentTab GenerationId="@GenerationId"/> }
|
||||
@if (_tab == "equipment") { <EquipmentTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "uns") { <UnsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||
@inject EquipmentService EquipmentSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Equipment (draft gen @GenerationId)</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
|
||||
<div>
|
||||
<button class="btn btn-outline-primary btn-sm me-2" @onclick="GoImport">Import CSV…</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_equipment is null)
|
||||
@@ -36,7 +40,10 @@ else if (_equipment.Count > 0)
|
||||
<td>@e.SAPID</td>
|
||||
<td>@e.Manufacturer / @e.Model</td>
|
||||
<td>@e.SerialNumber</td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(e)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -47,8 +54,8 @@ else if (_equipment.Count > 0)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5>New equipment</h5>
|
||||
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="new-equipment">
|
||||
<h5>@(_editMode ? "Edit equipment" : "New equipment")</h5>
|
||||
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="equipment-form">
|
||||
<DataAnnotationsValidator/>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
@@ -78,24 +85,13 @@ else if (_equipment.Count > 0)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-4">OPC 40010 Identification</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4"><label class="form-label">Manufacturer</label><InputText @bind-Value="_draft.Manufacturer" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Model</label><InputText @bind-Value="_draft.Model" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Serial number</label><InputText @bind-Value="_draft.SerialNumber" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Hardware rev</label><InputText @bind-Value="_draft.HardwareRevision" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Software rev</label><InputText @bind-Value="_draft.SoftwareRevision" class="form-control"/></div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Year of construction</label>
|
||||
<InputNumber @bind-Value="_draft.YearOfConstruction" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
<IdentificationFields Equipment="_draft"/>
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="Cancel">Cancel</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
@@ -104,8 +100,12 @@ else if (_equipment.Count > 0)
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private void GoImport() => Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}/import-equipment");
|
||||
private List<Equipment>? _equipment;
|
||||
private bool _showForm;
|
||||
private bool _editMode;
|
||||
private Equipment _draft = NewBlankDraft();
|
||||
private string? _error;
|
||||
|
||||
@@ -125,20 +125,68 @@ else if (_equipment.Count > 0)
|
||||
private void StartAdd()
|
||||
{
|
||||
_draft = NewBlankDraft();
|
||||
_editMode = false;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void StartEdit(Equipment row)
|
||||
{
|
||||
// Shallow-clone so Cancel doesn't mutate the list-displayed row with in-flight form edits.
|
||||
_draft = new Equipment
|
||||
{
|
||||
EquipmentRowId = row.EquipmentRowId,
|
||||
GenerationId = row.GenerationId,
|
||||
EquipmentId = row.EquipmentId,
|
||||
EquipmentUuid = row.EquipmentUuid,
|
||||
DriverInstanceId = row.DriverInstanceId,
|
||||
DeviceId = row.DeviceId,
|
||||
UnsLineId = row.UnsLineId,
|
||||
Name = row.Name,
|
||||
MachineCode = row.MachineCode,
|
||||
ZTag = row.ZTag,
|
||||
SAPID = row.SAPID,
|
||||
Manufacturer = row.Manufacturer,
|
||||
Model = row.Model,
|
||||
SerialNumber = row.SerialNumber,
|
||||
HardwareRevision = row.HardwareRevision,
|
||||
SoftwareRevision = row.SoftwareRevision,
|
||||
YearOfConstruction = row.YearOfConstruction,
|
||||
AssetLocation = row.AssetLocation,
|
||||
ManufacturerUri = row.ManufacturerUri,
|
||||
DeviceManualUri = row.DeviceManualUri,
|
||||
EquipmentClassRef = row.EquipmentClassRef,
|
||||
Enabled = row.Enabled,
|
||||
};
|
||||
_editMode = true;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
_showForm = false;
|
||||
_editMode = false;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
_draft.EquipmentUuid = Guid.NewGuid();
|
||||
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
|
||||
_draft.GenerationId = GenerationId;
|
||||
try
|
||||
{
|
||||
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
|
||||
if (_editMode)
|
||||
{
|
||||
await EquipmentSvc.UpdateAsync(_draft, CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
_draft.EquipmentUuid = Guid.NewGuid();
|
||||
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
|
||||
_draft.GenerationId = GenerationId;
|
||||
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
|
||||
}
|
||||
_showForm = false;
|
||||
_editMode = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
|
||||
@* Reusable OPC 40010 Machinery Identification editor. Binds to an Equipment row and renders the
|
||||
nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's
|
||||
create + edit forms so the same UI renders regardless of which flow opened it. *@
|
||||
|
||||
<h6 class="mt-4">OPC 40010 Identification</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Manufacturer</label>
|
||||
<InputText @bind-Value="Equipment!.Manufacturer" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Model</label>
|
||||
<InputText @bind-Value="Equipment!.Model" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Serial number</label>
|
||||
<InputText @bind-Value="Equipment!.SerialNumber" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Hardware rev</label>
|
||||
<InputText @bind-Value="Equipment!.HardwareRevision" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Software rev</label>
|
||||
<InputText @bind-Value="Equipment!.SoftwareRevision" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Year of construction</label>
|
||||
<InputNumber @bind-Value="Equipment!.YearOfConstruction" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Asset location</label>
|
||||
<InputText @bind-Value="Equipment!.AssetLocation" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Manufacturer URI</label>
|
||||
<InputText @bind-Value="Equipment!.ManufacturerUri" class="form-control" placeholder="https://…"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Device manual URI</label>
|
||||
<InputText @bind-Value="Equipment!.DeviceManualUri" class="form-control" placeholder="https://…"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public Equipment? Equipment { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/import-equipment"
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject DriverInstanceService DriverSvc
|
||||
@inject UnsService UnsSvc
|
||||
@inject EquipmentImportBatchService BatchSvc
|
||||
@inject NavigationManager Nav
|
||||
@inject AuthenticationStateProvider AuthProvider
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">Equipment CSV import</h1>
|
||||
<small class="text-muted">Cluster <code>@ClusterId</code> · draft generation @GenerationId</small>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info small mb-3">
|
||||
Accepts <code>@EquipmentCsvImporter.VersionMarker</code>-headered CSV per Stream B.3.
|
||||
Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns).
|
||||
Optional columns cover the OPC 40010 Identification fields. Paste the file contents
|
||||
or upload directly — the parser runs client-stream-side and shows a row-level preview
|
||||
before anything lands in the draft. ZTag + SAPID uniqueness across the fleet is NOT
|
||||
enforced here yet (see task #197); for now the finalise may fail at commit time if a
|
||||
reservation conflict exists.
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Target driver instance (for every accepted row)</label>
|
||||
<select class="form-select" @bind="_driverInstanceId">
|
||||
<option value="">-- select driver --</option>
|
||||
@if (_drivers is not null)
|
||||
{
|
||||
@foreach (var d in _drivers) { <option value="@d.DriverInstanceId">@d.DriverInstanceId</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Target UNS line (for every accepted row)</label>
|
||||
<select class="form-select" @bind="_unsLineId">
|
||||
<option value="">-- select line --</option>
|
||||
@if (_unsLines is not null)
|
||||
{
|
||||
@foreach (var l in _unsLines) { <option value="@l.UnsLineId">@l.UnsLineId — @l.Name</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 pt-4">
|
||||
<InputFile OnChange="HandleFileAsync" class="form-control form-control-sm" accept=".csv,.txt"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-label">CSV content (paste or uploaded)</label>
|
||||
<textarea class="form-control font-monospace" rows="8" @bind="_csvText"
|
||||
placeholder="# OtOpcUaCsv v1 ZTag,MachineCode,SAPID,EquipmentId,…"/>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="ParseAsync" disabled="@_busy">Parse</button>
|
||||
<button class="btn btn-sm btn-primary ms-2" @onclick="StageAndFinaliseAsync"
|
||||
disabled="@(_parseResult is null || _parseResult.AcceptedRows.Count == 0 || string.IsNullOrWhiteSpace(_driverInstanceId) || string.IsNullOrWhiteSpace(_unsLineId) || _busy)">
|
||||
Stage + Finalise
|
||||
</button>
|
||||
@if (_parseError is not null) { <span class="alert alert-danger ms-3 py-1 px-2 small">@_parseError</span> }
|
||||
@if (_result is not null) { <span class="alert alert-success ms-3 py-1 px-2 small">@_result</span> }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_parseResult is not null)
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-success text-white">
|
||||
Accepted (@_parseResult.AcceptedRows.Count)
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
@if (_parseResult.AcceptedRows.Count == 0)
|
||||
{
|
||||
<p class="text-muted p-3 mb-0">No accepted rows.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr><th>ZTag</th><th>Machine</th><th>Name</th><th>Line</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _parseResult.AcceptedRows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.ZTag</code></td>
|
||||
<td>@r.MachineCode</td>
|
||||
<td>@r.Name</td>
|
||||
<td>@r.UnsLineName</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
Rejected (@_parseResult.RejectedRows.Count)
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
@if (_parseResult.RejectedRows.Count == 0)
|
||||
{
|
||||
<p class="text-muted p-3 mb-0">No rejections.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead><tr><th>Line</th><th>Reason</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var e in _parseResult.RejectedRows)
|
||||
{
|
||||
<tr>
|
||||
<td>@e.LineNumber</td>
|
||||
<td class="small">@e.Reason</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
private List<DriverInstance>? _drivers;
|
||||
private List<UnsLine>? _unsLines;
|
||||
private string _driverInstanceId = string.Empty;
|
||||
private string _unsLineId = string.Empty;
|
||||
private string _csvText = string.Empty;
|
||||
private EquipmentCsvParseResult? _parseResult;
|
||||
private string? _parseError;
|
||||
private string? _result;
|
||||
private bool _busy;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_unsLines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task HandleFileAsync(InputFileChangeEventArgs e)
|
||||
{
|
||||
// 5 MiB cap — refuses pathological uploads that would OOM the server.
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024);
|
||||
using var reader = new StreamReader(stream);
|
||||
_csvText = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
private void ParseAsync()
|
||||
{
|
||||
_parseError = null;
|
||||
_parseResult = null;
|
||||
_result = null;
|
||||
try { _parseResult = EquipmentCsvImporter.Parse(_csvText); }
|
||||
catch (InvalidCsvFormatException ex) { _parseError = ex.Message; }
|
||||
catch (Exception ex) { _parseError = $"Parse failed: {ex.Message}"; }
|
||||
}
|
||||
|
||||
private async Task StageAndFinaliseAsync()
|
||||
{
|
||||
if (_parseResult is null) return;
|
||||
_busy = true;
|
||||
_result = null;
|
||||
_parseError = null;
|
||||
try
|
||||
{
|
||||
var auth = await AuthProvider.GetAuthenticationStateAsync();
|
||||
var createdBy = auth.User.Identity?.Name ?? "unknown";
|
||||
|
||||
var batch = await BatchSvc.CreateBatchAsync(ClusterId, createdBy, CancellationToken.None);
|
||||
await BatchSvc.StageRowsAsync(batch.Id, _parseResult.AcceptedRows, _parseResult.RejectedRows, CancellationToken.None);
|
||||
await BatchSvc.FinaliseBatchAsync(batch.Id, GenerationId, _driverInstanceId, _unsLineId, CancellationToken.None);
|
||||
|
||||
_result = $"Finalised batch {batch.Id:N} — {_parseResult.AcceptedRows.Count} rows added.";
|
||||
// Pause 600 ms so the success banner is visible, then navigate back.
|
||||
await Task.Delay(600);
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}");
|
||||
}
|
||||
catch (Exception ex) { _parseError = $"Finalise failed: {ex.Message}"; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject ClusterNodeService NodeSvc
|
||||
|
||||
<h4>Redundancy topology</h4>
|
||||
<p class="text-muted small">
|
||||
One row per <code>ClusterNode</code> in this cluster. Role, <code>ApplicationUri</code>,
|
||||
and <code>ServiceLevelBase</code> are authored separately; the Admin UI shows them read-only
|
||||
here so operators can confirm the published topology without touching it. LastSeen older than
|
||||
@((int)ClusterNodeService.StaleThreshold.TotalSeconds)s is flagged Stale — the node has
|
||||
stopped heart-beating and is likely down. Role swap goes through the server-side
|
||||
<code>RedundancyCoordinator</code> apply-lease flow, not direct DB edits.
|
||||
</p>
|
||||
|
||||
@if (_nodes is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_nodes.Count == 0)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
No ClusterNode rows for this cluster. The server process needs at least one entry
|
||||
(with a non-blank <code>ApplicationUri</code>) before it can start up per OPC UA spec.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var primaries = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Primary);
|
||||
var secondaries = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Secondary);
|
||||
var standalone = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Standalone);
|
||||
var staleCount = _nodes.Count(ClusterNodeService.IsStale);
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Nodes</h6>
|
||||
<div class="fs-3">@_nodes.Count</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-success"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Primary</h6>
|
||||
<div class="fs-3 text-success">@primaries</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-info"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Secondary</h6>
|
||||
<div class="fs-3 text-info">@secondaries</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card @(staleCount > 0 ? "border-warning" : "")"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Stale</h6>
|
||||
<div class="fs-3 @(staleCount > 0 ? "text-warning" : "")">@staleCount</div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (primaries == 0 && standalone == 0)
|
||||
{
|
||||
<div class="alert alert-danger small mb-3">
|
||||
No Primary or Standalone node — the cluster has no authoritative write target. Secondaries
|
||||
stay read-only until one of them gets promoted via <code>RedundancyCoordinator</code>.
|
||||
</div>
|
||||
}
|
||||
else if (primaries > 1)
|
||||
{
|
||||
<div class="alert alert-danger small mb-3">
|
||||
<strong>Split-brain:</strong> @primaries nodes claim the Primary role. Apply-lease
|
||||
enforcement should have made this impossible at the coordinator level. Investigate
|
||||
immediately — one of the rows was likely hand-edited.
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Role</th>
|
||||
<th>Host</th>
|
||||
<th class="text-end">OPC UA port</th>
|
||||
<th class="text-end">ServiceLevel base</th>
|
||||
<th>ApplicationUri</th>
|
||||
<th>Enabled</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var n in _nodes)
|
||||
{
|
||||
<tr class="@RowClass(n)">
|
||||
<td><code>@n.NodeId</code></td>
|
||||
<td><span class="badge @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td>
|
||||
<td>@n.Host</td>
|
||||
<td class="text-end"><code>@n.OpcUaPort</code></td>
|
||||
<td class="text-end">@n.ServiceLevelBase</td>
|
||||
<td class="small text-break"><code>@n.ApplicationUri</code></td>
|
||||
<td>
|
||||
@if (n.Enabled) { <span class="badge bg-success">Enabled</span> }
|
||||
else { <span class="badge bg-secondary">Disabled</span> }
|
||||
</td>
|
||||
<td class="small @(ClusterNodeService.IsStale(n) ? "text-warning fw-bold" : "")">
|
||||
@(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value))
|
||||
@if (ClusterNodeService.IsStale(n)) { <span class="badge bg-warning text-dark ms-1">Stale</span> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<ClusterNode>? _nodes;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static string RowClass(ClusterNode n) =>
|
||||
ClusterNodeService.IsStale(n) ? "table-warning" :
|
||||
!n.Enabled ? "table-secondary" : "";
|
||||
|
||||
private static string RoleBadge(RedundancyRole r) => r switch
|
||||
{
|
||||
RedundancyRole.Primary => "bg-success",
|
||||
RedundancyRole.Secondary => "bg-info",
|
||||
RedundancyRole.Standalone => "bg-primary",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime t)
|
||||
{
|
||||
var age = DateTime.UtcNow - t;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,16 @@ else
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (_rows.Any(HostStatusService.IsFlagged))
|
||||
{
|
||||
var flaggedCount = _rows.Count(HostStatusService.IsFlagged);
|
||||
<div class="alert alert-danger small mb-3">
|
||||
<strong>@flaggedCount host@(flaggedCount == 1 ? "" : "s")</strong>
|
||||
reporting ≥ @HostStatusService.FailureFlagThreshold consecutive failures — circuit breaker
|
||||
may trip soon. Inspect the resilience columns below to locate.
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
|
||||
{
|
||||
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2>
|
||||
@@ -66,6 +76,9 @@ else
|
||||
<th>Driver</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th class="text-end" title="Consecutive failures — resets when a call succeeds or the breaker closes">Fail#</th>
|
||||
<th class="text-end" title="In-flight capability calls (bulkhead-depth proxy)">In-flight</th>
|
||||
<th>Breaker opened</th>
|
||||
<th>Last transition</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
@@ -84,10 +97,21 @@ else
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1">Stale</span>
|
||||
}
|
||||
@if (HostStatusService.IsFlagged(r))
|
||||
{
|
||||
<span class="badge bg-danger ms-1" title="≥ @HostStatusService.FailureFlagThreshold consecutive failures">Flagged</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end small @(HostStatusService.IsFlagged(r) ? "text-danger fw-bold" : "")">
|
||||
@r.ConsecutiveFailures
|
||||
</td>
|
||||
<td class="text-end small">@r.CurrentBulkheadDepth</td>
|
||||
<td class="small">
|
||||
@(r.LastCircuitBreakerOpenUtc is null ? "—" : FormatAge(r.LastCircuitBreakerOpenUtc.Value))
|
||||
</td>
|
||||
<td class="small">@FormatAge(r.StateChangedUtc)</td>
|
||||
<td class="small @(HostStatusService.IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 320px;" title="@r.Detail">@r.Detail</td>
|
||||
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
||||
161
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor
Normal file
161
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor
Normal file
@@ -0,0 +1,161 @@
|
||||
@page "/role-grants"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Services
|
||||
@inject ILdapGroupRoleMappingService RoleSvc
|
||||
@inject ClusterService ClusterSvc
|
||||
|
||||
<h1 class="mb-4">LDAP group → Admin role grants</h1>
|
||||
|
||||
<div class="alert alert-info small mb-4">
|
||||
Maps LDAP groups to Admin UI roles (ConfigViewer / ConfigEditor / FleetAdmin). Control-plane
|
||||
only — OPC UA data-path authorization reads <code>NodeAcl</code> rows directly and is
|
||||
unaffected by these mappings (see decision #150). A fleet-wide grant applies across every
|
||||
cluster; a cluster-scoped grant only binds within the named cluster. The same LDAP group
|
||||
may hold different roles on different clusters.
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No role grants defined yet. Without at least one FleetAdmin grant,
|
||||
only the bootstrap admin can publish drafts.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr><th>LDAP group</th><th>Role</th><th>Scope</th><th>Created</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.LdapGroup</code></td>
|
||||
<td><span class="badge bg-secondary">@r.Role</span></td>
|
||||
<td>@(r.IsSystemWide ? "Fleet-wide" : $"Cluster: {r.ClusterId}")</td>
|
||||
<td class="small">@r.CreatedAtUtc.ToString("yyyy-MM-dd")</td>
|
||||
<td class="small text-muted">@r.Notes</td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(r.Id)">Revoke</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5>New role grant</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">LDAP group (DN)</label>
|
||||
<input class="form-control" @bind="_group" placeholder="cn=fleet-admin,ou=groups,dc=…"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Role</label>
|
||||
<select class="form-select" @bind="_role">
|
||||
@foreach (var r in Enum.GetValues<AdminRole>())
|
||||
{
|
||||
<option value="@r">@r</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 pt-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="systemWide" @bind="_isSystemWide"/>
|
||||
<label class="form-check-label" for="systemWide">Fleet-wide</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Cluster @(_isSystemWide ? "(disabled)" : "")</label>
|
||||
<select class="form-select" @bind="_clusterId" disabled="@_isSystemWide">
|
||||
<option value="">-- select --</option>
|
||||
@if (_clusters is not null)
|
||||
{
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<option value="@c.ClusterId">@c.ClusterId</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes (optional)</label>
|
||||
<input class="form-control" @bind="_notes"/>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<LdapGroupRoleMapping>? _rows;
|
||||
private List<ServerCluster>? _clusters;
|
||||
private bool _showForm;
|
||||
private string _group = string.Empty;
|
||||
private AdminRole _role = AdminRole.ConfigViewer;
|
||||
private bool _isSystemWide;
|
||||
private string _clusterId = string.Empty;
|
||||
private string? _notes;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_rows = await RoleSvc.ListAllAsync(CancellationToken.None);
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private void StartAdd()
|
||||
{
|
||||
_group = string.Empty;
|
||||
_role = AdminRole.ConfigViewer;
|
||||
_isSystemWide = false;
|
||||
_clusterId = string.Empty;
|
||||
_notes = null;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var row = new LdapGroupRoleMapping
|
||||
{
|
||||
LdapGroup = _group.Trim(),
|
||||
Role = _role,
|
||||
IsSystemWide = _isSystemWide,
|
||||
ClusterId = _isSystemWide ? null : (string.IsNullOrWhiteSpace(_clusterId) ? null : _clusterId),
|
||||
Notes = string.IsNullOrWhiteSpace(_notes) ? null : _notes,
|
||||
};
|
||||
await RoleSvc.CreateAsync(row, CancellationToken.None);
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await RoleSvc.DeleteAsync(id, CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,10 @@ builder.Services.AddScoped<ReservationService>();
|
||||
builder.Services.AddScoped<DraftValidationService>();
|
||||
builder.Services.AddScoped<AuditLogService>();
|
||||
builder.Services.AddScoped<HostStatusService>();
|
||||
builder.Services.AddScoped<ClusterNodeService>();
|
||||
builder.Services.AddScoped<EquipmentImportBatchService>();
|
||||
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
||||
|
||||
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||
|
||||
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterNodeService.cs
Normal file
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterNodeService.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for ClusterNode rows + their cluster-scoped redundancy view. Consumed
|
||||
/// by the RedundancyTab on the cluster detail page. Writes (role swap, node enable/disable)
|
||||
/// are not supported here — role swap happens through the RedundancyCoordinator apply-lease
|
||||
/// flow on the server side and would conflict with any direct DB mutation from Admin.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Stale-threshold matching <c>HostStatusService.StaleThreshold</c> — 30s of clock
|
||||
/// tolerance covers a missed heartbeat plus publisher GC pauses.</summary>
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
public Task<List<ClusterNode>> ListByClusterAsync(string clusterId, CancellationToken ct) =>
|
||||
db.ClusterNodes.AsNoTracking()
|
||||
.Where(n => n.ClusterId == clusterId)
|
||||
.OrderByDescending(n => n.ServiceLevelBase)
|
||||
.ThenBy(n => n.NodeId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public static bool IsStale(ClusterNode node) =>
|
||||
node.LastSeenAt is null || DateTime.UtcNow - node.LastSeenAt.Value > StaleThreshold;
|
||||
}
|
||||
@@ -7,8 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One row per <see cref="DriverHostStatus"/> record, enriched with the owning
|
||||
/// <c>ClusterNode.ClusterId</c> when available (left-join). The Admin <c>/hosts</c> page
|
||||
/// groups by cluster and renders a per-node → per-driver → per-host tree.
|
||||
/// <c>ClusterNode.ClusterId</c> (left-join) + the per-<c>(DriverInstanceId, HostName)</c>
|
||||
/// <see cref="DriverInstanceResilienceStatus"/> counters (also left-join) so the Admin
|
||||
/// <c>/hosts</c> page renders the resilience surface inline with host state.
|
||||
/// </summary>
|
||||
public sealed record HostStatusRow(
|
||||
string NodeId,
|
||||
@@ -18,7 +19,11 @@ public sealed record HostStatusRow(
|
||||
DriverHostState State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail);
|
||||
string? Detail,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
int CurrentBulkheadDepth,
|
||||
DateTime? LastRecycleUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for the Admin UI's per-host drill-down. Loads
|
||||
@@ -36,15 +41,26 @@ public sealed class HostStatusService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Consecutive-failure threshold at which <see cref="IsFlagged"/> returns <c>true</c>
|
||||
/// so the Admin UI can paint a red badge. Matches Phase 6.1 decision #143's conservative
|
||||
/// half-of-breaker-threshold convention — flags before the breaker actually opens.</summary>
|
||||
public const int FailureFlagThreshold = 3;
|
||||
|
||||
public async Task<IReadOnlyList<HostStatusRow>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
// LEFT JOIN on NodeId so a row persists even when its owning ClusterNode row hasn't
|
||||
// been created yet (first-boot bootstrap case — keeps the UI from losing sight of
|
||||
// the reporting server).
|
||||
// Two LEFT JOINs:
|
||||
// 1. ClusterNodes on NodeId — row persists even when its owning ClusterNode row
|
||||
// hasn't been created yet (first-boot bootstrap case).
|
||||
// 2. DriverInstanceResilienceStatuses on (DriverInstanceId, HostName) — resilience
|
||||
// counters haven't been sampled yet for brand-new hosts, so a missing row means
|
||||
// zero failures + never-opened breaker.
|
||||
var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
join n in db.ClusterNodes.AsNoTracking()
|
||||
on s.NodeId equals n.NodeId into nodeJoin
|
||||
from n in nodeJoin.DefaultIfEmpty()
|
||||
join r in db.DriverInstanceResilienceStatuses.AsNoTracking()
|
||||
on new { s.DriverInstanceId, s.HostName } equals new { r.DriverInstanceId, r.HostName } into resilJoin
|
||||
from r in resilJoin.DefaultIfEmpty()
|
||||
orderby s.NodeId, s.DriverInstanceId, s.HostName
|
||||
select new HostStatusRow(
|
||||
s.NodeId,
|
||||
@@ -54,10 +70,21 @@ public sealed class HostStatusService(OtOpcUaConfigDbContext db)
|
||||
s.State,
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail)).ToListAsync(ct);
|
||||
s.Detail,
|
||||
r != null ? r.ConsecutiveFailures : 0,
|
||||
r != null ? r.LastCircuitBreakerOpenUtc : null,
|
||||
r != null ? r.CurrentBulkheadDepth : 0,
|
||||
r != null ? r.LastRecycleUtc : null)).ToListAsync(ct);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static bool IsStale(HostStatusRow row) =>
|
||||
DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
|
||||
|
||||
/// <summary>
|
||||
/// Red-badge predicate — <c>true</c> when the host has accumulated enough consecutive
|
||||
/// failures that an operator should take notice before the breaker trips.
|
||||
/// </summary>
|
||||
public static bool IsFlagged(HostStatusRow row) =>
|
||||
row.ConsecutiveFailures >= FailureFlagThreshold;
|
||||
}
|
||||
|
||||
@@ -24,11 +24,21 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
{
|
||||
private readonly ConcurrentDictionary<PipelineKey, ResiliencePipeline> _pipelines = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DriverResilienceStatusTracker? _statusTracker;
|
||||
|
||||
/// <summary>Construct with the ambient clock (use <see cref="TimeProvider.System"/> in prod).</summary>
|
||||
public DriverResiliencePipelineBuilder(TimeProvider? timeProvider = null)
|
||||
/// <param name="timeProvider">Clock source for pipeline timeouts + breaker sampling. Defaults to system.</param>
|
||||
/// <param name="statusTracker">When non-null, every built pipeline wires Polly telemetry into
|
||||
/// the tracker — retries increment <c>ConsecutiveFailures</c>, breaker-open stamps
|
||||
/// <c>LastBreakerOpenUtc</c>, breaker-close resets failures. Feeds Admin <c>/hosts</c> +
|
||||
/// the Polly bulkhead-depth column. Absent tracker means no telemetry (unit tests +
|
||||
/// deployments that don't care about resilience observability).</param>
|
||||
public DriverResiliencePipelineBuilder(
|
||||
TimeProvider? timeProvider = null,
|
||||
DriverResilienceStatusTracker? statusTracker = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_statusTracker = statusTracker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -54,8 +64,9 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(hostName);
|
||||
|
||||
var key = new PipelineKey(driverInstanceId, hostName, capability);
|
||||
return _pipelines.GetOrAdd(key, static (_, state) => Build(state.capability, state.options, state.timeProvider),
|
||||
(capability, options, timeProvider: _timeProvider));
|
||||
return _pipelines.GetOrAdd(key, static (k, state) => Build(
|
||||
k.DriverInstanceId, k.HostName, state.capability, state.options, state.timeProvider, state.tracker),
|
||||
(capability, options, timeProvider: _timeProvider, tracker: _statusTracker));
|
||||
}
|
||||
|
||||
/// <summary>Drop cached pipelines for one driver instance (e.g. on ResilienceConfig change). Test + Admin-reload use.</summary>
|
||||
@@ -74,9 +85,12 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
public int CachedPipelineCount => _pipelines.Count;
|
||||
|
||||
private static ResiliencePipeline Build(
|
||||
string driverInstanceId,
|
||||
string hostName,
|
||||
DriverCapability capability,
|
||||
DriverResilienceOptions options,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
DriverResilienceStatusTracker? tracker)
|
||||
{
|
||||
var policy = options.Resolve(capability);
|
||||
var builder = new ResiliencePipelineBuilder { TimeProvider = timeProvider };
|
||||
@@ -88,7 +102,7 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
|
||||
if (policy.RetryCount > 0)
|
||||
{
|
||||
builder.AddRetry(new RetryStrategyOptions
|
||||
var retryOptions = new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = policy.RetryCount,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
@@ -96,19 +110,44 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
Delay = TimeSpan.FromMilliseconds(100),
|
||||
MaxDelay = TimeSpan.FromSeconds(5),
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
|
||||
});
|
||||
};
|
||||
if (tracker is not null)
|
||||
{
|
||||
retryOptions.OnRetry = args =>
|
||||
{
|
||||
tracker.RecordFailure(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
|
||||
return default;
|
||||
};
|
||||
}
|
||||
builder.AddRetry(retryOptions);
|
||||
}
|
||||
|
||||
if (policy.BreakerFailureThreshold > 0)
|
||||
{
|
||||
builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions
|
||||
var breakerOptions = new CircuitBreakerStrategyOptions
|
||||
{
|
||||
FailureRatio = 1.0,
|
||||
MinimumThroughput = policy.BreakerFailureThreshold,
|
||||
SamplingDuration = TimeSpan.FromSeconds(30),
|
||||
BreakDuration = TimeSpan.FromSeconds(15),
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
|
||||
});
|
||||
};
|
||||
if (tracker is not null)
|
||||
{
|
||||
breakerOptions.OnOpened = args =>
|
||||
{
|
||||
tracker.RecordBreakerOpen(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
|
||||
return default;
|
||||
};
|
||||
breakerOptions.OnClosed = args =>
|
||||
{
|
||||
// Closing the breaker means the target recovered — reset the consecutive-
|
||||
// failure counter so Admin UI stops flashing red for this host.
|
||||
tracker.RecordSuccess(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
|
||||
return default;
|
||||
};
|
||||
}
|
||||
builder.AddCircuitBreaker(breakerOptions);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbCipTagFactory _tagFactory;
|
||||
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
|
||||
private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
|
||||
private readonly AbCipTemplateCache _templateCache = new();
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -38,19 +39,63 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||||
IAbCipTagFactory? tagFactory = null,
|
||||
IAbCipTagEnumeratorFactory? enumeratorFactory = null)
|
||||
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
|
||||
IAbCipTemplateReaderFactory? templateReaderFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_tagFactory = tagFactory ?? new LibplctagTagFactory();
|
||||
_enumeratorFactory = enumeratorFactory ?? new EmptyAbCipTagEnumeratorFactory();
|
||||
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
|
||||
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch + cache the shape of a Logix UDT by template instance id. First call reads
|
||||
/// the Template Object off the controller; subsequent calls for the same
|
||||
/// <c>(deviceHostAddress, templateInstanceId)</c> return the cached shape without
|
||||
/// additional network traffic. <c>null</c> on template-not-found / decode failure so
|
||||
/// callers can fall back to declaration-driven UDT fan-out.
|
||||
/// </summary>
|
||||
internal async Task<AbCipUdtShape?> FetchUdtShapeAsync(
|
||||
string deviceHostAddress, uint templateInstanceId, CancellationToken cancellationToken)
|
||||
{
|
||||
var cached = _templateCache.TryGet(deviceHostAddress, templateInstanceId);
|
||||
if (cached is not null) return cached;
|
||||
|
||||
if (!_devices.TryGetValue(deviceHostAddress, out var device)) return null;
|
||||
|
||||
var deviceParams = new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: $"@udt/{templateInstanceId}",
|
||||
Timeout: _options.Timeout);
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = _templateReaderFactory.Create();
|
||||
var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false);
|
||||
var shape = CipTemplateObjectDecoder.Decode(buffer);
|
||||
if (shape is not null)
|
||||
_templateCache.Put(deviceHostAddress, templateInstanceId, shape);
|
||||
return shape;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch
|
||||
{
|
||||
// Template read failure — log via the driver's health surface so operators see it,
|
||||
// but don't propagate since callers should fall back to declaration-driven UDT
|
||||
// semantics rather than failing the whole discovery run.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics.</summary>
|
||||
internal AbCipTemplateCache TemplateCache => _templateCache;
|
||||
|
||||
@@ -329,9 +374,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
try
|
||||
{
|
||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
|
||||
// BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
|
||||
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
|
||||
// per-parent lock prevents two concurrent bit writes to the same DINT from
|
||||
// losing one another's update.
|
||||
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
|
||||
{
|
||||
results[i] = new WriteResult(
|
||||
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
if (results[i].StatusCode == AbCipStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
runtime.EncodeValue(def.DataType, tagPath?.BitIndex, w.Value);
|
||||
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
@@ -374,6 +434,74 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
|
||||
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
|
||||
/// writers against the same parent via a per-parent <see cref="SemaphoreSlim"/>.
|
||||
/// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern shipped in pass 1 of task #181.
|
||||
/// </summary>
|
||||
private async Task<uint> WriteBitInDIntAsync(
|
||||
DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct)
|
||||
{
|
||||
var parentPath = bitPath with { BitIndex = null };
|
||||
var parentName = parentPath.ToLibplctagName();
|
||||
|
||||
var rmwLock = device.GetRmwLock(parentName);
|
||||
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
|
||||
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
var readStatus = parentRuntime.GetStatus();
|
||||
if (readStatus != 0) return AbCipStatusMapper.MapLibplctagStatus(readStatus);
|
||||
|
||||
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
|
||||
var updated = Convert.ToBoolean(value)
|
||||
? current | (1 << bit)
|
||||
: current & ~(1 << bit);
|
||||
|
||||
parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated);
|
||||
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
|
||||
var writeStatus = parentRuntime.GetStatus();
|
||||
return writeStatus == 0
|
||||
? AbCipStatusMapper.Good
|
||||
: AbCipStatusMapper.MapLibplctagStatus(writeStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
|
||||
/// so repeated bit writes against the same DINT share one handle.
|
||||
/// </summary>
|
||||
private async Task<IAbCipTagRuntime> EnsureParentRuntimeAsync(
|
||||
DeviceState device, string parentTagName, CancellationToken ct)
|
||||
{
|
||||
if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
|
||||
|
||||
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parentTagName,
|
||||
Timeout: _options.Timeout));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.ParentRuntimes[parentTagName] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently materialise the runtime handle for a tag definition. First call creates
|
||||
/// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
|
||||
@@ -476,9 +604,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
|
||||
}
|
||||
|
||||
// Controller-discovered tags — optional. Default enumerator returns an empty sequence;
|
||||
// tests + the follow-up real @tags walker plug in via the ctor parameter.
|
||||
if (_devices.TryGetValue(device.HostAddress, out var state))
|
||||
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
|
||||
// walker (LibplctagTagEnumerator) is the factory default since task #178 shipped,
|
||||
// so leaving the flag off keeps the strict-config path for deployments where only
|
||||
// declared tags should appear.
|
||||
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
||||
{
|
||||
using var enumerator = _enumeratorFactory.Create();
|
||||
var deviceParams = new AbCipTagCreateParams(
|
||||
@@ -572,12 +702,28 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Parent-DINT runtimes created on-demand by <see cref="AbCipDriver.EnsureParentRuntimeAsync"/>
|
||||
/// for BOOL-within-DINT RMW writes. Separate from <see cref="Runtimes"/> because a
|
||||
/// bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT
|
||||
/// parent ("Motor.Flags") used to do the read + write.
|
||||
/// </summary>
|
||||
public Dictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
public SemaphoreSlim GetRmwLock(string parentTagName) =>
|
||||
_rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
public void DisposeHandles()
|
||||
{
|
||||
foreach (var h in TagHandles.Values) h.Dispose();
|
||||
TagHandles.Clear();
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
Runtimes.Clear();
|
||||
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
||||
ParentRuntimes.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,15 @@ public sealed class AbCipDriverOptions
|
||||
/// not pass a more specific value. Matches the Modbus driver's 2-second default.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <c>DiscoverAsync</c> walks each device's Logix symbol table via
|
||||
/// the <c>@tags</c> pseudo-tag + surfaces controller-resident globals under a
|
||||
/// <c>Discovered/</c> sub-folder. Pre-declared tags always emit regardless. Default
|
||||
/// <c>false</c> to keep the strict-config path for deployments where only declared tags
|
||||
/// should appear in the address space.
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
128
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs
Normal file
128
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Decoder for the CIP Symbol Object (class 0x6B) response returned by Logix controllers
|
||||
/// when a client reads the <c>@tags</c> pseudo-tag. Parses the concatenated tag-info
|
||||
/// entries into a sequence of <see cref="AbCipDiscoveredTag"/>s that the driver can stream
|
||||
/// into the address-space builder.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Entry layout (little-endian) per Rockwell CIP Vol 1 + Logix 5000 CIP Programming
|
||||
/// Manual (1756-PM019 chapter "Symbol Object"), cross-checked against libplctag's
|
||||
/// <c>ab/cip.c</c> <c>handle_listed_tags_reply</c>:</para>
|
||||
/// <list type="table">
|
||||
/// <item><term>u32</term><description>Symbol Instance ID — opaque identifier for the tag.</description></item>
|
||||
/// <item><term>u16</term><description>Symbol Type — lower 12 bits = CIP type code (0xC1 BOOL,
|
||||
/// 0xC2 SINT, …, 0xD0 STRING). Bit 12 = system-tag flag. Bit 13 = reserved.
|
||||
/// Bit 15 = struct flag; when set, the lower 12 bits are the template instance id
|
||||
/// (not a primitive type code).</description></item>
|
||||
/// <item><term>u16</term><description>Element length — bytes per element (e.g. 4 for DINT).</description></item>
|
||||
/// <item><term>u32 × 3</term><description>Array dimensions — zero for scalar tags.</description></item>
|
||||
/// <item><term>u16</term><description>Symbol name length in bytes.</description></item>
|
||||
/// <item><term>u8 × N</term><description>ASCII symbol name, padded to an even byte boundary.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para><c>Program:</c>-scope tags arrive with their scope prefix baked into the name
|
||||
/// (<c>Program:MainProgram.StepIndex</c>); decoder strips the prefix + emits the scope
|
||||
/// separately so the driver's IAddressSpaceBuilder can organise them.</para>
|
||||
/// </remarks>
|
||||
public static class CipSymbolObjectDecoder
|
||||
{
|
||||
// Fixed header size in bytes — instance-id(4) + symbol-type(2) + element-length(2)
|
||||
// + array-dims(4×3) + name-length(2) = 22.
|
||||
private const int FixedHeaderSize = 22;
|
||||
|
||||
private const ushort SymbolTypeSystemFlag = 0x1000;
|
||||
private const ushort SymbolTypeStructFlag = 0x8000;
|
||||
private const ushort SymbolTypeTypeCodeMask = 0x0FFF;
|
||||
|
||||
/// <summary>
|
||||
/// Decode the raw <c>@tags</c> blob into an enumerable sequence. Malformed entries at
|
||||
/// the tail cause decoding to stop gracefully — the caller gets whatever it could parse
|
||||
/// cleanly before the corruption.
|
||||
/// </summary>
|
||||
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
return DecodeImpl(buffer);
|
||||
}
|
||||
|
||||
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer)
|
||||
{
|
||||
var pos = 0;
|
||||
while (pos + FixedHeaderSize <= buffer.Length)
|
||||
{
|
||||
var instanceId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos));
|
||||
var symbolType = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 4));
|
||||
// element_length at pos+6 (u16) — useful for array sizing but not surfaced here
|
||||
// array_dims at pos+8, pos+12, pos+16 — same (scalar-tag case has all zeros)
|
||||
var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 20));
|
||||
pos += FixedHeaderSize;
|
||||
|
||||
if (pos + nameLength > buffer.Length) break;
|
||||
var name = Encoding.ASCII.GetString(buffer, pos, nameLength);
|
||||
pos += nameLength;
|
||||
if ((pos & 1) != 0) pos++; // even-align for the next entry
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
|
||||
var isSystem = (symbolType & SymbolTypeSystemFlag) != 0;
|
||||
var isStruct = (symbolType & SymbolTypeStructFlag) != 0;
|
||||
var typeCode = symbolType & SymbolTypeTypeCodeMask;
|
||||
|
||||
var (programScope, simpleName) = SplitProgramScope(name);
|
||||
var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode);
|
||||
|
||||
yield return new AbCipDiscoveredTag(
|
||||
Name: simpleName,
|
||||
ProgramScope: programScope,
|
||||
DataType: dataType ?? AbCipDataType.Structure, // unknown type code → treat as opaque
|
||||
ReadOnly: false, // Symbol Object doesn't carry write-protection bits; lift via AccessControl Object later
|
||||
IsSystemTag: isSystem);
|
||||
|
||||
_ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Split a <c>Program:MainProgram.StepIndex</c>-style name into its scope + local
|
||||
/// parts. Names without the <c>Program:</c> prefix pass through unchanged.
|
||||
/// </summary>
|
||||
internal static (string? programScope, string simpleName) SplitProgramScope(string fullName)
|
||||
{
|
||||
const string prefix = "Program:";
|
||||
if (!fullName.StartsWith(prefix, StringComparison.Ordinal)) return (null, fullName);
|
||||
var afterPrefix = fullName[prefix.Length..];
|
||||
var dot = afterPrefix.IndexOf('.');
|
||||
if (dot <= 0) return (null, fullName); // malformed scope — surface the raw name
|
||||
return (afterPrefix[..dot], afterPrefix[(dot + 1)..]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a CIP atomic type code (lower 12 bits of the symbol-type field) to our
|
||||
/// <see cref="AbCipDataType"/> surface. Returns <c>null</c> for unrecognised codes —
|
||||
/// caller treats those as <see cref="AbCipDataType.Structure"/> so the symbol is still
|
||||
/// surfaced + downstream config can add a concrete type override.
|
||||
/// </summary>
|
||||
internal static AbCipDataType? MapTypeCode(byte typeCode) => typeCode switch
|
||||
{
|
||||
0xC1 => AbCipDataType.Bool,
|
||||
0xC2 => AbCipDataType.SInt,
|
||||
0xC3 => AbCipDataType.Int,
|
||||
0xC4 => AbCipDataType.DInt,
|
||||
0xC5 => AbCipDataType.LInt,
|
||||
0xC6 => AbCipDataType.USInt,
|
||||
0xC7 => AbCipDataType.UInt,
|
||||
0xC8 => AbCipDataType.UDInt,
|
||||
0xC9 => AbCipDataType.ULInt,
|
||||
0xCA => AbCipDataType.Real,
|
||||
0xCB => AbCipDataType.LReal,
|
||||
0xCD => AbCipDataType.Dt, // DATE
|
||||
0xCF => AbCipDataType.Dt, // DATE_AND_TIME
|
||||
0xD0 => AbCipDataType.String,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
140
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs
Normal file
140
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Decoder for the CIP Template Object (class 0x6C) blob returned by a <c>Read Template</c>
|
||||
/// service. Produces an <see cref="AbCipUdtShape"/> describing the UDT's name, total size,
|
||||
/// + ordered member list with per-member offset + type + array length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Wire format per Rockwell CIP Vol 1 §5A + Logix 5000 CIP Programming Manual
|
||||
/// 1756-PM019 §"Template Object", cross-checked against libplctag's <c>ab/cip.c</c>
|
||||
/// <c>handle_read_template_reply</c>:</para>
|
||||
///
|
||||
/// <para>Header (fixed-size, little-endian):</para>
|
||||
/// <list type="table">
|
||||
/// <item><term>u16</term><description>Member count.</description></item>
|
||||
/// <item><term>u16</term><description>Struct handle (opaque id).</description></item>
|
||||
/// <item><term>u32</term><description>Instance size — bytes per structure instance.</description></item>
|
||||
/// <item><term>u32</term><description>Member-definition total size — not used here.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Then <c>member_count</c> member blocks (8 bytes each):</para>
|
||||
/// <list type="table">
|
||||
/// <item><term>u16</term><description>Member info — type code + flags (same encoding
|
||||
/// as Symbol Object: bit 15 = struct, lower 12 = CIP type code).</description></item>
|
||||
/// <item><term>u16</term><description>Array size — 0 for scalar members.</description></item>
|
||||
/// <item><term>u32</term><description>Struct offset — byte offset from struct start.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Then strings: UDT name followed by each member name, each terminated by a
|
||||
/// semicolon <c>;</c> followed by a null <c>\0</c>. The UDT name may itself contain the
|
||||
/// sequence <c>UDTName;0\0</c> where <c>0</c> after the semicolon is an ASCII flag byte.
|
||||
/// Decoder trims to the first semicolon.</para>
|
||||
/// </remarks>
|
||||
public static class CipTemplateObjectDecoder
|
||||
{
|
||||
private const int HeaderSize = 12; // u16 + u16 + u32 + u32
|
||||
private const int MemberBlockSize = 8; // u16 + u16 + u32
|
||||
|
||||
private const ushort MemberInfoStructFlag = 0x8000;
|
||||
private const ushort MemberInfoTypeCodeMask = 0x0FFF;
|
||||
|
||||
/// <summary>
|
||||
/// Decode the raw Template Object blob. Returns <c>null</c> when the header indicates
|
||||
/// zero members or the buffer is too short to hold the fixed header.
|
||||
/// </summary>
|
||||
public static AbCipUdtShape? Decode(byte[] buffer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
if (buffer.Length < HeaderSize) return null;
|
||||
|
||||
var memberCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(0));
|
||||
// bytes 2-3: struct handle — opaque, not needed for the shape record
|
||||
var instanceSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(4));
|
||||
// bytes 8-11: member-definition total size — inferred from names list instead
|
||||
|
||||
if (memberCount == 0) return null;
|
||||
|
||||
var memberBlocksOffset = HeaderSize;
|
||||
var namesOffset = memberBlocksOffset + MemberBlockSize * memberCount;
|
||||
if (namesOffset > buffer.Length) return null;
|
||||
|
||||
var stringsSpan = buffer.AsSpan(namesOffset);
|
||||
var names = ParseSemicolonTerminatedStrings(stringsSpan);
|
||||
if (names.Count == 0) return null;
|
||||
|
||||
// Strings layout: UDT name first, then one per member (in the same order as the
|
||||
// member-info blocks). Always consume the first entry as the UDT name; missing
|
||||
// trailing member names get <member_N> placeholders below.
|
||||
var udtName = names[0];
|
||||
var memberNames = names.Skip(1).ToArray();
|
||||
|
||||
var members = new List<AbCipUdtMember>(memberCount);
|
||||
for (var i = 0; i < memberCount; i++)
|
||||
{
|
||||
var blockOffset = memberBlocksOffset + (i * MemberBlockSize);
|
||||
var info = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset));
|
||||
var arraySize = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset + 2));
|
||||
var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(blockOffset + 4));
|
||||
|
||||
var isStruct = (info & MemberInfoStructFlag) != 0;
|
||||
var typeCode = (byte)(info & MemberInfoTypeCodeMask);
|
||||
var dataType = isStruct
|
||||
? AbCipDataType.Structure
|
||||
: (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? AbCipDataType.Structure);
|
||||
|
||||
var memberName = i < memberNames.Length ? memberNames[i] : $"<member_{i}>";
|
||||
members.Add(new AbCipUdtMember(
|
||||
Name: memberName,
|
||||
Offset: offset,
|
||||
DataType: dataType,
|
||||
ArrayLength: arraySize == 0 ? 1 : arraySize));
|
||||
}
|
||||
|
||||
return new AbCipUdtShape(
|
||||
TypeName: udtName,
|
||||
TotalSize: (int)instanceSize,
|
||||
Members: members);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk a span of <c>NAME;\0NAME;\0…</c> byte sequences. Splits at each semicolon —
|
||||
/// the null byte after each semicolon is optional padding per Rockwell's string
|
||||
/// encoding convention. Stops at a trailing null / end of buffer.
|
||||
/// </summary>
|
||||
internal static List<string> ParseSemicolonTerminatedStrings(ReadOnlySpan<byte> span)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var start = 0;
|
||||
for (var i = 0; i < span.Length; i++)
|
||||
{
|
||||
var b = span[i];
|
||||
if (b == ';')
|
||||
{
|
||||
if (i > start)
|
||||
result.Add(Encoding.ASCII.GetString(span[start..i]));
|
||||
// Skip the optional null/space padding following the semicolon.
|
||||
while (i + 1 < span.Length && (span[i + 1] == '\0' || span[i + 1] == ' '))
|
||||
i++;
|
||||
start = i + 1;
|
||||
}
|
||||
else if (b == 0 && start == i)
|
||||
{
|
||||
// Trailing null at a string boundary — done.
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Trailing name without a semicolon (unlikely but observed on some firmwares).
|
||||
if (start < span.Length)
|
||||
{
|
||||
var zeroAt = span[start..].IndexOf((byte)0);
|
||||
var end = zeroAt < 0 ? span.Length : start + zeroAt;
|
||||
if (end > start)
|
||||
result.Add(Encoding.ASCII.GetString(span[start..end]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
26
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTemplateReader.cs
Normal file
26
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTemplateReader.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the raw Template Object (class 0x6C) blob for a given UDT template instance id
|
||||
/// off a Logix controller. The default production implementation (see
|
||||
/// <see cref="LibplctagTemplateReader"/>) uses libplctag's <c>@udt/{id}</c> pseudo-tag.
|
||||
/// Tests swap in a fake via <see cref="IAbCipTemplateReaderFactory"/>.
|
||||
/// </summary>
|
||||
public interface IAbCipTemplateReader : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Read the raw template bytes for <paramref name="templateInstanceId"/>. Returns the
|
||||
/// full blob the Read Template service produced — the managed <see cref="CipTemplateObjectDecoder"/>
|
||||
/// parses it into an <see cref="AbCipUdtShape"/>.
|
||||
/// </summary>
|
||||
Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
uint templateInstanceId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="IAbCipTemplateReader"/>.</summary>
|
||||
public interface IAbCipTemplateReaderFactory
|
||||
{
|
||||
IAbCipTemplateReader Create();
|
||||
}
|
||||
63
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
Normal file
63
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Real <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table by
|
||||
/// reading the <c>@tags</c> pseudo-tag via libplctag + decoding the CIP Symbol Object
|
||||
/// response with <see cref="CipSymbolObjectDecoder"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>libplctag's <c>Tag.GetBuffer()</c> returns the raw Symbol Object bytes when the
|
||||
/// tag name is <c>@tags</c>. The decoder walks the concatenated entries + emits
|
||||
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
|
||||
///
|
||||
/// <para>Task #178 closed the stub gap from PR 5 — <see cref="EmptyAbCipTagEnumerator"/>
|
||||
/// is still available for tests that don't want to touch the native library, but the
|
||||
/// production factory default now wires this implementation in.</para>
|
||||
/// </remarks>
|
||||
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
private Tag? _tag;
|
||||
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// Build a tag specifically for the @tags pseudo — same gateway + path as the device,
|
||||
// distinguished by the name alone.
|
||||
_tag = new Tag
|
||||
{
|
||||
Gateway = deviceParams.Gateway,
|
||||
Path = deviceParams.CipPath,
|
||||
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = "@tags",
|
||||
Timeout = deviceParams.Timeout,
|
||||
};
|
||||
|
||||
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var buffer = _tag.GetBuffer();
|
||||
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
|
||||
yield return tag;
|
||||
}
|
||||
|
||||
public void Dispose() => _tag?.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
"controllogix" => PlcType.ControlLogix,
|
||||
"compactlogix" => PlcType.ControlLogix,
|
||||
"micro800" => PlcType.Micro800,
|
||||
_ => PlcType.ControlLogix,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="LibplctagTagEnumerator"/>.</summary>
|
||||
internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
|
||||
}
|
||||
@@ -58,13 +58,14 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
switch (type)
|
||||
{
|
||||
case AbCipDataType.Bool:
|
||||
if (bitIndex is int bit)
|
||||
if (bitIndex is int)
|
||||
{
|
||||
// BOOL-within-DINT writes require read-modify-write on the parent DINT.
|
||||
// Deferred to a follow-up PR — matches the Modbus BitInRegister pattern at
|
||||
// ModbusDriver.cs:640.
|
||||
// BOOL-within-DINT writes are routed at the driver level (AbCipDriver.
|
||||
// WriteBitInDIntAsync) via a parallel parent-DINT runtime so the RMW stays
|
||||
// serialised. If one reaches here it means the driver dispatch was bypassed —
|
||||
// throw so the error surfaces loudly rather than clobbering the whole DINT.
|
||||
throw new NotSupportedException(
|
||||
"BOOL-within-DINT writes require read-modify-write; not implemented in PR 4.");
|
||||
"BOOL-with-bitIndex writes must go through AbCipDriver.WriteBitInDIntAsync, not LibplctagTagRuntime.");
|
||||
}
|
||||
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// libplctag-backed <see cref="IAbCipTemplateReader"/>. Opens the <c>@udt/{templateId}</c>
|
||||
/// pseudo-tag libplctag exposes for Template Object reads, issues a <c>Read Template</c>
|
||||
/// internally via a normal read call, + returns the raw byte buffer so
|
||||
/// <see cref="CipTemplateObjectDecoder"/> can decode it.
|
||||
/// </summary>
|
||||
internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
private Tag? _tag;
|
||||
|
||||
public async Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
uint templateInstanceId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_tag?.Dispose();
|
||||
_tag = new Tag
|
||||
{
|
||||
Gateway = deviceParams.Gateway,
|
||||
Path = deviceParams.CipPath,
|
||||
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = $"@udt/{templateInstanceId}",
|
||||
Timeout = deviceParams.Timeout,
|
||||
};
|
||||
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return _tag.GetBuffer();
|
||||
}
|
||||
|
||||
public void Dispose() => _tag?.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
"controllogix" => PlcType.ControlLogix,
|
||||
"compactlogix" => PlcType.ControlLogix,
|
||||
"micro800" => PlcType.Micro800,
|
||||
_ => PlcType.ControlLogix,
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class LibplctagTemplateReaderFactory : IAbCipTemplateReaderFactory
|
||||
{
|
||||
public IAbCipTemplateReader Create() => new LibplctagTemplateReader();
|
||||
}
|
||||
@@ -186,8 +186,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
|
||||
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
|
||||
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
|
||||
// concurrent bit writers. Applies to N-file bit-in-word (N7:0/3) + B-file bits
|
||||
// (B3:0/0). T/C/R sub-elements don't hit this path because they're not Bit typed.
|
||||
if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit
|
||||
&& parsed.FileLetter is not "B" and not "I" and not "O")
|
||||
{
|
||||
results[i] = new WriteResult(
|
||||
await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
|
||||
continue;
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -331,6 +344,70 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a PCCC N-file word. Strips the /N bit suffix to
|
||||
/// form the parent-word address (N7:0/3 → N7:0), creates / reuses a parent-word runtime
|
||||
/// typed as Int16, serialises concurrent bit writers against the same parent via a
|
||||
/// per-parent <see cref="SemaphoreSlim"/>.
|
||||
/// </summary>
|
||||
private async Task<uint> WriteBitInWordAsync(
|
||||
AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct)
|
||||
{
|
||||
var parentAddress = bitAddress with { BitIndex = null };
|
||||
var parentName = parentAddress.ToLibplctagName();
|
||||
|
||||
var rmwLock = device.GetRmwLock(parentName);
|
||||
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
|
||||
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
var readStatus = parentRuntime.GetStatus();
|
||||
if (readStatus != 0) return AbLegacyStatusMapper.MapLibplctagStatus(readStatus);
|
||||
|
||||
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbLegacyDataType.Int, bitIndex: null) ?? 0);
|
||||
var updated = Convert.ToBoolean(value)
|
||||
? current | (1 << bit)
|
||||
: current & ~(1 << bit);
|
||||
|
||||
parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated);
|
||||
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
|
||||
var writeStatus = parentRuntime.GetStatus();
|
||||
return writeStatus == 0
|
||||
? AbLegacyStatusMapper.Good
|
||||
: AbLegacyStatusMapper.MapLibplctagStatus(writeStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
|
||||
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
|
||||
{
|
||||
if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;
|
||||
|
||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parentName,
|
||||
Timeout: _options.Timeout));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.ParentRuntimes[parentName] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
||||
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
||||
{
|
||||
@@ -374,6 +451,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public Dictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Parent-word runtimes for bit-within-word RMW writes (task #181). Keyed by the
|
||||
/// parent address (bit suffix stripped) — e.g. writes to N7:0/3 + N7:0/5 share a
|
||||
/// single parent runtime for N7:0.
|
||||
/// </summary>
|
||||
public Dictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
public SemaphoreSlim GetRmwLock(string parentName) =>
|
||||
_rmwLocks.GetOrAdd(parentName, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
public object ProbeLock { get; } = new();
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
@@ -384,6 +474,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
{
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
Runtimes.Clear();
|
||||
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
||||
ParentRuntimes.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +51,12 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
{
|
||||
case AbLegacyDataType.Bit:
|
||||
if (bitIndex is int)
|
||||
// Bit-within-word writes are routed at the driver level
|
||||
// (AbLegacyDriver.WriteBitInWordAsync) via a parallel parent-word runtime —
|
||||
// this branch only fires if dispatch was bypassed. Throw loudly rather than
|
||||
// silently clobbering the whole word.
|
||||
throw new NotSupportedException(
|
||||
"Bit-within-word writes require read-modify-write; tracked in task #181.");
|
||||
"Bit-with-bitIndex writes must go through AbLegacyDriver.WriteBitInWordAsync.");
|
||||
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
|
||||
break;
|
||||
case AbLegacyDataType.Int:
|
||||
|
||||
@@ -15,15 +15,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
|
||||
/// fail fast.
|
||||
/// </remarks>
|
||||
public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
|
||||
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IFocasClientFactory _clientFactory;
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
||||
IFocasClientFactory? clientFactory = null)
|
||||
{
|
||||
@@ -31,6 +36,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IA
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_clientFactory = clientFactory ?? new FwlibFocasClientFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
@@ -49,6 +58,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IA
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device);
|
||||
}
|
||||
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.ProbeCts = new CancellationTokenSource();
|
||||
var ct = state.ProbeCts.Token;
|
||||
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -65,13 +84,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IA
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var state in _devices.Values) state.DisposeClient();
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.DisposeClient();
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
@@ -189,6 +214,96 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IA
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var root = builder.Folder("FOCAS", "FOCAS");
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var label = device.DeviceName ?? device.HostAddress;
|
||||
var deviceFolder = root.Folder(device.HostAddress, label);
|
||||
var tagsForDevice = _options.Tags.Where(t =>
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: tag.Writable
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* connect-failure path already disposed + cleared the client */ }
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (state.ProbeLock)
|
||||
{
|
||||
old = state.HostState;
|
||||
if (old == newState) return;
|
||||
state.HostState = newState;
|
||||
state.HostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this,
|
||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
return def.DeviceHostAddress;
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||
}
|
||||
|
||||
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
||||
{
|
||||
if (device.Client is { IsConnected: true } c) return c;
|
||||
@@ -215,6 +330,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IA
|
||||
public FocasDeviceOptions Options { get; } = options;
|
||||
public IFocasClient? Client { get; set; }
|
||||
|
||||
public object ProbeLock { get; } = new();
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
@@ -24,6 +25,13 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
private ushort _handle;
|
||||
private bool _connected;
|
||||
|
||||
// Per-PMC-byte RMW lock registry. Bit writes to the same byte get serialised so two
|
||||
// concurrent bit updates don't lose one another's modification. Key = "{addrType}:{byteAddr}".
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
private SemaphoreSlim GetRmwLock(short addrType, int byteAddr) =>
|
||||
_rmwLocks.GetOrAdd($"{addrType}:{byteAddr}", _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
@@ -55,21 +63,72 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
};
|
||||
}
|
||||
|
||||
public Task<uint> WriteAsync(
|
||||
public async Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(FocasStatusMapper.BadCommunicationError);
|
||||
if (!_connected) return FocasStatusMapper.BadCommunicationError;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => Task.FromResult(WritePmc(address, type, value)),
|
||||
FocasAreaKind.Parameter => Task.FromResult(WriteParameter(address, type, value)),
|
||||
FocasAreaKind.Macro => Task.FromResult(WriteMacro(address, value)),
|
||||
_ => Task.FromResult(FocasStatusMapper.BadNotSupported),
|
||||
FocasAreaKind.Pmc when type == FocasDataType.Bit && address.BitIndex is int =>
|
||||
await WritePmcBitAsync(address, Convert.ToBoolean(value), cancellationToken).ConfigureAwait(false),
|
||||
FocasAreaKind.Pmc => WritePmc(address, type, value),
|
||||
FocasAreaKind.Parameter => WriteParameter(address, type, value),
|
||||
FocasAreaKind.Macro => WriteMacro(address, value),
|
||||
_ => FocasStatusMapper.BadNotSupported,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a PMC byte. Acquires a per-byte semaphore so
|
||||
/// concurrent bit writes against the same byte serialise and neither loses its update.
|
||||
/// </summary>
|
||||
private async Task<uint> WritePmcBitAsync(
|
||||
FocasAddress address, bool newValue, CancellationToken cancellationToken)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
||||
var bit = address.BitIndex ?? 0;
|
||||
if (bit is < 0 or > 7)
|
||||
throw new InvalidOperationException(
|
||||
$"PMC bit index {bit} out of range (0-7) for {address.Canonical}.");
|
||||
|
||||
var rmwLock = GetRmwLock(addrType, address.Number);
|
||||
await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Read the parent byte.
|
||||
var readBuf = new FwlibNative.IODBPMC { Data = new byte[40] };
|
||||
var readRet = FwlibNative.PmcRdPmcRng(
|
||||
_handle, addrType, FocasPmcDataType.Byte,
|
||||
(ushort)address.Number, (ushort)address.Number, 8 + 1, ref readBuf);
|
||||
if (readRet != 0) return FocasStatusMapper.MapFocasReturn(readRet);
|
||||
|
||||
var current = readBuf.Data[0];
|
||||
var updated = newValue
|
||||
? (byte)(current | (1 << bit))
|
||||
: (byte)(current & ~(1 << bit));
|
||||
|
||||
// Write the updated byte.
|
||||
var writeBuf = new FwlibNative.IODBPMC
|
||||
{
|
||||
TypeA = addrType,
|
||||
TypeD = FocasPmcDataType.Byte,
|
||||
DatanoS = (ushort)address.Number,
|
||||
DatanoE = (ushort)address.Number,
|
||||
Data = new byte[40],
|
||||
};
|
||||
writeBuf.Data[0] = updated;
|
||||
|
||||
var writeRet = FwlibNative.PmcWrPmcRng(_handle, 8 + 1, ref writeBuf);
|
||||
return writeRet == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(writeRet);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(false);
|
||||
@@ -216,11 +275,11 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
switch (type)
|
||||
{
|
||||
case FocasDataType.Bit:
|
||||
// Bit-in-byte write is a read-modify-write at the PMC level — the underlying
|
||||
// pmc_wrpmcrng takes a byte payload, so caller must set the bit on a byte they
|
||||
// just read. This path is flagged for the follow-up RMW work in task #181.
|
||||
throw new NotSupportedException(
|
||||
"FOCAS Bit writes require read-modify-write; tracked in task #181.");
|
||||
// PMC Bit writes with a non-null bitIndex go through WritePmcBitAsync's RMW path
|
||||
// upstream. This branch only fires when a caller passes Bit with no bitIndex —
|
||||
// treat the value as a whole-byte boolean (non-zero / zero).
|
||||
data[0] = Convert.ToBoolean(value) ? (byte)1 : (byte)0;
|
||||
break;
|
||||
case FocasDataType.Byte:
|
||||
data[0] = (byte)(sbyte)Convert.ToSByte(value);
|
||||
break;
|
||||
|
||||
@@ -264,8 +264,27 @@ public sealed class ModbusDriver
|
||||
return results;
|
||||
}
|
||||
|
||||
// BitInRegister writes need a read-modify-write against the full holding register. A
|
||||
// per-register lock keeps concurrent bit-write callers from stomping on each other —
|
||||
// Write bit 0 and Write bit 5 targeting the same register can arrive on separate
|
||||
// subscriber threads, and without serialising the RMW the second-to-commit value wins
|
||||
// + the first bit update is lost.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<ushort, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
private SemaphoreSlim GetRmwLock(ushort address) =>
|
||||
_rmwLocks.GetOrAdd(address, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
// BitInRegister → RMW dispatch ahead of the normal encode path so the lock + read-modify-
|
||||
// write sequence doesn't hit EncodeRegister's defensive throw.
|
||||
if (tag.DataType == ModbusDataType.BitInRegister &&
|
||||
tag.Region is ModbusRegion.HoldingRegisters)
|
||||
{
|
||||
await WriteBitInRegisterAsync(transport, tag, value, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (tag.Region)
|
||||
{
|
||||
case ModbusRegion.Coils:
|
||||
@@ -309,6 +328,44 @@ public sealed class ModbusDriver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit in a holding register. FC03 → bit-swap → FC06. Serialised
|
||||
/// against other bit writes targeting the same register via <see cref="GetRmwLock"/>.
|
||||
/// </summary>
|
||||
private async Task WriteBitInRegisterAsync(
|
||||
IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
var bit = tag.BitIndex;
|
||||
if (bit > 15)
|
||||
throw new InvalidOperationException(
|
||||
$"BitInRegister bit index {bit} out of range (0-15) for tag {tag.Name}.");
|
||||
var on = Convert.ToBoolean(value);
|
||||
|
||||
var rmwLock = GetRmwLock(tag.Address);
|
||||
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// FC03 read 1 holding register at tag.Address.
|
||||
var readPdu = new byte[] { 0x03, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
||||
var readResp = await transport.SendAsync(_options.UnitId, readPdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count=2][hi][lo]
|
||||
var current = (ushort)((readResp[2] << 8) | readResp[3]);
|
||||
|
||||
var updated = on
|
||||
? (ushort)(current | (1 << bit))
|
||||
: (ushort)(current & ~(1 << bit));
|
||||
|
||||
// FC06 write single holding register.
|
||||
var writePdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
(byte)(updated >> 8), (byte)(updated & 0xFF) };
|
||||
await transport.SendAsync(_options.UnitId, writePdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
@@ -575,8 +632,11 @@ public sealed class ModbusDriver
|
||||
return b;
|
||||
}
|
||||
case ModbusDataType.BitInRegister:
|
||||
// Reached only if BitInRegister is somehow passed outside the HoldingRegisters
|
||||
// path. Normal BitInRegister writes dispatch through WriteBitInRegisterAsync via
|
||||
// the RMW shortcut in WriteOneAsync.
|
||||
throw new InvalidOperationException(
|
||||
"BitInRegister writes require a read-modify-write; not supported in PR 24 (separate follow-up).");
|
||||
"BitInRegister writes must go through WriteBitInRegisterAsync (HoldingRegisters region only).");
|
||||
default:
|
||||
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TwinCAT;
|
||||
using TwinCAT.Ads;
|
||||
using TwinCAT.Ads.TypeSystem;
|
||||
using TwinCAT.TypeSystem;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
@@ -149,6 +153,56 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
catch { /* best-effort tear-down; target may already be gone */ }
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// SymbolLoaderFactory downloads the symbol-info blob once then iterates locally — the
|
||||
// async surface on this interface is for our callers, not for the underlying call which
|
||||
// is effectively sync on top of the already-open AdsClient.
|
||||
var settings = new SymbolLoaderSettings(SymbolsLoadMode.Flat);
|
||||
var loader = SymbolLoaderFactory.Create(_client, settings);
|
||||
await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync
|
||||
|
||||
foreach (ISymbol symbol in loader.Symbols)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) yield break;
|
||||
var mapped = MapSymbolTypeName(symbol.DataType?.Name);
|
||||
var readOnly = !IsSymbolWritable(symbol);
|
||||
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
|
||||
}
|
||||
}
|
||||
|
||||
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
|
||||
{
|
||||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||||
"SINT" or "BYTE" => TwinCATDataType.SInt,
|
||||
"USINT" => TwinCATDataType.USInt,
|
||||
"INT" or "WORD" => TwinCATDataType.Int,
|
||||
"UINT" => TwinCATDataType.UInt,
|
||||
"DINT" or "DWORD" => TwinCATDataType.DInt,
|
||||
"UDINT" => TwinCATDataType.UDInt,
|
||||
"LINT" or "LWORD" => TwinCATDataType.LInt,
|
||||
"ULINT" => TwinCATDataType.ULInt,
|
||||
"REAL" => TwinCATDataType.Real,
|
||||
"LREAL" => TwinCATDataType.LReal,
|
||||
"STRING" => TwinCATDataType.String,
|
||||
"WSTRING" => TwinCATDataType.WString,
|
||||
"TIME" => TwinCATDataType.Time,
|
||||
"DATE" => TwinCATDataType.Date,
|
||||
"DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime,
|
||||
"TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay,
|
||||
_ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope
|
||||
};
|
||||
|
||||
private static bool IsSymbolWritable(ISymbol symbol)
|
||||
{
|
||||
// SymbolAccessRights is a flags enum — the Write bit indicates a writable symbol.
|
||||
// When the symbol implementation doesn't surface it, assume writable + let the PLC
|
||||
// return AccessDenied at write time.
|
||||
if (symbol is Symbol s) return (s.AccessRights & SymbolAccessRights.Write) != 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.AdsNotificationEx -= OnAdsNotificationEx;
|
||||
|
||||
@@ -66,11 +66,33 @@ public interface ITwinCATClient : IDisposable
|
||||
TimeSpan cycleTime,
|
||||
Action<string, object?> onChange,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Walk the target's symbol table via the TwinCAT <c>SymbolLoaderFactory</c> (flat mode).
|
||||
/// Yields each top-level symbol the PLC exposes — global variables, program-scope locals,
|
||||
/// function-block instance fields. Filters for our atomic type surface; structured /
|
||||
/// UDT / function-block typed symbols surface with <c>DataType = null</c> so callers can
|
||||
/// decide whether to drill in via their own walker.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary>
|
||||
public interface ITwinCATNotificationHandle : IDisposable { }
|
||||
|
||||
/// <summary>
|
||||
/// One symbol yielded by <see cref="ITwinCATClient.BrowseSymbolsAsync"/> — full instance
|
||||
/// path + detected <see cref="TwinCATDataType"/> + read-only flag.
|
||||
/// </summary>
|
||||
/// <param name="InstancePath">Full dotted symbol path (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>).</param>
|
||||
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/>; <c>null</c> when the symbol's type
|
||||
/// doesn't map onto our supported atomic surface (UDTs, pointers, function blocks).</param>
|
||||
/// <param name="ReadOnly"><c>true</c> when the symbol's AccessRights flag forbids writes.</param>
|
||||
public sealed record TwinCATDiscoveredSymbol(
|
||||
string InstancePath,
|
||||
TwinCATDataType? DataType,
|
||||
bool ReadOnly);
|
||||
|
||||
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
|
||||
public interface ITwinCATClientFactory
|
||||
{
|
||||
|
||||
@@ -217,7 +217,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var root = builder.Folder("TwinCAT", "TwinCAT");
|
||||
@@ -225,6 +225,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
{
|
||||
var label = device.DeviceName ?? device.HostAddress;
|
||||
var deviceFolder = root.Folder(device.HostAddress, label);
|
||||
|
||||
// Pre-declared tags — always emitted as the authoritative config path.
|
||||
var tagsForDevice = _options.Tags.Where(t =>
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
@@ -241,8 +243,42 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
|
||||
// Controller-side symbol browse — opt-in. Falls back to pre-declared-only on any
|
||||
// client-side error so a flaky symbol-table download doesn't block discovery.
|
||||
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
||||
{
|
||||
IAddressSpaceBuilder? discoveredFolder = null;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
await foreach (var sym in client.BrowseSymbolsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (TwinCATSystemSymbolFilter.IsSystemSymbol(sym.InstancePath)) continue;
|
||||
if (sym.DataType is not TwinCATDataType dt) continue; // unsupported type
|
||||
|
||||
discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
|
||||
discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo(
|
||||
FullName: sym.InstancePath,
|
||||
DriverDataType: dt.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: sym.ReadOnly
|
||||
? SecurityClassification.ViewOnly
|
||||
: SecurityClassification.Operate,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch
|
||||
{
|
||||
// Symbol-loader failure is non-fatal to discovery — pre-declared tags already
|
||||
// shipped + operators see the failure in driver health on next read.
|
||||
}
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- ISubscribable (native ADS notifications with poll fallback) ----
|
||||
|
||||
@@ -23,6 +23,15 @@ public sealed class TwinCATDriverOptions
|
||||
/// notification limits you can't raise.
|
||||
/// </summary>
|
||||
public bool UseNativeNotifications { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <c>DiscoverAsync</c> walks each device's symbol table via the
|
||||
/// TwinCAT <c>SymbolLoaderFactory</c> (flat mode) + surfaces controller-resident
|
||||
/// globals / program locals under a <c>Discovered/</c> sub-folder. Pre-declared tags
|
||||
/// from <see cref="Tags"/> always emit regardless. Default <c>false</c> to preserve
|
||||
/// the strict-config path for deployments where only declared tags should appear.
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Filter system / infrastructure symbols out of a TwinCAT symbol-loader walk. TC PLC
|
||||
/// runtimes export plumbing symbols alongside user-declared ones — <c>TwinCAT_SystemInfoVarList</c>,
|
||||
/// constants, IO task images, motion-layer internals — that clutter an OPC UA address space
|
||||
/// if exposed.
|
||||
/// </summary>
|
||||
public static class TwinCATSystemSymbolFilter
|
||||
{
|
||||
/// <summary><c>true</c> when the symbol path matches a known system / infrastructure prefix.</summary>
|
||||
public static bool IsSystemSymbol(string instancePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(instancePath)) return true;
|
||||
|
||||
// Runtime-exported info lists.
|
||||
if (instancePath.StartsWith("TwinCAT_SystemInfoVarList", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (instancePath.StartsWith("TwinCAT_", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (instancePath.StartsWith("Global_Version", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
// Constants pool — read-only, no operator value.
|
||||
if (instancePath.StartsWith("Constants.", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
// Anonymous / compiler-generated.
|
||||
if (instancePath.StartsWith("__", StringComparison.Ordinal)) return true;
|
||||
|
||||
// Motion / NC internals routinely surfaced by the symbol loader.
|
||||
if (instancePath.StartsWith("Mc_", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ClusterNodeServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsStale_NullLastSeen_Returns_True()
|
||||
{
|
||||
var node = NewNode("A", RedundancyRole.Primary, lastSeenAt: null);
|
||||
ClusterNodeService.IsStale(node).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsStale_RecentLastSeen_Returns_False()
|
||||
{
|
||||
var node = NewNode("A", RedundancyRole.Primary, lastSeenAt: DateTime.UtcNow.AddSeconds(-5));
|
||||
ClusterNodeService.IsStale(node).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsStale_Old_LastSeen_Returns_True()
|
||||
{
|
||||
var node = NewNode("A", RedundancyRole.Primary,
|
||||
lastSeenAt: DateTime.UtcNow - ClusterNodeService.StaleThreshold - TimeSpan.FromSeconds(1));
|
||||
ClusterNodeService.IsStale(node).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByClusterAsync_OrdersByServiceLevelBase_Descending_Then_NodeId()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.ClusterNodes.AddRange(
|
||||
NewNode("B-low", RedundancyRole.Secondary, serviceLevelBase: 150, clusterId: "c1"),
|
||||
NewNode("A-high", RedundancyRole.Primary, serviceLevelBase: 200, clusterId: "c1"),
|
||||
NewNode("other-cluster", RedundancyRole.Primary, serviceLevelBase: 200, clusterId: "c2"));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new ClusterNodeService(ctx);
|
||||
var rows = await svc.ListByClusterAsync("c1", CancellationToken.None);
|
||||
|
||||
rows.Count.ShouldBe(2);
|
||||
rows[0].NodeId.ShouldBe("A-high"); // higher ServiceLevelBase first
|
||||
rows[1].NodeId.ShouldBe("B-low");
|
||||
}
|
||||
|
||||
private static ClusterNode NewNode(
|
||||
string nodeId,
|
||||
RedundancyRole role,
|
||||
DateTime? lastSeenAt = null,
|
||||
int serviceLevelBase = 200,
|
||||
string clusterId = "c1") => new()
|
||||
{
|
||||
NodeId = nodeId,
|
||||
ClusterId = clusterId,
|
||||
RedundancyRole = role,
|
||||
Host = $"{nodeId}.example",
|
||||
ApplicationUri = $"urn:{nodeId}",
|
||||
ServiceLevelBase = (byte)serviceLevelBase,
|
||||
LastSeenAt = lastSeenAt,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
private static OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
@@ -219,4 +219,67 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
|
||||
attempts.ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tracker_RecordsFailure_OnEveryRetry()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
|
||||
var pipeline = builder.GetOrCreate("drv-trk", "host-x", DriverCapability.Read, TierAOptions);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("always fails");
|
||||
}));
|
||||
|
||||
var snap = tracker.TryGet("drv-trk", "host-x");
|
||||
snap.ShouldNotBeNull();
|
||||
var retryCount = TierAOptions.Resolve(DriverCapability.Read).RetryCount;
|
||||
snap!.ConsecutiveFailures.ShouldBe(retryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tracker_StampsBreakerOpen_WhenBreakerTrips()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
|
||||
var pipeline = builder.GetOrCreate("drv-trk", "host-b", DriverCapability.Write, TierAOptions);
|
||||
|
||||
var threshold = TierAOptions.Resolve(DriverCapability.Write).BreakerFailureThreshold;
|
||||
for (var i = 0; i < threshold; i++)
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("boom");
|
||||
}));
|
||||
}
|
||||
|
||||
var snap = tracker.TryGet("drv-trk", "host-b");
|
||||
snap.ShouldNotBeNull();
|
||||
snap!.LastBreakerOpenUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tracker_IsolatesCounters_PerHost()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
|
||||
var dead = builder.GetOrCreate("drv-trk", "dead", DriverCapability.Read, TierAOptions);
|
||||
var live = builder.GetOrCreate("drv-trk", "live", DriverCapability.Read, TierAOptions);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await dead.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("dead");
|
||||
}));
|
||||
await live.ExecuteAsync(async _ => await Task.Yield());
|
||||
|
||||
tracker.TryGet("drv-trk", "dead")!.ConsecutiveFailures.ShouldBeGreaterThan(0);
|
||||
tracker.TryGet("drv-trk", "live").ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipBoolInDIntRmwTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake tag runtime that stores a DINT value + exposes Read/Write/EncodeValue/DecodeValue
|
||||
/// for DInt. RMW tests use one instance as the "parent" runtime (tag name "Motor.Flags")
|
||||
/// which the driver's WriteBitInDIntAsync reads + writes.
|
||||
/// </summary>
|
||||
private sealed class ParentDintFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
// Uses the base FakeAbCipTag's Value + ReadCount + WriteCount.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_set_reads_parent_ORs_bit_writes_back()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0b0001 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
// Parent runtime created under name "Motor.Flags" — distinct from the bit-selector tag.
|
||||
factory.Tags.ShouldContainKey("Motor.Flags");
|
||||
factory.Tags["Motor.Flags"].Value.ShouldBe(0b1001); // bit 3 set, bit 0 preserved
|
||||
factory.Tags["Motor.Flags"].ReadCount.ShouldBe(1);
|
||||
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_preserves_other_bits()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = unchecked((int)0xFFFFFFFF) },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
|
||||
|
||||
var updated = Convert.ToInt32(factory.Tags["Motor.Flags"].Value);
|
||||
(updated & (1 << 3)).ShouldBe(0); // bit 3 cleared
|
||||
(updated & ~(1 << 3)).ShouldBe(unchecked((int)0xFFFFFFF7)); // every other bit preserved
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_bit_writes_to_same_parent_compose_correctly()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0 },
|
||||
};
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new AbCipTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"Flags.{b}", AbCipDataType.Bool))
|
||||
.ToArray();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
|
||||
|
||||
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0xFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_writes_to_different_parents_each_get_own_runtime()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "Motor1.Flags.0", AbCipDataType.Bool),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("A", true)], CancellationToken.None);
|
||||
await drv.WriteAsync([new WriteRequest("B", true)], CancellationToken.None);
|
||||
|
||||
factory.Tags.ShouldContainKey("Motor1.Flags");
|
||||
factory.Tags.ShouldContainKey("Motor2.Flags");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_bit_writes_reuse_one_parent_runtime()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new ParentDintFake(p) { Value = 0 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Bit0", "ab://10.0.0.5/1,0", "Flags.0", AbCipDataType.Bool),
|
||||
new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
|
||||
await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
|
||||
|
||||
// Three factory invocations: two bit-selector tags (never used for writes, but the
|
||||
// driver may create them opportunistically) + one shared parent. Assert the parent was
|
||||
// init'd exactly once + used for both writes.
|
||||
factory.Tags["Flags"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["Flags"].WriteCount.ShouldBe(2);
|
||||
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0x21); // bits 0 + 5
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
@@ -119,6 +120,7 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
@@ -137,6 +139,7 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
@@ -153,6 +156,7 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)],
|
||||
Timeout = TimeSpan.FromSeconds(7),
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
|
||||
@@ -60,9 +60,12 @@ public sealed class AbCipDriverWriteTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_in_dint_write_returns_BadNotSupported()
|
||||
public async Task Bit_in_dint_write_now_succeeds_via_RMW()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new ThrowingBoolBitFake(p) };
|
||||
// Task #181 pass 2 lifted this gap — BOOL-within-DINT writes now go through
|
||||
// WriteBitInDIntAsync + a parallel parent-DINT runtime, so the result is Good rather
|
||||
// than BadNotSupported. Full RMW semantics covered by AbCipBoolInDIntRmwTests.
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
@@ -73,7 +76,7 @@ public sealed class AbCipDriverWriteTests
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipFetchUdtShapeTests
|
||||
{
|
||||
private sealed class FakeTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
public byte[] Response { get; set; } = [];
|
||||
public int ReadCount { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
public uint LastTemplateId { get; private set; }
|
||||
|
||||
public Task<byte[]> ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct)
|
||||
{
|
||||
ReadCount++;
|
||||
LastTemplateId = templateInstanceId;
|
||||
return Task.FromResult(Response);
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory
|
||||
{
|
||||
public List<IAbCipTemplateReader> Readers { get; } = new();
|
||||
public Func<IAbCipTemplateReader>? Customise { get; set; }
|
||||
|
||||
public IAbCipTemplateReader Create()
|
||||
{
|
||||
var r = Customise?.Invoke() ?? new FakeTemplateReader();
|
||||
Readers.Add(r);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildSimpleTemplate(string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
|
||||
{
|
||||
var headerSize = 12;
|
||||
var blockSize = 8;
|
||||
var strings = new MemoryStream();
|
||||
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
|
||||
Add(name);
|
||||
foreach (var m in members) Add(m.n);
|
||||
var stringsArr = strings.ToArray();
|
||||
|
||||
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
var o = headerSize + i * blockSize;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
|
||||
}
|
||||
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static Task<AbCipUdtShape?> InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId)
|
||||
{
|
||||
var mi = typeof(AbCipDriver).GetMethod("FetchUdtShapeAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
return (Task<AbCipUdtShape?>)mi.Invoke(drv, [deviceHostAddress, templateId, CancellationToken.None])!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_decodes_blob_and_caches_result()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new FakeTemplateReader
|
||||
{
|
||||
Response = BuildSimpleTemplate("MotorUdt", 8,
|
||||
("Speed", 0xC4, 0, 0),
|
||||
("Enabled", 0xC1, 0, 4)),
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.TypeName.ShouldBe("MotorUdt");
|
||||
shape.Members.Count.ShouldBe(2);
|
||||
|
||||
// Second fetch must hit the cache — no second reader created.
|
||||
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
|
||||
factory.Readers.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_different_templateIds_each_fetch()
|
||||
{
|
||||
var callCount = 0;
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
callCount++;
|
||||
var name = callCount == 1 ? "UdtA" : "UdtB";
|
||||
return new FakeTemplateReader
|
||||
{
|
||||
Response = BuildSimpleTemplate(name, 4, ("X", 0xC4, 0, 0)),
|
||||
};
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var a = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
var b = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 2);
|
||||
|
||||
a!.TypeName.ShouldBe("UdtA");
|
||||
b!.TypeName.ShouldBe("UdtB");
|
||||
factory.Readers.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_unknown_device_returns_null()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.99/1,0", 1);
|
||||
shape.ShouldBeNull();
|
||||
factory.Readers.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_decode_failure_returns_null_and_does_not_cache()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new FakeTemplateReader { Response = [0x00, 0x00] }, // too short
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
shape.ShouldBeNull();
|
||||
|
||||
// Next call retries (not cached as a failure).
|
||||
var shape2 = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
shape2.ShouldBeNull();
|
||||
factory.Readers.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchUdtShapeAsync_reader_exception_returns_null()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new ThrowingTemplateReader(),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
|
||||
shape.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlushOptionalCachesAsync_empties_template_cache()
|
||||
{
|
||||
var factory = new FakeTemplateReaderFactory
|
||||
{
|
||||
Customise = () => new FakeTemplateReader
|
||||
{
|
||||
Response = BuildSimpleTemplate("U", 4, ("X", 0xC4, 0, 0)),
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1", templateReaderFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
|
||||
drv.TemplateCache.Count.ShouldBe(1);
|
||||
|
||||
await drv.FlushOptionalCachesAsync(CancellationToken.None);
|
||||
drv.TemplateCache.Count.ShouldBe(0);
|
||||
|
||||
// Next fetch hits the network again.
|
||||
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
|
||||
factory.Readers.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
private sealed class ThrowingTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
public Task<byte[]> ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) =>
|
||||
throw new InvalidOperationException("fake read failure");
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CipSymbolObjectDecoderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Build one Symbol Object entry in the byte layout
|
||||
/// <c>instance_id(u32) symbol_type(u16) element_length(u16) array_dims(u32×3) name_len(u16) name[len] pad</c>.
|
||||
/// </summary>
|
||||
private static byte[] BuildEntry(
|
||||
uint instanceId,
|
||||
ushort symbolType,
|
||||
ushort elementLength,
|
||||
(uint, uint, uint) arrayDims,
|
||||
string name)
|
||||
{
|
||||
var nameBytes = Encoding.ASCII.GetBytes(name);
|
||||
var nameLen = nameBytes.Length;
|
||||
var totalLen = 22 + nameLen;
|
||||
if ((totalLen & 1) != 0) totalLen++; // pad to even
|
||||
|
||||
var buf = new byte[totalLen];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0), instanceId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), symbolType);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(6), elementLength);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), arrayDims.Item1);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(12), arrayDims.Item2);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), arrayDims.Item3);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), (ushort)nameLen);
|
||||
Buffer.BlockCopy(nameBytes, 0, buf, 22, nameLen);
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] chunks)
|
||||
{
|
||||
var total = chunks.Sum(c => c.Length);
|
||||
var result = new byte[total];
|
||||
var pos = 0;
|
||||
foreach (var c in chunks)
|
||||
{
|
||||
Buffer.BlockCopy(c, 0, result, pos, c.Length);
|
||||
pos += c.Length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_DInt_entry_decodes_to_scalar_DInt_tag()
|
||||
{
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 42,
|
||||
symbolType: 0xC4,
|
||||
elementLength: 4,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "Counter");
|
||||
|
||||
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
|
||||
|
||||
tags.Count.ShouldBe(1);
|
||||
tags[0].Name.ShouldBe("Counter");
|
||||
tags[0].ProgramScope.ShouldBeNull();
|
||||
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tags[0].IsSystemTag.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0xC1, AbCipDataType.Bool)]
|
||||
[InlineData((byte)0xC2, AbCipDataType.SInt)]
|
||||
[InlineData((byte)0xC3, AbCipDataType.Int)]
|
||||
[InlineData((byte)0xC4, AbCipDataType.DInt)]
|
||||
[InlineData((byte)0xC5, AbCipDataType.LInt)]
|
||||
[InlineData((byte)0xC6, AbCipDataType.USInt)]
|
||||
[InlineData((byte)0xC7, AbCipDataType.UInt)]
|
||||
[InlineData((byte)0xC8, AbCipDataType.UDInt)]
|
||||
[InlineData((byte)0xC9, AbCipDataType.ULInt)]
|
||||
[InlineData((byte)0xCA, AbCipDataType.Real)]
|
||||
[InlineData((byte)0xCB, AbCipDataType.LReal)]
|
||||
[InlineData((byte)0xD0, AbCipDataType.String)]
|
||||
public void Every_known_atomic_type_code_maps_to_correct_AbCipDataType(byte typeCode, AbCipDataType expected)
|
||||
{
|
||||
CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_type_code_returns_null_so_caller_treats_as_opaque()
|
||||
{
|
||||
CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Struct_flag_overrides_type_code_and_yields_Structure()
|
||||
{
|
||||
// 0x8000 (struct) + 0x1234 (template instance id in lower 12 bits; uses 0x234)
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 5,
|
||||
symbolType: 0x8000 | 0x0234,
|
||||
elementLength: 16,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "Motor1");
|
||||
|
||||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void System_flag_surfaces_as_IsSystemTag_true()
|
||||
{
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 99,
|
||||
symbolType: 0x1000 | 0xC4, // system flag + DINT
|
||||
elementLength: 4,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "__Reserved_1");
|
||||
|
||||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||
tag.IsSystemTag.ShouldBeTrue();
|
||||
tag.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_name_splits_prefix_into_ProgramScope()
|
||||
{
|
||||
var bytes = BuildEntry(
|
||||
instanceId: 1,
|
||||
symbolType: 0xC4,
|
||||
elementLength: 4,
|
||||
arrayDims: (0, 0, 0),
|
||||
name: "Program:MainProgram.StepIndex");
|
||||
|
||||
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
|
||||
tag.ProgramScope.ShouldBe("MainProgram");
|
||||
tag.Name.ShouldBe("StepIndex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_entries_decode_in_wire_order_with_even_padding()
|
||||
{
|
||||
// Name "Abc" is 3 bytes — triggers the even-pad branch between entries.
|
||||
var bytes = Concat(
|
||||
BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abc"), // DINT named "Abc" (3-byte name, pads to 4)
|
||||
BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi")); // REAL named "Pi"
|
||||
|
||||
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
|
||||
tags.Count.ShouldBe(2);
|
||||
tags[0].Name.ShouldBe("Abc");
|
||||
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tags[1].Name.ShouldBe("Pi");
|
||||
tags[1].DataType.ShouldBe(AbCipDataType.Real);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncated_buffer_stops_decoding_gracefully()
|
||||
{
|
||||
var full = BuildEntry(7, 0xC4, 4, (0, 0, 0), "Counter");
|
||||
// Deliberately chop off the last 5 bytes — decoder should bail cleanly, not throw.
|
||||
var truncated = full.Take(full.Length - 5).ToArray();
|
||||
|
||||
CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_buffer_yields_no_tags()
|
||||
{
|
||||
CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Counter", null, "Counter")]
|
||||
[InlineData("Program:MainProgram.Step", "MainProgram", "Step")]
|
||||
[InlineData("Program:MyProg.a.b.c", "MyProg", "a.b.c")]
|
||||
[InlineData("Program:", null, "Program:")] // malformed — no dot
|
||||
[InlineData("Program:OnlyProg", null, "Program:OnlyProg")]
|
||||
[InlineData("Motor.Status.Running", null, "Motor.Status.Running")]
|
||||
public void SplitProgramScope_handles_every_shape(string input, string? expectedScope, string expectedName)
|
||||
{
|
||||
var (scope, name) = CipSymbolObjectDecoder.SplitProgramScope(input);
|
||||
scope.ShouldBe(expectedScope);
|
||||
name.ShouldBe(expectedName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CipTemplateObjectDecoderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Construct a Template Object blob — header + member blocks + semicolon-delimited
|
||||
/// strings (UDT name first, then member names).
|
||||
/// </summary>
|
||||
private static byte[] BuildTemplate(
|
||||
string udtName,
|
||||
uint instanceSize,
|
||||
params (string name, ushort info, ushort arraySize, uint offset)[] members)
|
||||
{
|
||||
var memberCount = (ushort)members.Length;
|
||||
var headerSize = 12;
|
||||
var memberBlockSize = 8;
|
||||
var blocksSize = memberBlockSize * members.Length;
|
||||
|
||||
var stringsBuf = new MemoryStream();
|
||||
void AppendString(string s)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(s + ";\0");
|
||||
stringsBuf.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
AppendString(udtName);
|
||||
foreach (var m in members) AppendString(m.name);
|
||||
var strings = stringsBuf.ToArray();
|
||||
|
||||
var buf = new byte[headerSize + blocksSize + strings.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), 0);
|
||||
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
var o = headerSize + (i * memberBlockSize);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arraySize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].offset);
|
||||
}
|
||||
Buffer.BlockCopy(strings, 0, buf, headerSize + blocksSize, strings.Length);
|
||||
return buf;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simple_two_member_UDT_decodes_correctly()
|
||||
{
|
||||
var bytes = BuildTemplate("MotorUdt", instanceSize: 8,
|
||||
("Speed", info: 0xC4, arraySize: 0, offset: 0), // DINT at offset 0
|
||||
("Enabled", info: 0xC1, arraySize: 0, offset: 4)); // BOOL at offset 4
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.TypeName.ShouldBe("MotorUdt");
|
||||
shape.TotalSize.ShouldBe(8);
|
||||
shape.Members.Count.ShouldBe(2);
|
||||
shape.Members[0].Name.ShouldBe("Speed");
|
||||
shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||
shape.Members[0].Offset.ShouldBe(0);
|
||||
shape.Members[0].ArrayLength.ShouldBe(1);
|
||||
shape.Members[1].Name.ShouldBe("Enabled");
|
||||
shape.Members[1].DataType.ShouldBe(AbCipDataType.Bool);
|
||||
shape.Members[1].Offset.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Struct_member_flag_surfaces_Structure_type()
|
||||
{
|
||||
var bytes = BuildTemplate("ContainerUdt", instanceSize: 32,
|
||||
("InnerStruct", info: 0x8042, arraySize: 0, offset: 0)); // struct flag + template-id 0x42
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_member_carries_non_one_ArrayLength()
|
||||
{
|
||||
var bytes = BuildTemplate("ArrayUdt", instanceSize: 40,
|
||||
("Values", info: 0xC4, arraySize: 10, offset: 0));
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Single().ArrayLength.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_atomic_types_preserve_offsets_and_types()
|
||||
{
|
||||
var bytes = BuildTemplate("MixedUdt", instanceSize: 24,
|
||||
("A", 0xC1, 0, 0), // BOOL
|
||||
("B", 0xC2, 0, 1), // SINT
|
||||
("C", 0xC3, 0, 2), // INT
|
||||
("D", 0xC4, 0, 4), // DINT
|
||||
("E", 0xCA, 0, 8), // REAL
|
||||
("F", 0xCB, 0, 16)); // LREAL
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Count.ShouldBe(6);
|
||||
shape.Members.Select(m => m.DataType).ShouldBe(
|
||||
[AbCipDataType.Bool, AbCipDataType.SInt, AbCipDataType.Int,
|
||||
AbCipDataType.DInt, AbCipDataType.Real, AbCipDataType.LReal]);
|
||||
shape.Members.Select(m => m.Offset).ShouldBe([0, 1, 2, 4, 8, 16]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_atomic_type_code_falls_back_to_Structure()
|
||||
{
|
||||
var bytes = BuildTemplate("WeirdUdt", instanceSize: 4,
|
||||
("Unknown", info: 0xFF, 0, 0));
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(bytes);
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_member_count_returns_null()
|
||||
{
|
||||
var buf = new byte[12];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), 0);
|
||||
CipTemplateObjectDecoder.Decode(buf).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Short_buffer_returns_null()
|
||||
{
|
||||
CipTemplateObjectDecoder.Decode([0x01, 0x00]).ShouldBeNull(); // only 2 bytes — less than header
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_member_name_surfaces_placeholder()
|
||||
{
|
||||
// Header says 3 members but strings list has only UDT name + 2 member names.
|
||||
var memberCount = (ushort)3;
|
||||
var buf = new byte[12 + 8 * 3];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), 12);
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var o = 12 + i * 8;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), 0xC4);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), (uint)(i * 4));
|
||||
}
|
||||
// strings: only UDT + 2 members, missing the third.
|
||||
var strings = Encoding.ASCII.GetBytes("MyUdt;\0A;\0B;\0");
|
||||
var combined = buf.Concat(strings).ToArray();
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(combined);
|
||||
shape.ShouldNotBeNull();
|
||||
shape.Members.Count.ShouldBe(3);
|
||||
shape.Members[2].Name.ShouldBe("<member_2>");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Foo;\0Bar;\0", new[] { "Foo", "Bar" })]
|
||||
[InlineData("Foo;Bar;", new[] { "Foo", "Bar" })] // no nulls
|
||||
[InlineData("Only;\0", new[] { "Only" })]
|
||||
[InlineData(";\0", new string[] { })] // empty
|
||||
[InlineData("", new string[] { })]
|
||||
public void ParseSemicolonTerminatedStrings_handles_shapes(string input, string[] expected)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(input);
|
||||
var result = CipTemplateObjectDecoder.ParseSemicolonTerminatedStrings(bytes);
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyBitRmwTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Bit_set_reads_parent_word_ORs_bit_writes_back()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0b0001 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("Flag3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags.ShouldContainKey("N7:0"); // parent word runtime created
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0b1001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_preserves_other_bits_in_N_file_word()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = unchecked((short)0xFFFF) },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
|
||||
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(unchecked((short)0xFFF7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_bit_writes_to_same_word_compose_correctly()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
|
||||
};
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new AbLegacyTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"N7:0/{b}", AbLegacyDataType.Bit))
|
||||
.ToArray();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
|
||||
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0xFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_bit_writes_reuse_parent_runtime()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Bit0", "ab://10.0.0.5/1,0", "N7:0/0", AbLegacyDataType.Bit),
|
||||
new AbLegacyTagDefinition("Bit5", "ab://10.0.0.5/1,0", "N7:0/5", AbLegacyDataType.Bit),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
|
||||
await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
|
||||
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["N7:0"].WriteCount.ShouldBe(2);
|
||||
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0x21); // bits 0 + 5
|
||||
}
|
||||
}
|
||||
@@ -157,9 +157,12 @@ public sealed class AbLegacyReadWriteTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_within_word_write_rejected_as_BadNotSupported()
|
||||
public async Task Bit_within_word_write_now_succeeds_via_RMW()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new RmwThrowingFake(p) };
|
||||
// Task #181 pass 2 lifted this gap — N-file bit writes now go through
|
||||
// WriteBitInWordAsync + a parallel parent-word runtime, so the status is Good rather
|
||||
// than BadNotSupported. Full RMW semantics covered by AbLegacyBitRmwTests.
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
@@ -170,7 +173,7 @@ public sealed class AbLegacyReadWriteTests
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Bit3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasCapabilityTests
|
||||
{
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_pre_declared_tags()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
|
||||
builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_initial_poll_raises_OnDataChange()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)42 } },
|
||||
};
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe((sbyte)42);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_cancels_active_subscriptions()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } },
|
||||
};
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
var afterShutdown = events.Count;
|
||||
await Task.Delay(200);
|
||||
events.Count.ShouldBe(afterShutdown);
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_returns_entry_per_device()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:8193"),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetHostStatuses().Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Running_on_success()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { ProbeResult = true },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Stopped_on_failure()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { ProbeResult = false },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:8193"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("A").ShouldBe("focas://10.0.0.5:8193");
|
||||
drv.ResolveHost("B").ShouldBe("focas://10.0.0.6:8193");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_first_device_for_unknown()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("missing").ShouldBe("focas://10.0.0.5:8193");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasPmcBitRmwTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake client simulating PMC byte storage + exposing it as a sbyte so RMW callers can
|
||||
/// observe the read-modify-write round-trip. ReadAsync for a Bit with bitIndex surfaces
|
||||
/// the current bit; WriteAsync stores the full byte the driver issues.
|
||||
/// </summary>
|
||||
private sealed class PmcRmwFake : FakeFocasClient
|
||||
{
|
||||
public byte[] PmcBytes { get; } = new byte[1024];
|
||||
|
||||
public override Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
|
||||
return Task.FromResult(((object?)(sbyte)PmcBytes[address.Number], FocasStatusMapper.Good));
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
return Task.FromResult(((object?)((PmcBytes[address.Number] & (1 << bit)) != 0), FocasStatusMapper.Good));
|
||||
return base.ReadAsync(address, type, ct);
|
||||
}
|
||||
|
||||
public override Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
{
|
||||
// Driver writes the full byte after RMW (type==Byte with full byte value), OR a raw
|
||||
// bit write (type==Bit, bitIndex non-null) — depending on how the driver routes it.
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
|
||||
{
|
||||
PmcBytes[address.Number] = (byte)Convert.ToSByte(value);
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
{
|
||||
var current = PmcBytes[address.Number];
|
||||
PmcBytes[address.Number] = Convert.ToBoolean(value)
|
||||
? (byte)(current | (1 << bit))
|
||||
: (byte)(current & ~(1 << bit));
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
return base.WriteAsync(address, type, value, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static (FocasDriver drv, PmcRmwFake fake) NewDriver(params FocasTagDefinition[] tags)
|
||||
{
|
||||
var fake = new PmcRmwFake();
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_set_surfaces_as_Good_status_and_flips_bit()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0b0000_0001;
|
||||
|
||||
var results = await drv.WriteAsync([new WriteRequest("Run", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
fake.PmcBytes[100].ShouldBe((byte)0b0000_1001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_preserves_other_bits()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new FocasTagDefinition("Flag", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0xFF;
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
|
||||
|
||||
fake.PmcBytes[100].ShouldBe((byte)0b1111_0111);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subsequent_bit_sets_in_same_byte_compose_correctly()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new FocasTagDefinition($"Bit{b}", "focas://10.0.0.5:8193", $"R100.{b}", FocasDataType.Bit))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0;
|
||||
|
||||
for (var b = 0; b < 8; b++)
|
||||
await drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None);
|
||||
|
||||
fake.PmcBytes[100].ShouldBe((byte)0xFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_write_to_different_bytes_does_not_contend()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 4)
|
||||
.Select(i => new FocasTagDefinition($"Bit{i}", "focas://10.0.0.5:8193", $"R{50 + i}.0", FocasDataType.Bit))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 4).Select(i =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{i}", true)], CancellationToken.None)));
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
fake.PmcBytes[50 + i].ShouldBe((byte)0x01);
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,7 @@ public sealed class FocasScaffoldingTests
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
|
||||
@@ -80,11 +80,17 @@ public sealed class FwlibNativeHelperTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Bit_throws_NotSupported_for_RMW_gap()
|
||||
public void EncodePmcValue_Bit_without_bit_index_writes_byte_boolean()
|
||||
{
|
||||
// Task #181 closed the Bit-write gap — PMC Bit with a bitIndex now routes through
|
||||
// WritePmcBitAsync's RMW path upstream, and raw EncodePmcValue only gets the
|
||||
// no-bit-index case (treated as a whole-byte boolean).
|
||||
var buf = new byte[40];
|
||||
Should.Throw<NotSupportedException>(() =>
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, true, bitIndex: 3));
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, true, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)1);
|
||||
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, false, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
141
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusBitRmwTests.cs
Normal file
141
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusBitRmwTests.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusBitRmwTests
|
||||
{
|
||||
/// <summary>Fake transport capturing each PDU so tests can assert on the read + write sequence.</summary>
|
||||
private sealed class RmwTransport : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] HoldingRegisters = new ushort[256];
|
||||
public readonly List<byte[]> Pdus = new();
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
Pdus.Add(pdu);
|
||||
if (pdu[0] == 0x03)
|
||||
{
|
||||
// FC03 Read Holding Registers.
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var resp = new byte[2 + qty * 2];
|
||||
resp[0] = 0x03;
|
||||
resp[1] = (byte)(qty * 2);
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
|
||||
}
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
if (pdu[0] == 0x06)
|
||||
{
|
||||
// FC06 Write Single Register.
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var v = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
HoldingRegisters[addr] = v;
|
||||
return Task.FromResult(new byte[] { 0x06, pdu[1], pdu[2], pdu[3], pdu[4] });
|
||||
}
|
||||
return Task.FromException<byte[]>(new NotSupportedException($"FC 0x{pdu[0]:X2} not supported by fake"));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static (ModbusDriver drv, RmwTransport fake) NewDriver(params ModbusTagDefinition[] tags)
|
||||
{
|
||||
var fake = new RmwTransport();
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake",
|
||||
Tags = tags,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_set_reads_current_register_ORs_bit_writes_back()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new ModbusTagDefinition("Flag3", ModbusRegion.HoldingRegisters, 10, ModbusDataType.BitInRegister, BitIndex: 3));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[10] = 0b0000_0001; // bit 0 already set
|
||||
|
||||
var results = await drv.WriteAsync([new WriteRequest("Flag3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(0u);
|
||||
fake.HoldingRegisters[10].ShouldBe((ushort)0b0000_1001); // bit 3 now set, bit 0 preserved
|
||||
// Two PDUs: FC03 read then FC06 write.
|
||||
fake.Pdus.Count.ShouldBe(2);
|
||||
fake.Pdus[0][0].ShouldBe((byte)0x03);
|
||||
fake.Pdus[1][0].ShouldBe((byte)0x06);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_reads_current_register_ANDs_bit_off_writes_back()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new ModbusTagDefinition("Flag3", ModbusRegion.HoldingRegisters, 10, ModbusDataType.BitInRegister, BitIndex: 3));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[10] = 0xFFFF; // all bits set
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Flag3", false)], CancellationToken.None);
|
||||
|
||||
fake.HoldingRegisters[10].ShouldBe((ushort)0b1111_1111_1111_0111); // bit 3 cleared, rest preserved
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_bit_writes_to_same_register_preserve_all_updates()
|
||||
{
|
||||
// Serialization test — 8 writers target different bits in register 20. Without the RMW
|
||||
// lock, concurrent reads interleave + last-to-commit wins so some bits get lost.
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new ModbusTagDefinition($"Bit{b}", ModbusRegion.HoldingRegisters, 20, ModbusDataType.BitInRegister, BitIndex: (byte)b))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[20] = 0;
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
|
||||
|
||||
fake.HoldingRegisters[20].ShouldBe((ushort)0xFF); // all 8 bits set
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_write_on_different_registers_proceeds_in_parallel_without_contention()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 4)
|
||||
.Select(i => new ModbusTagDefinition($"Bit{i}", ModbusRegion.HoldingRegisters, (ushort)(50 + i), ModbusDataType.BitInRegister, BitIndex: 0))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 4).Select(i =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{i}", true)], CancellationToken.None)));
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
fake.HoldingRegisters[50 + i].ShouldBe((ushort)0x01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_write_preserves_other_bits_in_the_same_register()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new ModbusTagDefinition("BitA", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister, BitIndex: 5),
|
||||
new ModbusTagDefinition("BitB", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister, BitIndex: 10));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("BitA", true)], CancellationToken.None);
|
||||
await drv.WriteAsync([new WriteRequest("BitB", true)], CancellationToken.None);
|
||||
|
||||
fake.HoldingRegisters[30].ShouldBe((ushort)((1 << 5) | (1 << 10)));
|
||||
}
|
||||
}
|
||||
@@ -132,12 +132,15 @@ public sealed class ModbusDataTypeTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BitInRegister_write_is_not_supported_in_PR24()
|
||||
public void BitInRegister_EncodeRegister_still_rejects_direct_calls()
|
||||
{
|
||||
// BitInRegister writes now go through WriteBitInRegisterAsync's RMW path (task #181).
|
||||
// EncodeRegister should never be reached for this type — if it is, throwing keeps an
|
||||
// unintended caller loud rather than silently clobbering the register.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
|
||||
BitIndex: 5);
|
||||
Should.Throw<InvalidOperationException>(() => ModbusDriver.EncodeRegister(true, tag))
|
||||
.Message.ShouldContain("read-modify-write");
|
||||
.Message.ShouldContain("WriteBitInRegisterAsync");
|
||||
}
|
||||
|
||||
// --- String ---
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
@@ -82,6 +83,23 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
n.OnChange(symbolPath, value);
|
||||
}
|
||||
|
||||
// ---- symbol browser fake ----
|
||||
|
||||
public List<TwinCATDiscoveredSymbol> BrowseResults { get; } = new();
|
||||
public bool ThrowOnBrowse { get; set; }
|
||||
|
||||
public virtual async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (ThrowOnBrowse) throw Exception ?? new InvalidOperationException("fake browse failure");
|
||||
await Task.CompletedTask;
|
||||
foreach (var sym in BrowseResults)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) yield break;
|
||||
yield return sym;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeNotification(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex,
|
||||
Action<string, object?> onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATSymbolBrowserTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Discovery_without_EnableControllerBrowse_emits_only_predeclared()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Hidden", TwinCATDataType.DInt, false));
|
||||
return c;
|
||||
},
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = false,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.BrowseName).ShouldBe(["Declared"]);
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "Discovered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discovery_with_browse_enabled_adds_controller_symbols_under_Discovered_folder()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Counter", TwinCATDataType.DInt, ReadOnly: false));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("GVL.Setpoint", TwinCATDataType.Real, ReadOnly: false));
|
||||
return c;
|
||||
},
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Discovered");
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldContain("MAIN.Counter");
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldContain("GVL.Setpoint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browse_filters_system_symbols()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("TwinCAT_SystemInfoVarList._AppInfo", TwinCATDataType.DInt, false));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("Constants.PI", TwinCATDataType.LReal, true));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("Mc_InternalState", TwinCATDataType.DInt, true));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("__CompilerGen", TwinCATDataType.DInt, true));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Real", TwinCATDataType.DInt, false));
|
||||
return c;
|
||||
},
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Real"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browse_skips_symbols_with_null_datatype()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Struct", DataType: null, ReadOnly: false));
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Counter", TwinCATDataType.DInt, false));
|
||||
return c;
|
||||
},
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Counter"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOnly_symbol_surfaces_ViewOnly()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Status", TwinCATDataType.DInt, ReadOnly: true));
|
||||
return c;
|
||||
},
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browse_failure_is_non_fatal_predeclared_still_emits()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () => new FakeTwinCATClient { ThrowOnBrowse = true },
|
||||
};
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.BrowseName).ShouldContain("Declared");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("TwinCAT_SystemInfoVarList._AppInfo", true)]
|
||||
[InlineData("TwinCAT_RuntimeInfo.Something", true)]
|
||||
[InlineData("Constants.PI", true)]
|
||||
[InlineData("Mc_AxisState", true)]
|
||||
[InlineData("__hidden", true)]
|
||||
[InlineData("Global_Version", true)]
|
||||
[InlineData("MAIN.UserVar", false)]
|
||||
[InlineData("GVL.Counter", false)]
|
||||
[InlineData("MyFbInstance.State", false)]
|
||||
[InlineData("", true)]
|
||||
[InlineData(" ", true)]
|
||||
public void SystemSymbolFilter_matches_expected_patterns(string path, bool expected)
|
||||
{
|
||||
TwinCATSystemSymbolFilter.IsSystemSymbol(path).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user