Merge branch 'master' into b626-opcuaclient

This commit is contained in:
Joseph Doherty
2026-10-08 14:47:49 -04:00
72 changed files with 3775 additions and 343 deletions
@@ -142,6 +142,13 @@
try
{
_keys = await Keys.ListAsync(default);
// The one-time banner is only meaningful while its key exists; the credential is
// otua_<keyId>_<secret>, so drop it once no listed key matches (deleted here or elsewhere).
if (_created is not null
&& !_keys.Any(k => _created.StartsWith($"otua_{k.ApiKeyId}_", StringComparison.Ordinal)))
{
_created = null;
}
}
catch (Exception ex)
{
@@ -30,6 +30,36 @@
<div class="form-text">Controls ISO-TSAP slot byte during handshake.</div>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="s7ConnType">Connection type</label>
<InputSelect id="s7ConnType" @bind-Value="_form.ConnectionTypeText" @bind-Value:after="EmitAsync"
class="form-select form-select-sm">
<option value="">Default (slot-derived)</option>
@foreach (var v in Enum.GetValues<S7ConnectionType>())
{
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">First byte of the remote TSAP. Leave on default unless the CPU needs a PG/OP resource.</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="s7LocalTsap">Local TSAP</label>
<InputText id="s7LocalTsap" @bind-Value="_form.LocalTsap" @bind-Value:after="EmitAsync"
class="form-control form-control-sm mono" placeholder="01.00" />
<div class="form-text">Optional hex override, e.g. <code>01.00</code>.</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="s7RemoteTsap">Remote TSAP</label>
<InputText id="s7RemoteTsap" @bind-Value="_form.RemoteTsap" @bind-Value:after="EmitAsync"
class="form-control form-control-sm mono" placeholder="03.01" />
<div class="form-text">Optional hex override, e.g. <code>03.01</code>. Not combinable with Connection type.</div>
</div>
</div>
@if (_form.TsapConflictError() is { } tsapError)
{
<div class="text-danger small" role="alert">@tsapError</div>
}
</div>
</section>
@@ -141,6 +171,10 @@
public S7CpuType CpuType { get; set; } = S7CpuType.S71500;
public short Rack { get; set; } = 0;
public short Slot { get; set; } = 0;
public string? LocalTsap { get; set; }
public string? RemoteTsap { get; set; }
// Bound to a <select> as text ("" = default) so the nullable enum round-trips through InputSelect.
public string ConnectionTypeText { get; set; } = "";
public int TimeoutSeconds { get; set; } = 5;
// Probe
@@ -153,6 +187,12 @@
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
/// <summary>The same error the driver factory raises for Remote TSAP + Connection type together; null when fine.</summary>
public string? TsapConflictError() =>
!string.IsNullOrWhiteSpace(RemoteTsap) && !string.IsNullOrWhiteSpace(ConnectionTypeText)
? "S7 driver config " + S7DriverOptions.TsapConflictDetail
: null;
public static FormModel FromOptions(S7DriverOptions o) => new()
{
Host = o.Host,
@@ -160,6 +200,9 @@
CpuType = o.CpuType,
Rack = o.Rack,
Slot = o.Slot,
LocalTsap = o.LocalTsap,
RemoteTsap = o.RemoteTsap,
ConnectionTypeText = o.ConnectionType?.ToString() ?? "",
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
@@ -174,6 +217,9 @@
CpuType = CpuType,
Rack = Rack,
Slot = Slot,
LocalTsap = string.IsNullOrWhiteSpace(LocalTsap) ? null : LocalTsap.Trim(),
RemoteTsap = string.IsNullOrWhiteSpace(RemoteTsap) ? null : RemoteTsap.Trim(),
ConnectionType = Enum.TryParse<S7ConnectionType>(ConnectionTypeText, out var ct) ? ct : null,
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new S7ProbeOptions
{
@@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -615,9 +616,16 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (!await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == driverInstanceId, ct))
var owner = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
if (owner is null)
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
// #628: MTConnect owns one agent client per driver — mirror the deploy gate
// (DraftValidator.ValidateMTConnectSingleDevice) so the second Device is refused where it is authored.
if (string.Equals(owner.DriverType, DriverTypeNames.MTConnect, StringComparison.Ordinal)
&& await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId, ct))
return new UnsMutationResult(false, DraftValidator.MTConnectMultipleDevicesMessage(owner.Name));
if (await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId && dev.Name == name, ct))
return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver.");
@@ -68,8 +68,11 @@ public sealed class AbLegacyTagConfigModel
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(_bag, "dataType") is { } dtError)
return dtError;
if (string.IsNullOrWhiteSpace(Address))
return "Address is required.";
// The editor cannot see the device's PLC family, so it checks only what no family accepts (the same
// AbLegacyAddress authority the deploy gate and runtime use) — never stricter than the gate, which adds the
// family-specific verdict (PLC-5 octal I/O, MicroLogix-only function files).
if (AbLegacyAddress.Validate(Address, family: null) is { } addressError)
return addressError;
// DELIBERATELY STRICTER THAN THE RUNTIME, and re-confirmed as such by the Gitea #554 survey — do not
// "align" it. PCCC data-file elements are limited to AbLegacyArray.MaxElements (256). The reader
@@ -229,10 +229,25 @@ public sealed class MTConnectTagConfigModel
/// passed as null; it can only DEMOTE an item to String (DATA_SET/TABLE), which cannot create a false
/// warning because a numeric declaration on a String-inferred item is warned either way, and a SAMPLE
/// inferred Float64 is only warned about for a non-numeric, non-String declaration or an integer one.
/// TIME_SERIES arrays are not detectable without it and are not covered.
/// TIME_SERIES arrays are covered separately by the isArray rule in <see cref="Warnings"/>.
/// </remarks>
/// <returns>Zero or more human-readable warnings.</returns>
public IReadOnlyList<string> Warnings()
{
// #627: an array (TIME_SERIES) tag is served as double[] only. Independent of the probe metadata —
// the array flag is authored on the tag, and any other declared element type reads BadNotSupported.
var arrayWarning = _bag.TryGetPropertyValue("isArray", out var arr)
&& arr is JsonValue av && av.TryGetValue<bool>(out var isArray) && isArray
&& DataType != DriverDataType.Float64
? $"Data type {DataType} on an array tag: MTConnect TIME_SERIES arrays are served as Float64 (double[]) only; "
+ "declare Float64 or the tag reads BadNotSupported. Saving is still allowed."
: null;
var scalar = ScalarWarnings();
return arrayWarning is null ? scalar : [arrayWarning, .. scalar];
}
private IReadOnlyList<string> ScalarWarnings()
{
if (string.IsNullOrWhiteSpace(MtCategory)) { return []; }
@@ -120,7 +120,9 @@ public sealed class S7TagConfigModel
{
var dt = DataType;
if (dt is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
or S7DataType.String or S7DataType.DateTime)
or S7DataType.String or S7DataType.DateTime or S7DataType.WString or S7DataType.Char
or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date or S7DataType.Tod
or S7DataType.Time or S7DataType.S5Time)
{
return $"S7 does not support {dt} arrays — use Bool (read-only), Byte, Int16, UInt16, Int32, "
+ "UInt32 or Float32 for an array tag, or a scalar tag.";
@@ -48,3 +48,14 @@
flex: 0 0 18px;
width: 18px;
}
/* Modal backdrop overran the viewport on /raw (#631). The theme's `.rise` entrance animation runs with
`animation-fill-mode: both`, which keeps its final keyframe's transform applied for good — and ANY
transform (even the identity it leaves) makes that section the containing block for position:fixed
descendants. A modal rendered inside a `.panel.rise` therefore had its fixed backdrop offset by the
panel's left edge, so it ran 240px past the viewport and the page grew a horizontal scrollbar.
`backwards` keeps the hidden start state during the stagger delay but drops the transform once the
animation ends. (Upstream fix belongs in ZB.MOM.WW.Theme's theme.css.) */
.rise {
animation-fill-mode: backwards;
}
@@ -262,12 +262,16 @@ public sealed class AdminOperationsActor : ReceiveActor
var driverByDevice = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverInstanceId, StringComparer.Ordinal);
var deviceConfigById = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DeviceConfig, StringComparer.Ordinal);
var tagConfigWarnings = new List<(string TagId, string Text)>();
foreach (var t in draft.Tags)
{
if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue;
if (!driverTypeById.TryGetValue(driverInstanceId, out var driverType)) continue;
foreach (var w in EquipmentTagConfigInspector.Inspect(driverType, t.TagConfig))
foreach (var w in EquipmentTagConfigInspector.Inspect(
driverType, t.TagConfig, deviceConfigById.GetValueOrDefault(t.DeviceId)))
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}': {w}"));
}
if (tagConfigWarnings.Count > 0)
@@ -1,6 +1,7 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -78,6 +79,47 @@ public static class EquipmentTagConfigInspector
return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty<string>();
}
/// <summary>
/// <see cref="Inspect(string?, string?)"/> with the owning device's <c>DeviceConfig</c>, so a driver whose
/// address grammar depends on the device (AbLegacy: the <c>PlcFamily</c>) is checked against the family the
/// runtime will use. Other drivers ignore the device config. A missing family key is the runtime default
/// (SLC 500); an unrecognised value is "unknown" and only the family-independent check applies — the gate
/// is never stricter than the runtime.
/// </summary>
/// <param name="driverType">The bound driver's <c>DriverType</c>.</param>
/// <param name="tagConfig">The equipment tag's <c>TagConfig</c> JSON.</param>
/// <param name="deviceConfig">The owning device's <c>DeviceConfig</c> JSON (may be null).</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string? driverType, string? tagConfig, string? deviceConfig)
{
if (string.IsNullOrEmpty(driverType) || tagConfig is null) return Array.Empty<string>();
if (string.Equals(driverType, DriverTypeNames.AbLegacy, StringComparison.OrdinalIgnoreCase))
return AbLegacyTagDefinitionFactory.Inspect(tagConfig, ReadAbLegacyFamily(deviceConfig));
return Inspect(driverType, tagConfig);
}
private static AbLegacyPlcFamily? ReadAbLegacyFamily(string? deviceConfig)
{
if (string.IsNullOrWhiteSpace(deviceConfig)) return AbLegacyPlcFamily.Slc500;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(deviceConfig);
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return AbLegacyPlcFamily.Slc500;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, "PlcFamily", StringComparison.OrdinalIgnoreCase)) continue;
var raw = prop.Value.ValueKind == System.Text.Json.JsonValueKind.String ? prop.Value.GetString() : null;
if (string.IsNullOrWhiteSpace(raw)) return AbLegacyPlcFamily.Slc500;
return Enum.TryParse<AbLegacyPlcFamily>(raw, ignoreCase: true, out var f) && Enum.IsDefined(f) ? f : null;
}
return AbLegacyPlcFamily.Slc500;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
/// <summary>Whether the given driver type has a mapped inspector.</summary>
/// <param name="driverType">The bound driver's <c>DriverType</c>.</param>
/// <returns><see langword="true"/> when an inspector is registered for the driver type.</returns>