Merge R2-11 TagConfig consolidation (arch-review round 2) [PR #434]
Findings 01/C-1, 01/P-1 (single TagConfigIntent.Parse in Commons, 4 copies delegate, parse-once) + 05 CONV-2/UNDER-1/UNDER-6 (shared strict TagConfigJson readers, per-driver Inspect() + writable key, FOCAS capability pre-flight, Modbus probe timeoutMs, deploy-gate Warn|Error). Phase C runtime-strict flip deferred. T22/T24 deferred. Auto-merged AddressSpaceApplier.cs + DriverHostActor.cs with R2-04/R2-10; verified OpcUaServer.Tests 286/286 + Runtime.Tests 396/396. Build clean.
This commit is contained in:
+8
@@ -17,6 +17,14 @@
|
||||
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, AbCipDataType.DInt)))">
|
||||
@foreach (var v in Enum.GetValues<AbCipDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="abcip-writable"
|
||||
checked="@(_m.Writable ?? true)"
|
||||
@onchange="@(e => Update(() => _m.Writable = e.Value is bool b && b))" />
|
||||
<label class="form-check-label small" for="abcip-writable">Writable (uncheck for a read-only tag)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showPicker)
|
||||
|
||||
+8
@@ -17,6 +17,14 @@
|
||||
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, AbLegacyDataType.Int)))">
|
||||
@foreach (var v in Enum.GetValues<AbLegacyDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="ablegacy-writable"
|
||||
checked="@(_m.Writable ?? true)"
|
||||
@onchange="@(e => Update(() => _m.Writable = e.Value is bool b && b))" />
|
||||
<label class="form-check-label small" for="ablegacy-writable">Writable (uncheck for a read-only tag)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showPicker)
|
||||
|
||||
+6
@@ -17,6 +17,12 @@
|
||||
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, FocasDataType.Int32)))">
|
||||
@foreach (var v in Enum.GetValues<FocasDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="focas-writable" disabled checked="false" />
|
||||
<label class="form-check-label small text-muted" for="focas-writable">Writable — FOCAS writes are unsupported; tags are read-only.</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showPicker)
|
||||
|
||||
+8
@@ -25,6 +25,14 @@
|
||||
<div class="col-12 mt-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="@(() => _showPicker = true)">Build address</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="modbus-writable"
|
||||
checked="@(_m.Writable ?? true)"
|
||||
@onchange="@(e => Update(() => _m.Writable = e.Value is bool b && b))" />
|
||||
<label class="form-check-label small" for="modbus-writable">Writable (uncheck for a read-only tag)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showPicker)
|
||||
|
||||
+8
@@ -17,6 +17,14 @@
|
||||
</select></div>
|
||||
<div class="col-md-3"><label class="form-label">String len</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="s7-writable"
|
||||
checked="@(_m.Writable ?? true)"
|
||||
@onchange="@(e => Update(() => _m.Writable = e.Value is bool b && b))" />
|
||||
<label class="form-check-label small" for="s7-writable">Writable (uncheck for a read-only tag)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showPicker)
|
||||
|
||||
+8
@@ -17,6 +17,14 @@
|
||||
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, TwinCATDataType.DInt)))">
|
||||
@foreach (var v in Enum.GetValues<TwinCATDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="twincat-writable"
|
||||
checked="@(_m.Writable ?? true)"
|
||||
@onchange="@(e => Update(() => _m.Writable = e.Value is bool b && b))" />
|
||||
<label class="form-check-label small" for="twincat-writable">Writable (uncheck for a read-only tag)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showPicker)
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
|
||||
/// <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
|
||||
/// (<c>src/Server/…Runtime/VirtualTags/DependencyMuxActor.cs:97</c>) — and that
|
||||
/// <c>FullReference</c> is the <c>FullName</c> field extracted from <c>Tag.TagConfig</c>
|
||||
/// (see <c>AddressSpaceComposer.ExtractTagFullName</c> + <c>EquipmentNodeWalker.ExtractFullName</c>).
|
||||
/// (see <c>Commons.Types.TagConfigIntent.Parse</c>, the shared byte-parity FullName authority).
|
||||
/// The UNS-path engine (<c>Core.VirtualTags.VirtualTagEngine</c>, keyed by a slash-joined
|
||||
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c>) is dormant — it is NOT wired into the
|
||||
/// host — so UNS browse paths never resolve at runtime and are intentionally NOT suggested.
|
||||
@@ -179,8 +179,8 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
|
||||
/// <summary>
|
||||
/// Extracts the driver-side full reference from a <c>Tag.TagConfig</c> JSON blob — the
|
||||
/// top-level <c>FullName</c> string every shipped driver stores. Mirrors
|
||||
/// <c>EquipmentNodeWalker.ExtractFullName</c> / <c>AddressSpaceComposer.ExtractTagFullName</c>
|
||||
/// (AdminUI does not reference those assemblies). Falls back to the raw blob when it is not
|
||||
/// <c>Commons.Types.TagConfigIntent.Parse(...).FullName</c> (the shared byte-parity authority;
|
||||
/// AdminUI does not reference that assembly here). Falls back to the raw blob when it is not
|
||||
/// a JSON object carrying a string <c>FullName</c>.
|
||||
/// </summary>
|
||||
private static string ExtractFullNameFromTagConfig(string tagConfig)
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed class AbCipTagConfigModel
|
||||
/// <summary>Logix atomic type, or <c>AbCipDataType.Structure</c> for UDT-typed tags.</summary>
|
||||
public AbCipDataType DataType { get; set; } = AbCipDataType.DInt;
|
||||
|
||||
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
@@ -30,6 +34,7 @@ public sealed class AbCipTagConfigModel
|
||||
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
|
||||
TagPath = TagConfigJson.GetString(o, "tagPath") ?? "",
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", AbCipDataType.DInt),
|
||||
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
@@ -42,6 +47,7 @@ public sealed class AbCipTagConfigModel
|
||||
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
|
||||
TagConfigJson.Set(_bag, "tagPath", TagPath.Trim());
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "writable", Writable);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed class AbLegacyTagConfigModel
|
||||
/// <summary>PCCC data type that maps onto SLC / MicroLogix / PLC-5 files.</summary>
|
||||
public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int;
|
||||
|
||||
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
@@ -30,6 +34,7 @@ public sealed class AbLegacyTagConfigModel
|
||||
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
|
||||
Address = TagConfigJson.GetString(o, "address") ?? "",
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", AbLegacyDataType.Int),
|
||||
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
@@ -42,6 +47,7 @@ public sealed class AbLegacyTagConfigModel
|
||||
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
|
||||
TagConfigJson.Set(_bag, "address", Address.Trim());
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "writable", Writable);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ public sealed class ModbusTagConfigModel
|
||||
/// <summary>String length in characters for String tags.</summary>
|
||||
public int StringLength { get; set; }
|
||||
|
||||
/// <summary>Optional writable flag (R2-11). <c>null</c> ⇒ the key is omitted (driver default true);
|
||||
/// an explicit <c>false</c> makes the equipment tag read-only. Preserved across load→save so an absent
|
||||
/// key stays absent.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
@@ -42,6 +47,7 @@ public sealed class ModbusTagConfigModel
|
||||
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
|
||||
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
|
||||
StringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
@@ -57,6 +63,7 @@ public sealed class ModbusTagConfigModel
|
||||
TagConfigJson.Set(_bag, "byteOrder", ByteOrder);
|
||||
TagConfigJson.Set(_bag, "bitIndex", BitIndex);
|
||||
TagConfigJson.Set(_bag, "stringLength", StringLength);
|
||||
TagConfigJson.Set(_bag, "writable", Writable);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// stable <c>nsu=…;s=…</c> or plain <c>ns=2;s=…</c> NodeId the driver reads/writes/subscribes against).
|
||||
/// Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
/// <remarks>
|
||||
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
|
||||
/// (<c>AddressSpaceComposer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
|
||||
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
|
||||
/// The <c>FullName</c> key is intentionally PascalCase: the shared deploy-time parser
|
||||
/// (<c>Commons.Types.TagConfigIntent.Parse</c>, consumed by the composer, artifact decoder, draft
|
||||
/// gate, and node walker) reads it via a case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
|
||||
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
|
||||
/// <c>historianTagname</c>) are NOT modelled here — they are owned by the TagModal-merge seam
|
||||
/// (<see cref="TagHistorizeConfig"/>) and survive a load→save of this model as preserved unknown keys.
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed class S7TagConfigModel
|
||||
/// <summary>For <c>DataType = String</c>: S7-string max length (max 254).</summary>
|
||||
public int StringLength { get; set; }
|
||||
|
||||
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
@@ -30,6 +34,7 @@ public sealed class S7TagConfigModel
|
||||
Address = TagConfigJson.GetString(o, "address") ?? "",
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", S7DataType.Int16),
|
||||
StringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
@@ -42,6 +47,7 @@ public sealed class S7TagConfigModel
|
||||
TagConfigJson.Set(_bag, "address", Address.Trim());
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "stringLength", StringLength);
|
||||
TagConfigJson.Set(_bag, "writable", Writable);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,14 @@ public static class TagConfigJson
|
||||
public static bool GetBool(JsonObject o, string name, bool fallback = false)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<bool>(out var b) ? b : fallback;
|
||||
|
||||
/// <summary>Reads a bool value, or <c>null</c> when the property is absent/null/non-boolean — so an
|
||||
/// absent key stays absent through a load→save (distinguishes "not set" from an explicit false).</summary>
|
||||
/// <param name="o">The JSON object to read from.</param>
|
||||
/// <param name="name">The property name to read.</param>
|
||||
/// <returns>The bool value, or <c>null</c> when absent/null/non-boolean.</returns>
|
||||
public static bool? GetBoolNullable(JsonObject o, string name)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<bool>(out var b) ? b : null;
|
||||
|
||||
/// <summary>Reads an enum by its serialised name, or <paramref name="fallback"/> if absent/unparseable.</summary>
|
||||
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
|
||||
/// <param name="o">The JSON object to read from.</param>
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed class TwinCATTagConfigModel
|
||||
/// <summary>TwinCAT / IEC 61131-3 atomic data type.</summary>
|
||||
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
|
||||
|
||||
/// <summary>Optional writable flag (R2-11); <c>null</c> ⇒ omitted (driver default true), explicit
|
||||
/// <c>false</c> ⇒ read-only. Preserved across load→save.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
@@ -30,6 +34,7 @@ public sealed class TwinCATTagConfigModel
|
||||
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
|
||||
SymbolPath = TagConfigJson.GetString(o, "symbolPath") ?? "",
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", TwinCATDataType.DInt),
|
||||
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
@@ -42,6 +47,7 @@ public sealed class TwinCATTagConfigModel
|
||||
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
|
||||
TagConfigJson.Set(_bag, "symbolPath", SymbolPath.Trim());
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "writable", Writable);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,31 +25,37 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
private readonly IActorRef _coordinator;
|
||||
private readonly IReadOnlyDictionary<string, IDriverProbe> _probesByType;
|
||||
private readonly TagConfigValidationMode _tagConfigValidationMode;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
/// <summary>Creates actor props for the admin operations actor.</summary>
|
||||
/// <param name="dbFactory">Factory for creating config database contexts.</param>
|
||||
/// <param name="coordinator">Reference to the deployment coordinator actor.</param>
|
||||
/// <param name="probes">Driver probes registered in DI; keyed by DriverType (case-insensitive).</param>
|
||||
/// <param name="tagConfigValidationMode">Deploy-gate TagConfig strictness mode (Warn default | Error opt-in).</param>
|
||||
/// <returns>Props configured to create an AdminOperationsActor.</returns>
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
IActorRef coordinator,
|
||||
IEnumerable<IDriverProbe> probes) =>
|
||||
Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator, probes));
|
||||
IEnumerable<IDriverProbe> probes,
|
||||
TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn) =>
|
||||
Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator, probes, tagConfigValidationMode));
|
||||
|
||||
/// <summary>Initializes a new instance of the AdminOperationsActor.</summary>
|
||||
/// <param name="dbFactory">Factory for creating config database contexts.</param>
|
||||
/// <param name="coordinator">Reference to the deployment coordinator actor.</param>
|
||||
/// <param name="probes">Driver probes registered in DI; keyed by DriverType (case-insensitive).</param>
|
||||
/// <param name="tagConfigValidationMode">Deploy-gate TagConfig strictness mode (Warn default | Error opt-in).</param>
|
||||
public AdminOperationsActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
IActorRef coordinator,
|
||||
IEnumerable<IDriverProbe> probes)
|
||||
IEnumerable<IDriverProbe> probes,
|
||||
TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_coordinator = coordinator;
|
||||
_probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
|
||||
_tagConfigValidationMode = tagConfigValidationMode;
|
||||
|
||||
ReceiveAsync<StartDeployment>(HandleStartDeploymentAsync);
|
||||
ReceiveAsync<TestDriverConnect>(HandleTestDriverConnectAsync);
|
||||
@@ -202,6 +208,31 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
errors.AddRange(DraftValidator.ValidateClusterTopology(cluster, nodes));
|
||||
}
|
||||
|
||||
// R2-11 (05/CONV-2): deploy-time TagConfig strictness inspection over equipment tags. The
|
||||
// driver-typed EquipmentTagConfigInspector surfaces present-but-invalid enum values (which the
|
||||
// lenient runtime silently defaults) + structurally-unparseable TagConfig. In Warn mode
|
||||
// (default) these are non-blocking (logged + appended to the accepted result Message); in Error
|
||||
// mode they fold into the reject list so a typo'd config cannot be re-deployed until fixed.
|
||||
// Running servers are untouched either way — the gate only sees re-deploys.
|
||||
var driverTypeById = draft.DriverInstances
|
||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
|
||||
var tagConfigWarnings = new List<(string TagId, string Text)>();
|
||||
foreach (var t in draft.Tags.Where(t => t.EquipmentId is not null))
|
||||
{
|
||||
if (!driverTypeById.TryGetValue(t.DriverInstanceId, out var driverType)) continue;
|
||||
foreach (var w in EquipmentTagConfigInspector.Inspect(driverType, t.TagConfig))
|
||||
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}' (equipment '{t.EquipmentId}'): {w}"));
|
||||
}
|
||||
if (tagConfigWarnings.Count > 0)
|
||||
{
|
||||
if (_tagConfigValidationMode == TagConfigValidationMode.Error)
|
||||
errors.AddRange(tagConfigWarnings.Select(x => new ValidationError("TagConfigInvalid", x.Text, x.TagId)));
|
||||
else
|
||||
foreach (var x in tagConfigWarnings)
|
||||
_log.Warning("StartDeployment: TagConfig warning (Warn mode, non-blocking): {Warning}", x.Text);
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
var summary = string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}"));
|
||||
@@ -277,11 +308,18 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
|
||||
_coordinator.Tell(new DispatchDeployment(deploymentId, revHash, msg.CorrelationId));
|
||||
|
||||
// Accepted message = the compile-cost advisory (if any) + any Warn-mode TagConfig warnings, so
|
||||
// the operator sees the non-blocking strictness findings on a successful deploy.
|
||||
var messageParts = new List<string>();
|
||||
if (advisory is not null) messageParts.Add(advisory);
|
||||
messageParts.AddRange(tagConfigWarnings.Select(x => x.Text));
|
||||
var acceptedMessage = messageParts.Count > 0 ? string.Join("; ", messageParts) : null;
|
||||
|
||||
replyTo.Tell(new StartDeploymentResult(
|
||||
StartDeploymentOutcome.Accepted,
|
||||
deploymentId,
|
||||
revHash,
|
||||
Message: advisory,
|
||||
Message: acceptedMessage,
|
||||
msg.CorrelationId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time TagConfig inspection dispatched by <c>DriverType</c> (R2-11, 05/CONV-2). Mirrors the
|
||||
/// AdminUI <c>TagConfigValidator</c> shape: a case-insensitive map from the canonical driver-type string
|
||||
/// to that driver's lightweight <c>Contracts</c> parser <c>Inspect()</c>, returning human-readable
|
||||
/// warnings (present-but-invalid enum values that the lenient runtime silently defaults, plus
|
||||
/// structurally-unparseable TagConfig). Unmapped drivers (Galaxy, OpcUaClient) are skipped exactly as in
|
||||
/// the AdminUI validator. Never throws.
|
||||
/// </summary>
|
||||
public static class EquipmentTagConfigInspector
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, Func<string, IReadOnlyList<string>>> Inspectors =
|
||||
new Dictionary<string, Func<string, IReadOnlyList<string>>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Modbus"] = ModbusEquipmentTagParser.Inspect,
|
||||
["S7"] = S7EquipmentTagParser.Inspect,
|
||||
["AbCip"] = AbCipEquipmentTagParser.Inspect,
|
||||
["AbLegacy"] = AbLegacyEquipmentTagParser.Inspect,
|
||||
["TwinCat"] = TwinCATEquipmentTagParser.Inspect,
|
||||
["Focas"] = FocasEquipmentTagParser.Inspect,
|
||||
};
|
||||
|
||||
/// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings,
|
||||
/// or an empty list when the driver type is unmapped (Galaxy/OpcUaClient) or the inputs are null.</summary>
|
||||
/// <param name="driverType">The bound driver's <c>DriverType</c> (canonical string, case-insensitive).</param>
|
||||
/// <param name="tagConfig">The equipment tag's <c>TagConfig</c> JSON.</param>
|
||||
/// <returns>The warnings; empty when the driver is unmapped or clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string? driverType, string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrEmpty(driverType) || tagConfig is null) return Array.Empty<string>();
|
||||
return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>Whether the given driver type has a mapped inspector (equipment-tag protocol drivers only).</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>
|
||||
public static bool IsMapped(string? driverType) =>
|
||||
!string.IsNullOrEmpty(driverType) && Inspectors.ContainsKey(driverType);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
|
||||
/// <summary>
|
||||
/// How the deploy gate treats TagConfig strictness inspection results (R2-11, 05/CONV-2). Sourced from
|
||||
/// <c>Deployment:TagConfigValidationMode</c> in appsettings; defaults to <see cref="Warn"/> so existing
|
||||
/// deployments are never blocked by a previously-tolerated typo.
|
||||
/// </summary>
|
||||
public enum TagConfigValidationMode
|
||||
{
|
||||
/// <summary>Non-blocking: inspection warnings are logged and appended to the accepted
|
||||
/// <c>StartDeploymentResult.Message</c>, but the deploy still succeeds. The default.</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Blocking: inspection warnings fold into the draft-gate reject list, so a config with a
|
||||
/// typo'd enum (or unparseable TagConfig) cannot be re-deployed until fixed. Opt-in per environment
|
||||
/// once the fleet deploys warning-clean.</summary>
|
||||
Error,
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Akka.Actor;
|
||||
using Akka.Cluster.Hosting;
|
||||
using Akka.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
@@ -61,7 +62,12 @@ public static class ServiceCollectionExtensions
|
||||
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
||||
var coordinator = registry.Get<ConfigPublishCoordinatorKey>();
|
||||
var probes = resolver.GetService<IEnumerable<IDriverProbe>>() ?? Enumerable.Empty<IDriverProbe>();
|
||||
return AdminOperationsActor.Props(dbFactory, coordinator, probes);
|
||||
// R2-11 (05/CONV-2): deploy-gate TagConfig strictness mode from appsettings
|
||||
// (Deployment:TagConfigValidationMode = Warn default | Error opt-in).
|
||||
var mode = Enum.TryParse<TagConfigValidationMode>(
|
||||
resolver.GetService<IConfiguration>()?["Deployment:TagConfigValidationMode"],
|
||||
ignoreCase: true, out var m) ? m : TagConfigValidationMode.Warn;
|
||||
return AdminOperationsActor.Props(dbFactory, coordinator, probes, mode);
|
||||
},
|
||||
singletonOptions);
|
||||
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
<!-- Roslyn-free; gives HandleStartDeploymentAsync PassthroughScript.TryMatch to exclude
|
||||
mirror scripts (which compile to ~nothing) from the deploy compile-cost advisory. -->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
|
||||
<!-- R2-11 (05/CONV-2): the driver-typed EquipmentTagConfigInspector dispatches to each driver's
|
||||
lightweight Contracts parser Inspect(), the same dependency-light consumption AdminUI does. -->
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts\ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -43,5 +43,9 @@
|
||||
"DrainIntervalSeconds": 5,
|
||||
"Capacity": 1000000,
|
||||
"DeadLetterRetentionDays": 30
|
||||
},
|
||||
"Deployment": {
|
||||
"_comment": "R2-11 (05/CONV-2): deploy-gate TagConfig strictness. Warn (default) = non-blocking warnings logged + appended to the deployment result message; Error = a config with a typo'd enum or unparseable TagConfig is rejected at the draft gate. Running servers are untouched; the gate only sees re-deploys.",
|
||||
"TagConfigValidationMode": "Warn"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,7 +689,7 @@ public sealed class AddressSpaceApplier
|
||||
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
|
||||
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
|
||||
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
|
||||
// AddressSpaceComposer.ExtractTagFullName falls back to the RAW TagConfig blob as FullName, so a DataType/
|
||||
// TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/
|
||||
// array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the
|
||||
// correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway).
|
||||
private static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) =>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
@@ -66,11 +65,11 @@ public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, strin
|
||||
/// (both <c>null</c> ⇒ driver-less / no device), copied straight from the <c>Equipment</c> row.
|
||||
/// <see cref="DeviceHost"/> is the device's connection host (e.g. <c>"10.0.0.5:8193"</c>) resolved from the
|
||||
/// bound <c>Device</c>'s schemaless <c>DeviceConfig</c> JSON via
|
||||
/// <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> — <c>null</c> when there is no device, no
|
||||
/// <c>HostAddress</c> in its config, or the host cannot be parsed. These three let a later task graft a
|
||||
/// driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent.TryExtractHost"/> — <c>null</c> when there
|
||||
/// is no device, no <c>HostAddress</c> in its config, or the host cannot be parsed. These three let a later
|
||||
/// task graft a driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a
|
||||
/// multi-device driver by host. The value is normalized identically on both the live-edit composer and
|
||||
/// the artifact-decode sides (single source of truth: <see cref="AddressSpaceComposer.TryExtractDeviceHost"/>);
|
||||
/// the artifact-decode sides (single source of truth: <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent"/>);
|
||||
/// the later partition task MUST normalize the driver-discovered device-host folder segment the same way
|
||||
/// (trim + lower-case) so the two compare equal.</para>
|
||||
/// <para><b>Address-space-rebuild interaction (accepted trade-off).</b> These three fields participate in
|
||||
@@ -350,8 +349,8 @@ public static class AddressSpaceComposer
|
||||
var resolvedScripts = scripts ?? Array.Empty<Script>();
|
||||
|
||||
// DeviceId → connection host, resolved once from each bound Device's schemaless DeviceConfig JSON
|
||||
// via the shared TryExtractDeviceHost (single source of truth + normalization for both this
|
||||
// composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
|
||||
// via the shared DeviceConfigIntent.TryExtractHost (single source of truth + normalization for both
|
||||
// this composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
|
||||
// byte-parity-equal). This MUST match DeploymentArtifact.BuildDeviceHostMap semantics EXACTLY:
|
||||
// Ordinal comparer, skip blank/whitespace DeviceIds, and LAST-WINS on a duplicate DeviceId (a
|
||||
// foreach assignment, NOT ToDictionary which would THROW on a dupe — diverging from the decode
|
||||
@@ -360,7 +359,7 @@ public static class AddressSpaceComposer
|
||||
foreach (var d in devices ?? Array.Empty<Device>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(d.DeviceId)) continue;
|
||||
deviceHostById[d.DeviceId] = TryExtractDeviceHost(d.DeviceConfig);
|
||||
deviceHostById[d.DeviceId] = DeviceConfigIntent.TryExtractHost(d.DeviceConfig);
|
||||
}
|
||||
var areas = unsAreas
|
||||
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||
@@ -415,8 +414,11 @@ public static class AddressSpaceComposer
|
||||
.ThenBy(t => t.Name, StringComparer.Ordinal)
|
||||
.Select(t =>
|
||||
{
|
||||
var (isHistorized, historianTagname) = ExtractTagHistorize(t.TagConfig);
|
||||
var (isArray, arrayLength) = ExtractTagArray(t.TagConfig);
|
||||
// Parse the schemaless TagConfig blob ONCE per tag (01/P-1) via the shared byte-parity
|
||||
// authority (01/C-1). TagConfigIntent.Parse is the SINGLE SOURCE OF TRUTH the
|
||||
// artifact-decode seam (DeploymentArtifact), the draft gate (DraftValidator), and the
|
||||
// walker all consume — so the live-compose and artifact-decode plans stay byte-equal.
|
||||
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||
return new EquipmentTagPlan(
|
||||
TagId: t.TagId,
|
||||
EquipmentId: t.EquipmentId!,
|
||||
@@ -424,13 +426,13 @@ public static class AddressSpaceComposer
|
||||
FolderPath: t.FolderPath ?? string.Empty,
|
||||
Name: t.Name,
|
||||
DataType: t.DataType,
|
||||
FullName: ExtractTagFullName(t.TagConfig),
|
||||
FullName: intent.FullName,
|
||||
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
|
||||
Alarm: ExtractTagAlarm(t.TagConfig),
|
||||
IsHistorized: isHistorized,
|
||||
HistorianTagname: historianTagname,
|
||||
IsArray: isArray,
|
||||
ArrayLength: arrayLength);
|
||||
Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null,
|
||||
IsHistorized: intent.IsHistorized,
|
||||
HistorianTagname: intent.HistorianTagname,
|
||||
IsArray: intent.IsArray,
|
||||
ArrayLength: intent.ArrayLength);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -523,165 +525,4 @@ public static class AddressSpaceComposer
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract the driver-side full reference from a <see cref="Tag.TagConfig"/> JSON blob: the
|
||||
/// <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a JSON object, and every shipped
|
||||
/// driver stores the wire-level address in a top-level <c>FullName</c> field. Replicated from
|
||||
/// <c>EquipmentNodeWalker.ExtractFullName</c> because OpcUaServer does not reference the Core
|
||||
/// driver assembly (kept in sync with the artifact-decode copy in <c>DeploymentArtifact</c>).
|
||||
/// Falls back to the raw blob when it is not a JSON object with a string <c>FullName</c>.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The tag's wire-level address JSON.</param>
|
||||
/// <returns>The extracted full reference, or the raw blob when no <c>FullName</c> is present.</returns>
|
||||
private static string ExtractTagFullName(string tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
|
||||
&& fullName.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return fullName.GetString() ?? tagConfig;
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* fall through to raw blob */ }
|
||||
return tagConfig;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract a <see cref="Device"/>'s connection host from its schemaless <c>DeviceConfig</c> JSON:
|
||||
/// the top-level <c>"HostAddress"</c> string (e.g. <c>"10.201.31.5:8193"</c>) — the same value a
|
||||
/// FOCAS driver emits as its discovered device-host folder segment. Returns <c>null</c> when the
|
||||
/// config is blank, not a JSON object, has no string <c>HostAddress</c>, or the value is
|
||||
/// blank/whitespace. Never throws.
|
||||
/// <para>The returned host is deterministically normalized — trimmed and lower-cased — so the
|
||||
/// live-edit composer side and the artifact-decode side (<c>DeploymentArtifact</c>) agree
|
||||
/// byte-for-byte. This method is the SINGLE SOURCE OF TRUTH for that normalization: the later
|
||||
/// FixedTree-partition task MUST normalize the driver-discovered device-host folder segment the
|
||||
/// same way (call this, or apply the identical trim + lower-case) before comparing the two.</para>
|
||||
/// </summary>
|
||||
/// <param name="deviceConfigJson">The device's schemaless <c>DeviceConfig</c> JSON blob.</param>
|
||||
/// <returns>The normalized device host, or <c>null</c> when absent/blank/unparseable.</returns>
|
||||
public static string? TryExtractDeviceHost(string? deviceConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceConfigJson)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(deviceConfigJson);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!doc.RootElement.TryGetProperty("HostAddress", out var hostEl)
|
||||
|| hostEl.ValueKind != JsonValueKind.String) return null;
|
||||
var raw = hostEl.GetString();
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
// Deterministic normalization (trim + lower-case) so both seams produce the identical string.
|
||||
return NormalizeDeviceHost(raw);
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SINGLE SOURCE OF TRUTH for device-host normalization: trims surrounding whitespace and
|
||||
/// lower-cases (invariant). <see cref="TryExtractDeviceHost"/> applies this to a <c>Device</c>'s
|
||||
/// parsed <c>HostAddress</c>, and the FixedTree-partition path (<c>DriverHostActor</c>) applies the
|
||||
/// SAME function to a driver-discovered device-host folder segment before comparing the two — so an
|
||||
/// <see cref="EquipmentNode.DeviceHost"/> and a captured folder segment for the same device compare
|
||||
/// equal regardless of case/whitespace. Idempotent (a value already normalized is unchanged).
|
||||
/// </summary>
|
||||
/// <param name="host">The raw host string (non-null; a non-empty <c>HostAddress</c> or folder segment).</param>
|
||||
/// <returns>The normalized host (trimmed + lower-cased).</returns>
|
||||
public static string NormalizeDeviceHost(string host) => host.Trim().ToLowerInvariant();
|
||||
|
||||
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
|
||||
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
|
||||
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
|
||||
/// <returns>The parsed native-alarm intent, or <see langword="null"/> when the tag has no alarm object.</returns>
|
||||
internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
||||
var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
|
||||
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
|
||||
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
|
||||
&& sEl.TryGetInt32(out var sv) ? sv : 500;
|
||||
// historizeToAveva (bool?, absent ⇒ null ⇒ historize): only an explicit false suppresses the
|
||||
// durable AVEVA write at the HistorianAdapterActor gate; a non-bool node ⇒ null (default-on).
|
||||
bool? historize = a.TryGetProperty("historizeToAveva", out var hEl)
|
||||
&& hEl.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? hEl.GetBoolean()
|
||||
: null;
|
||||
return new EquipmentTagAlarmInfo(type, sev, historize);
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Parses the optional server-side HistoryRead intent from a tag's <c>TagConfig</c> JSON:
|
||||
/// the <c>isHistorized</c> bool (absent / not a bool / non-object root / blank / malformed ⇒
|
||||
/// <c>false</c>) and the optional <c>historianTagname</c> string override (absent / not a string /
|
||||
/// whitespace-or-empty ⇒ <c>null</c>, meaning the historian tagname defaults to the tag's FullName,
|
||||
/// resolved later). The raw string value is used — not trimmed — matching <c>ExtractTagFullName</c> /
|
||||
/// <c>ExtractTagAlarm</c>. Never throws. The artifact-decode side
|
||||
/// (<c>DeploymentArtifact.ExtractTagHistorize</c>) MUST parse identically (byte-parity).</summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
|
||||
/// <returns>The parsed historize flag and optional historian tagname override.</returns>
|
||||
internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
|
||||
var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl)
|
||||
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
|
||||
&& hEl.GetBoolean();
|
||||
string? tagname = null;
|
||||
if (doc.RootElement.TryGetProperty("historianTagname", out var nEl)
|
||||
&& nEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var raw = nEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(raw)) tagname = raw;
|
||||
}
|
||||
return (isHistorized, tagname);
|
||||
}
|
||||
catch (JsonException) { return (false, null); }
|
||||
}
|
||||
|
||||
/// <summary>Parses the optional array intent from a tag's <c>TagConfig</c> JSON: the <c>isArray</c>
|
||||
/// bool (absent / not a bool / non-object root / blank / malformed ⇒ <c>false</c>) and the optional
|
||||
/// <c>arrayLength</c> uint. The length is honoured ONLY when <c>isArray</c> is true AND the prop is a
|
||||
/// JSON number that fits <c>uint</c> (else <c>null</c> ⇒ unbounded 1-D array,
|
||||
/// <c>ArrayDimensions=[0]</c> at materialisation). Mirrors
|
||||
/// <see cref="ExtractTagHistorize"/> exactly in structure + null/blank/non-object/malformed-JSON
|
||||
/// tolerance. Never throws. The artifact-decode side
|
||||
/// (<c>DeploymentArtifact.ExtractTagArray</c>) MUST parse identically (byte-parity).</summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
|
||||
/// <returns>The parsed array flag and optional array length.</returns>
|
||||
internal static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
|
||||
var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl)
|
||||
&& (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
|
||||
&& aEl.GetBoolean();
|
||||
uint? arrayLength = null;
|
||||
if (isArray
|
||||
&& doc.RootElement.TryGetProperty("arrayLength", out var lEl)
|
||||
&& lEl.ValueKind == JsonValueKind.Number
|
||||
&& lEl.TryGetUInt32(out var len))
|
||||
{
|
||||
arrayLength = len;
|
||||
}
|
||||
return (isArray, arrayLength);
|
||||
}
|
||||
catch (JsonException) { return (false, null); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,8 +458,10 @@ public static class DeploymentArtifact
|
||||
// ordinary equipment tags now (GalaxyMxGateway is a standard Equipment-kind driver).
|
||||
if (!equipmentNamespaces.Contains(nsId)) continue;
|
||||
|
||||
var (isHistorized, historianTagname) = ExtractTagHistorize(tagConfig);
|
||||
var (isArray, arrayLength) = ExtractTagArray(tagConfig);
|
||||
// Parse the schemaless TagConfig blob ONCE per tag via the shared byte-parity authority
|
||||
// (01/C-1) — the SAME TagConfigIntent.Parse the live-compose seam (AddressSpaceComposer)
|
||||
// consumes, so the artifact-decode plan stays byte-equal with the composer's.
|
||||
var intent = TagConfigIntent.Parse(tagConfig);
|
||||
result.Add(new EquipmentTagPlan(
|
||||
TagId: tagId!,
|
||||
EquipmentId: equipmentId!,
|
||||
@@ -467,13 +469,13 @@ public static class DeploymentArtifact
|
||||
FolderPath: folder ?? string.Empty,
|
||||
Name: name!,
|
||||
DataType: dataType ?? "BaseDataType",
|
||||
FullName: ExtractTagFullName(tagConfig),
|
||||
FullName: intent.FullName,
|
||||
Writable: writable,
|
||||
Alarm: ExtractTagAlarm(tagConfig),
|
||||
IsHistorized: isHistorized,
|
||||
HistorianTagname: historianTagname,
|
||||
IsArray: isArray,
|
||||
ArrayLength: arrayLength));
|
||||
Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null,
|
||||
IsHistorized: intent.IsHistorized,
|
||||
HistorianTagname: intent.HistorianTagname,
|
||||
IsArray: intent.IsArray,
|
||||
ArrayLength: intent.ArrayLength));
|
||||
}
|
||||
|
||||
result.Sort((a, b) =>
|
||||
@@ -665,113 +667,6 @@ public static class DeploymentArtifact
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName"
|
||||
/// field). The artifact-decode mirror of <c>AddressSpaceComposer.ExtractTagFullName</c> /
|
||||
/// <c>EquipmentNodeWalker.ExtractFullName</c> — replicated because Runtime does not reference
|
||||
/// the Core driver assembly. Falls back to the raw blob when absent or non-JSON.
|
||||
/// </summary>
|
||||
private static string ExtractTagFullName(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig ?? string.Empty;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
|
||||
&& fullName.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return fullName.GetString() ?? tagConfig;
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* fall through to raw blob */ }
|
||||
return tagConfig;
|
||||
}
|
||||
|
||||
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
|
||||
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
|
||||
/// live-edit side (<c>AddressSpaceComposer.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
|
||||
private static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
||||
var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
|
||||
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
|
||||
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
|
||||
&& sEl.TryGetInt32(out var sv) ? sv : 500;
|
||||
// historizeToAveva (bool?, absent ⇒ null ⇒ historize): byte-parity with
|
||||
// AddressSpaceComposer.ExtractTagAlarm — only an explicit false suppresses the durable AVEVA write.
|
||||
bool? historize = a.TryGetProperty("historizeToAveva", out var hEl)
|
||||
&& hEl.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? hEl.GetBoolean()
|
||||
: null;
|
||||
return new EquipmentTagAlarmInfo(type, sev, historize);
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Parses the optional server-side HistoryRead intent from a tag's <c>TagConfig</c> JSON:
|
||||
/// the <c>isHistorized</c> bool (absent / not a bool / non-object root / blank / malformed ⇒
|
||||
/// <c>false</c>) and the optional <c>historianTagname</c> string override (absent / not a string /
|
||||
/// whitespace-or-empty ⇒ <c>null</c>, meaning the historian tagname defaults to the tag's FullName,
|
||||
/// resolved later). The raw string value is used — not trimmed — matching <c>ExtractTagFullName</c> /
|
||||
/// <c>ExtractTagAlarm</c>. Never throws. The live-edit composer side
|
||||
/// (<c>AddressSpaceComposer.ExtractTagHistorize</c>) MUST parse identically (byte-parity).</summary>
|
||||
private static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
|
||||
var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl)
|
||||
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
|
||||
&& hEl.GetBoolean();
|
||||
string? tagname = null;
|
||||
if (doc.RootElement.TryGetProperty("historianTagname", out var nEl)
|
||||
&& nEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var raw = nEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(raw)) tagname = raw;
|
||||
}
|
||||
return (isHistorized, tagname);
|
||||
}
|
||||
catch (JsonException) { return (false, null); }
|
||||
}
|
||||
|
||||
/// <summary>Parses the optional array intent from a tag's <c>TagConfig</c> JSON: the <c>isArray</c>
|
||||
/// bool (absent / not a bool / non-object root / blank / malformed ⇒ <c>false</c>) and the optional
|
||||
/// <c>arrayLength</c> uint (honoured ONLY when <c>isArray</c> is true AND the prop is a JSON number
|
||||
/// that fits <c>uint</c>; else <c>null</c>). Mirrors <see cref="ExtractTagHistorize"/> in structure +
|
||||
/// null/blank/non-object/malformed-JSON tolerance. Never throws. The live-edit composer side
|
||||
/// (<c>AddressSpaceComposer.ExtractTagArray</c>) MUST parse identically (byte-parity).</summary>
|
||||
private static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
|
||||
var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl)
|
||||
&& (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
|
||||
&& aEl.GetBoolean();
|
||||
uint? arrayLength = null;
|
||||
if (isArray
|
||||
&& doc.RootElement.TryGetProperty("arrayLength", out var lEl)
|
||||
&& lEl.ValueKind == JsonValueKind.Number
|
||||
&& lEl.TryGetUInt32(out var len))
|
||||
{
|
||||
arrayLength = len;
|
||||
}
|
||||
return (isArray, arrayLength);
|
||||
}
|
||||
catch (JsonException) { return (false, null); }
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
||||
where T : class
|
||||
{
|
||||
@@ -819,7 +714,7 @@ public static class DeploymentArtifact
|
||||
|
||||
/// <summary>Build the <c>DeviceId</c> → connection-host map from the artifact's <c>Devices</c> array
|
||||
/// (each row carries a <c>DeviceId</c> + schemaless <c>DeviceConfig</c> JSON). The host is resolved via
|
||||
/// the shared <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> so the artifact-decode side
|
||||
/// the shared <see cref="DeviceConfigIntent.TryExtractHost"/> so the artifact-decode side
|
||||
/// normalizes byte-identically to the live-edit composer. Ordinal comparer + last-wins on a duplicate
|
||||
/// DeviceId. A missing/empty/non-array <c>Devices</c> property yields an empty map (no device hosts).</summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
@@ -834,7 +729,7 @@ public static class DeploymentArtifact
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) continue;
|
||||
map[deviceId!] = AddressSpaceComposer.TryExtractDeviceHost(ReadString(el, "DeviceConfig"));
|
||||
map[deviceId!] = DeviceConfigIntent.TryExtractHost(ReadString(el, "DeviceConfig"));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -728,7 +728,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// host folder) is warn-skipped — its nodes are NOT mis-grafted; the matched partitions still graft.</item>
|
||||
/// </list>
|
||||
/// The device-host folder segment AND the stored DeviceHost are both run through the SAME
|
||||
/// <see cref="AddressSpaceComposer.NormalizeDeviceHost"/> (single source of truth), so they compare equal
|
||||
/// <see cref="DeviceConfigIntent.NormalizeHost"/> (single source of truth), so they compare equal
|
||||
/// regardless of case/whitespace.
|
||||
/// <para><b>Warn-spam taming.</b> The unmatched/ambiguous/degenerate condition is warned ONCE then
|
||||
/// Debug-logged on the repeated re-discovery passes (see <see cref="ShouldWarnPartition"/>).</para>
|
||||
@@ -755,7 +755,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
if (!candidateSet.Contains(node.EquipmentId) || node.DeviceHost is null) continue;
|
||||
// DeviceHost is already normalized at compose/decode time; re-normalize through the shared helper so
|
||||
// the comparison is the single source of truth (idempotent — harmless if it was already normalized).
|
||||
var host = AddressSpaceComposer.NormalizeDeviceHost(node.DeviceHost);
|
||||
var host = DeviceConfigIntent.NormalizeHost(node.DeviceHost);
|
||||
if (ambiguousHosts.Contains(host)) continue;
|
||||
if (hostToEquipment.TryGetValue(host, out var existing))
|
||||
{
|
||||
@@ -789,7 +789,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
foreach (var n in msg.Nodes)
|
||||
{
|
||||
var key = n.FolderPathSegments.Count >= 2
|
||||
? AddressSpaceComposer.NormalizeDeviceHost(n.FolderPathSegments[1])
|
||||
? DeviceConfigIntent.NormalizeHost(n.FolderPathSegments[1])
|
||||
: null;
|
||||
if (key is not null && hostToEquipment.ContainsKey(key))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user