fix(historian): read-only historian bindings + ProvisionTags policy; provision OPC UA type names (#899, #898)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
#899 — EnsureTags is an upsert, so binding a node to an existing AVEVA tag via historianTagname re-typed/re-described it on every add and host start. - TagConfig `historianReadOnly: true` (TagConfigIntent -> RawTagPlan.HistorianReadOnly): HistoryRead from an existing historian tag that OtOpcUa never provisions and never records into (AddressSpaceApplier.IsOwnedHistorizedValue gates both the EnsureTags hook and the recorder feed). Node-inert: surgical, not mirrored on UNS refs, in the RawTagPlan equality/hash. A non-bool value is the deploy error HistorianReadOnlyInvalid. AdminUI: CSV column HistorianReadOnly (strict), TagHistorizeConfig reads via the runtime parse. - ServerHistorian:ProvisionTags = CreateOrUpdate (default, unchanged) | CreateOnly (Gateway only: TagExistsAsync first, existing tags left alone, failed lookup not sent) | Off. Unknown value / CreateOnly on a SQL backend fail host start. - Default for an explicit historianTagname stays OWNED: overrides pin owned history across renames, a read-only default would silently stop provisioning + recording. #898 — the hook parsed Tag.DataType (an OPC UA name) as DriverDataType, so historized Float/Double/Byte/SByte tags were never provisioned. New shared HistorianDataTypeNames.TryResolve: Float->Float32, Double->Float64, Byte/SByte->Int16 (lossless, provisioned as Int2); unmapped names logged at Information. Gateway now skips Int64/UInt64 (HistorianGateway cannot create Int8/UInt8: lossless-cast-or-reject). UPGRADE: the first host start after this provisions (upserts) every existing historized Float/Double/Byte/SByte tag. Mark foreign-tag bindings historianReadOnly, or set ServerHistorian:ProvisionTags=CreateOnly/Off, before upgrading (docs/Historian.md). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -42,7 +42,7 @@ public static class RawTagCsvMapper
|
||||
// Root TagConfig keys owned by the flag columns — stripped from the export residual so the
|
||||
// TagConfigJson fallback never duplicates a value already carried by a dedicated column.
|
||||
private static readonly string[] FlagKeys =
|
||||
["isHistorized", "historianTagname", "isArray", "arrayLength", "alarm"];
|
||||
["isHistorized", "historianTagname", "historianReadOnly", "isArray", "arrayLength", "alarm"];
|
||||
|
||||
// ---------------------------------------------------------------------------------------- Import
|
||||
|
||||
@@ -213,7 +213,8 @@ public static class RawTagCsvMapper
|
||||
|
||||
// Historize flags.
|
||||
var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized"));
|
||||
json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"));
|
||||
json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"),
|
||||
ParseFlag("HistorianReadOnly", Cell("HistorianReadOnly")));
|
||||
|
||||
// Array flags.
|
||||
var isArray = ParseFlag("IsArray", Cell("IsArray"));
|
||||
@@ -406,6 +407,7 @@ public static class RawTagCsvMapper
|
||||
tag.PollGroupId ?? "",
|
||||
hist.IsHistorized ? "true" : "",
|
||||
hist.HistorianTagname,
|
||||
hist.HistorianReadOnly ? "true" : "",
|
||||
array.IsArray ? "true" : "",
|
||||
array.ArrayLength?.ToString(CultureInfo.InvariantCulture) ?? "",
|
||||
alarm.IsAlarm ? alarm.AlarmType : "",
|
||||
|
||||
@@ -433,6 +433,7 @@ public static class CsvColumnMap
|
||||
"PollGroup",
|
||||
"IsHistorized",
|
||||
"HistorianTagname",
|
||||
"HistorianReadOnly",
|
||||
"IsArray",
|
||||
"ArrayLength",
|
||||
"Alarm.AlarmType",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Pure, driver-agnostic merge helper for the two server-side HistoryRead intent keys at the ROOT of a
|
||||
/// tag's <c>TagConfig</c> JSON: <c>isHistorized</c> (camelCase bool — omit/false default) and
|
||||
/// <c>historianTagname</c> (camelCase string override — omit when blank). These map to what the server's
|
||||
/// <c>AddressSpaceComposer.ExtractTagHistorize</c> reads (see <c>docs/Historian.md</c>).
|
||||
/// Pure, driver-agnostic merge helper for the server-side HistoryRead intent keys at the ROOT of a
|
||||
/// tag's <c>TagConfig</c> JSON: <c>isHistorized</c> (camelCase bool — omit/false default),
|
||||
/// <c>historianTagname</c> (camelCase string override — omit when blank) and <c>historianReadOnly</c>
|
||||
/// (lmxopcua#899 — camelCase bool, omit/false default: the history is read from an existing historian tag
|
||||
/// OtOpcUa never provisions or records into). These map to what the server's <see cref="TagConfigIntent"/>
|
||||
/// reads (see <c>docs/Historian.md</c>); <see cref="Read"/> uses that same parse, so what the AdminUI shows is
|
||||
/// what the runtime does.
|
||||
///
|
||||
/// <para>
|
||||
/// This is the TagModal-merge seam: the TagModal owns the canonical TagConfig JSON; the driver's typed
|
||||
@@ -17,10 +22,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// </summary>
|
||||
public static class TagHistorizeConfig
|
||||
{
|
||||
/// <summary>The two history-intent values parsed from a TagConfig JSON's root.</summary>
|
||||
/// <summary>The history-intent values parsed from a TagConfig JSON's root.</summary>
|
||||
/// <param name="IsHistorized">Whether the server exposes OPC UA HistoryRead over this tag's node.</param>
|
||||
/// <param name="HistorianTagname">Optional historian tagname override; <c>""</c> means default to the tag's FullName.</param>
|
||||
public readonly record struct HistorizeState(bool IsHistorized, string HistorianTagname);
|
||||
/// <param name="HistorianReadOnly">Whether the historian tag is a read-only binding OtOpcUa never provisions or records into.</param>
|
||||
public readonly record struct HistorizeState(bool IsHistorized, string HistorianTagname, bool HistorianReadOnly = false);
|
||||
|
||||
/// <summary>Reads the history-intent keys from a TagConfig JSON, defaulting any absent key
|
||||
/// (null/blank/malformed input ⇒ <c>(false, "")</c>).</summary>
|
||||
@@ -31,7 +37,9 @@ public static class TagHistorizeConfig
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new HistorizeState(
|
||||
TagConfigJson.GetBool(o, "isHistorized"),
|
||||
TagConfigJson.GetString(o, "historianTagname") ?? "");
|
||||
TagConfigJson.GetString(o, "historianTagname") ?? "",
|
||||
// The runtime's own parse (only a JSON true counts), not a lenient re-read.
|
||||
TagConfigIntent.Parse(json).HistorianReadOnly);
|
||||
}
|
||||
|
||||
/// <summary>Merges the two history-intent keys into <paramref name="json"/>, preserving every other
|
||||
@@ -41,13 +49,17 @@ public static class TagHistorizeConfig
|
||||
/// <param name="json">The tag's existing TagConfig JSON, or null/empty to start from a new object.</param>
|
||||
/// <param name="isHistorized">Whether the server should expose OPC UA HistoryRead over this tag's node.</param>
|
||||
/// <param name="historianTagname">Optional historian tagname override; null/blank omits the key.</param>
|
||||
/// <returns>The merged TagConfig JSON with the two history-intent keys applied.</returns>
|
||||
public static string Set(string? json, bool isHistorized, string? historianTagname)
|
||||
/// <param name="historianReadOnly"><see langword="true"/> writes <c>historianReadOnly: true</c>, <see langword="false"/>
|
||||
/// drops the key (absent ⇒ owned), <see langword="null"/> leaves whatever the JSON already carries untouched.</param>
|
||||
/// <returns>The merged TagConfig JSON with the history-intent keys applied.</returns>
|
||||
public static string Set(string? json, bool isHistorized, string? historianTagname, bool? historianReadOnly = null)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
TagConfigJson.Set(o, "isHistorized", isHistorized ? true : null);
|
||||
TagConfigJson.Set(o, "historianTagname",
|
||||
string.IsNullOrWhiteSpace(historianTagname) ? null : historianTagname.Trim());
|
||||
if (historianReadOnly is { } readOnly)
|
||||
TagConfigJson.Set(o, TagConfigIntent.HistorianReadOnlyKey, readOnly ? true : null);
|
||||
return TagConfigJson.Serialize(o);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,18 @@ public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase<Serve
|
||||
$"ServerHistorian:Backend '{options.Backend}' is not a known historian backend. "
|
||||
+ $"Allowed values: {string.Join(", ", HistorianBackendRegistry.KnownBackends)}.");
|
||||
|
||||
builder.RequireThat(
|
||||
ServerHistorianOptions.TryParseProvisionPolicy(options.ProvisionTags, out var provisionPolicy),
|
||||
$"ServerHistorian:ProvisionTags '{options.ProvisionTags}' is not a known provisioning policy. "
|
||||
+ $"Allowed values: {string.Join(", ", Enum.GetNames<HistorianProvisionPolicy>())}.");
|
||||
|
||||
// CreateOnly needs an existence lookup only the Gateway provisioner makes; the SQL backends' stores are
|
||||
// OtOpcUa's own tables (lmxopcua#899).
|
||||
builder.RequireThat(
|
||||
!options.Enabled || provisionPolicy != HistorianProvisionPolicy.CreateOnly || options.IsGatewayBackend,
|
||||
$"ServerHistorian:ProvisionTags=CreateOnly is supported only by the Gateway backend (Backend is '{options.Backend}'). "
|
||||
+ "Use CreateOrUpdate or Off, or opt individual tags out with historianReadOnly.");
|
||||
|
||||
var alarmHistorianEnabled = _configuration.GetValue<bool>("AlarmHistorian:Enabled");
|
||||
var consumed = options.Enabled || alarmHistorianEnabled;
|
||||
|
||||
|
||||
@@ -199,7 +199,13 @@ public sealed class AddressSpaceApplier
|
||||
// notifier wiring, and an alarm-bearing delta never reaches here — the classifier routes it
|
||||
// to Rebuild). So a delta whose ONLY difference is the referencing set needs NOTHING said to
|
||||
// the sink: compare the two sides with the referencing set copied across, and skip on equal.
|
||||
if ((d.Previous with { ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths }).Equals(d.Current))
|
||||
// HistorianReadOnly (lmxopcua#899) is node-inert the same way: it only steers the provisioning
|
||||
// hook and the recorder feed below, never the node's attributes.
|
||||
if ((d.Previous with
|
||||
{
|
||||
ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths,
|
||||
HistorianReadOnly = d.Current.HistorianReadOnly,
|
||||
}).Equals(d.Current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -621,6 +627,15 @@ public sealed class AddressSpaceApplier
|
||||
private static string ResolveHistorianName(RawTagPlan tag) =>
|
||||
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
|
||||
|
||||
/// <summary>
|
||||
/// A historized value tag (not a native-alarm condition) whose historian tag OtOpcUa OWNS — i.e. not a
|
||||
/// read-only binding (<see cref="RawTagPlan.HistorianReadOnly"/>, lmxopcua#899). Only these are provisioned
|
||||
/// (<c>EnsureTags</c>) and fed to the continuous-historization recorder; a read-only binding still serves
|
||||
/// HistoryRead from the existing historian tag, which OtOpcUa never creates, modifies or writes.
|
||||
/// </summary>
|
||||
private static bool IsOwnedHistorizedValue(RawTagPlan tag) =>
|
||||
tag.IsHistorized && tag.Alarm is null && !tag.HistorianReadOnly;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-provision the historian for the added (and newly-historized / renamed-historian) raw tags. Runs on the OPC UA
|
||||
/// publish actor's pinned thread, so the synchronous portion is kept to building the request
|
||||
@@ -639,7 +654,7 @@ public sealed class AddressSpaceApplier
|
||||
// and skip them.
|
||||
foreach (var tag in plan.AddedRawTags)
|
||||
{
|
||||
if (!tag.IsHistorized || tag.Alarm is not null) continue;
|
||||
if (!IsOwnedHistorizedValue(tag)) continue;
|
||||
AddRequest(tag);
|
||||
}
|
||||
|
||||
@@ -647,12 +662,14 @@ public sealed class AddressSpaceApplier
|
||||
// EXISTING raw tag never appears in AddedRawTags, so EnsureTags never fired for it. EnsureTags is
|
||||
// an upsert, so re-ensuring is safe; an unchanged pair is a no-op. Only raw deltas are used - a
|
||||
// UNS mirror delta must not double-provision the same historian tag.
|
||||
// A read-only binding (lmxopcua#899) is never provisioned; flipping read-only OFF counts as becoming
|
||||
// owned, so it provisions like a historization flip.
|
||||
foreach (var d in plan.ChangedRawTags)
|
||||
{
|
||||
var cur = d.Current;
|
||||
if (!cur.IsHistorized || cur.Alarm is not null) continue;
|
||||
if (!IsOwnedHistorizedValue(cur)) continue;
|
||||
var prev = d.Previous;
|
||||
var wasProvisioned = prev.IsHistorized && prev.Alarm is null;
|
||||
var wasProvisioned = IsOwnedHistorizedValue(prev);
|
||||
if (wasProvisioned
|
||||
&& ResolveHistorianName(prev) == ResolveHistorianName(cur)
|
||||
&& string.Equals(prev.DataType, cur.DataType, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -662,12 +679,15 @@ public sealed class AddressSpaceApplier
|
||||
|
||||
void AddRequest(RawTagPlan tag)
|
||||
{
|
||||
// An unparseable data type is skipped (logged at Debug) rather than faulting the hook.
|
||||
if (!Enum.TryParse<DriverDataType>(tag.DataType, ignoreCase: true, out var dataType))
|
||||
// Tag.DataType is an OPC UA built-in name (Float, Double, Byte, …); resolve it with the shared
|
||||
// map (lmxopcua#898 — a bare Enum.TryParse<DriverDataType> silently skipped Float/Double/Byte/SByte).
|
||||
// A type with no historian mapping (Guid, ByteString, …) is skipped rather than faulting the hook,
|
||||
// at Information so a historized tag that never provisions is visible without Debug logging.
|
||||
if (!HistorianDataTypeNames.TryResolve(tag.DataType, out var dataType))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"AddressSpaceApplier: skipping historian provisioning for a historized tag whose data type '{DataType}' is not a DriverDataType",
|
||||
tag.DataType);
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: not provisioning historized tag {Node}: data type '{DataType}' has no historian mapping",
|
||||
tag.NodeId, tag.DataType);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -786,12 +806,15 @@ public sealed class AddressSpaceApplier
|
||||
/// <c>HistorianName</c> = the value the EnsureTags hook / materialiser write under (a non-alarm
|
||||
/// historized value variable's <c>HistorianTagname</c> override, else its <c>FullName</c>). The
|
||||
/// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized
|
||||
/// value variable (not historized, or a native-alarm condition node).
|
||||
/// value variable OtOpcUa records (not historized, a native-alarm condition node, or a read-only binding,
|
||||
/// lmxopcua#899).
|
||||
/// </summary>
|
||||
/// <param name="tag">The raw tag to resolve a historized ref for.</param>
|
||||
/// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns>
|
||||
private static HistorizedTagRef? HistorizedRef(RawTagPlan tag) =>
|
||||
tag.IsHistorized && tag.Alarm is null
|
||||
// lmxopcua#899: a read-only binding is never recorded into — the recorder's WriteLiveValues would write
|
||||
// into a historian tag OtOpcUa does not own. A flip of the flag is a ref appearing/disappearing below.
|
||||
IsOwnedHistorizedValue(tag)
|
||||
// v3: the RawPath (== NodeId) is BOTH the mux ref (the driver fans values by RawPath) and the
|
||||
// default historian tagname; an override diverges only the historian name.
|
||||
? new HistorizedTagRef(
|
||||
|
||||
@@ -143,7 +143,7 @@ public static class AddressSpaceChangeClassifier
|
||||
}).Equals(d.Current);
|
||||
|
||||
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
|
||||
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable)
|
||||
// attributes (Writable / IsHistorized / HistorianTagname / HistorianReadOnly / DataType / IsArray / ArrayLength / ArrayWritable)
|
||||
// and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the
|
||||
// same AccessLevel + OnWriteValue swap Writable already drives in place — so it must be listed here or
|
||||
// every array-tag edit that flips it would fall through to a full rebuild. The referencing set is node-inert for a VALUE tag — it only drives native-alarm
|
||||
@@ -160,6 +160,9 @@ public static class AddressSpaceChangeClassifier
|
||||
Writable = d.Current.Writable,
|
||||
IsHistorized = d.Current.IsHistorized,
|
||||
HistorianTagname = d.Current.HistorianTagname,
|
||||
// lmxopcua#899: node-inert — the node serves HistoryRead the same either way; only the
|
||||
// provisioning hook and the recorder feed read it.
|
||||
HistorianReadOnly = d.Current.HistorianReadOnly,
|
||||
DataType = d.Current.DataType,
|
||||
IsArray = d.Current.IsArray,
|
||||
ArrayLength = d.Current.ArrayLength,
|
||||
|
||||
@@ -155,6 +155,11 @@ public sealed record RawContainerNode(
|
||||
/// every other tag. AVEVA stores the alarm's history under that reference's object, so the node manager adds the
|
||||
/// object to the event-history sources of every folder the condition fires on (Gitea #886). It is not in the
|
||||
/// surgical whitelist either: an alarm-bearing tag's delta rebuilds anyway.
|
||||
/// <see cref="HistorianReadOnly"/> (lmxopcua#899, <c>TagConfig.historianReadOnly</c>) marks a historized tag whose
|
||||
/// history is read from an existing historian tag OtOpcUa does not own: the node still serves HistoryRead, but the
|
||||
/// applier neither provisions the historian tag (<c>EnsureTags</c>) nor feeds it to the continuous-historization
|
||||
/// recorder. It is node-inert, so it is in the surgical whitelist, and a UNS reference node does not mirror it
|
||||
/// (UNS nodes neither provision nor record).
|
||||
/// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close
|
||||
/// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom
|
||||
/// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added
|
||||
@@ -184,7 +189,8 @@ public sealed record RawTagPlan(
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Raw,
|
||||
bool ArrayWritable = false,
|
||||
bool ScalarRefusesArrayValue = false,
|
||||
string? NativeAlarmReference = null)
|
||||
string? NativeAlarmReference = null,
|
||||
bool HistorianReadOnly = false)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
|
||||
@@ -209,6 +215,7 @@ public sealed record RawTagPlan(
|
||||
ArrayWritable == other.ArrayWritable &&
|
||||
ScalarRefusesArrayValue == other.ScalarRefusesArrayValue &&
|
||||
NativeAlarmReference == other.NativeAlarmReference &&
|
||||
HistorianReadOnly == other.HistorianReadOnly &&
|
||||
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -219,6 +226,7 @@ public sealed record RawTagPlan(
|
||||
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
|
||||
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
|
||||
hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(ScalarRefusesArrayValue); hash.Add(NativeAlarmReference);
|
||||
hash.Add(HistorianReadOnly);
|
||||
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
@@ -847,7 +855,8 @@ public static class AddressSpaceComposer
|
||||
ArrayLength: intent.ArrayLength,
|
||||
ArrayWritable: arrayWritable,
|
||||
ScalarRefusesArrayValue: scalarRefusesArray,
|
||||
NativeAlarmReference: nativeAlarmReference));
|
||||
NativeAlarmReference: nativeAlarmReference,
|
||||
HistorianReadOnly: intent.HistorianReadOnly));
|
||||
}
|
||||
|
||||
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||
|
||||
@@ -106,6 +106,41 @@ public sealed class ServerHistorianOptions
|
||||
/// </summary>
|
||||
public bool GatewayAtTimeFromRaw { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// What the tag provisioning hook may do to the historian's tags (lmxopcua#899): <c>CreateOrUpdate</c> (default —
|
||||
/// the behaviour before #899: <c>EnsureTags</c> is an upsert that creates a missing tag and re-types /
|
||||
/// re-describes an existing one), <c>CreateOnly</c> (<c>Gateway</c> backend only: each tag is looked up first and
|
||||
/// an EXISTING one is left exactly as it is; only missing tags are created) or <c>Off</c> (no provisioning at all;
|
||||
/// HistoryRead and continuous historization are unaffected). Case-insensitive; an unknown value fails host start.
|
||||
/// A tag's own <c>historianReadOnly: true</c> opts it out of provisioning (and recording) under every policy.
|
||||
/// </summary>
|
||||
public string ProvisionTags { get; init; } = nameof(HistorianProvisionPolicy.CreateOrUpdate);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ProvisionTags"/> parsed; an unknown value reads as <see cref="HistorianProvisionPolicy.CreateOrUpdate"/>
|
||||
/// here (the options validator fails host start on it first).
|
||||
/// </summary>
|
||||
public HistorianProvisionPolicy ProvisionPolicy =>
|
||||
TryParseProvisionPolicy(ProvisionTags, out var policy) ? policy : HistorianProvisionPolicy.CreateOrUpdate;
|
||||
|
||||
/// <summary>Parses a <see cref="ProvisionTags"/> value (case-insensitive, trimmed; blank ⇒ the default).</summary>
|
||||
/// <param name="value">The configured value.</param>
|
||||
/// <param name="policy">The parsed policy.</param>
|
||||
/// <returns><see langword="true"/> when the value names a policy (or is blank).</returns>
|
||||
public static bool TryParseProvisionPolicy(string? value, out HistorianProvisionPolicy policy)
|
||||
{
|
||||
policy = HistorianProvisionPolicy.CreateOrUpdate;
|
||||
var v = value?.Trim();
|
||||
if (string.IsNullOrEmpty(v)) return true;
|
||||
foreach (var candidate in Enum.GetValues<HistorianProvisionPolicy>())
|
||||
{
|
||||
if (!string.Equals(v, candidate.ToString(), StringComparison.OrdinalIgnoreCase)) continue;
|
||||
policy = candidate;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Gateway</c> backend only: the <c>ServerHistorian:Gateway</c> sub-section — knobs that exist because of the
|
||||
/// HistorianGateway's wire contract (see <see cref="GatewayHistorianSettings"/>). Ignored by the other backends.
|
||||
@@ -303,3 +338,16 @@ public sealed class GatewayHistorianSettings
|
||||
/// </summary>
|
||||
public TimeSpan ProbeInterval { get; init; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>What the historian tag provisioning hook may do (<see cref="ServerHistorianOptions.ProvisionTags"/>, lmxopcua#899).</summary>
|
||||
public enum HistorianProvisionPolicy
|
||||
{
|
||||
/// <summary>Create a missing tag and update (re-type, re-describe) an existing one — the upsert, and the default.</summary>
|
||||
CreateOrUpdate,
|
||||
|
||||
/// <summary>Create a missing tag; never touch an existing one (<c>Gateway</c> backend only).</summary>
|
||||
CreateOnly,
|
||||
|
||||
/// <summary>No provisioning.</summary>
|
||||
Off,
|
||||
}
|
||||
|
||||
@@ -283,6 +283,8 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>();
|
||||
if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime
|
||||
// ServerHistorian:ProvisionTags=Off (lmxopcua#899): keep the Null default — reads and recording are unaffected.
|
||||
if (opts.ProvisionPolicy == HistorianProvisionPolicy.Off) return services;
|
||||
|
||||
// Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime.
|
||||
services.AddSingleton<IHistorianProvisioning>(sp => provisioningFactory(opts, sp));
|
||||
|
||||
Reference in New Issue
Block a user