feat(adminui): isHistorized + historianTagname as first-class Tag fields
This commit is contained in:
-11
@@ -6,17 +6,6 @@
|
|||||||
placeholder="Reactor1.Temperature"
|
placeholder="Reactor1.Temperature"
|
||||||
@onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
|
@onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
|
||||||
<div class="form-text">The AVEVA Historian tagname the driver reads against.</div></div>
|
<div class="form-text">The AVEVA Historian tagname the driver reads against.</div></div>
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input type="checkbox" class="form-check-input" checked="@_m.IsHistorized"
|
|
||||||
@onchange="@(e => Update(() => _m.IsHistorized = e.Value is true))" />
|
|
||||||
<label class="form-check-label">Historized (expose OPC UA HistoryRead)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-12"><label class="form-label">Historian tagname override (optional)</label>
|
|
||||||
<input type="text" class="form-control form-control-sm mono" value="@_m.HistorianTagname"
|
|
||||||
@onchange="@(e => Update(() => _m.HistorianTagname = e.Value?.ToString() ?? string.Empty))" />
|
|
||||||
<div class="form-text">Blank defaults the historian tagname to the FullName above.</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
|||||||
-11
@@ -6,17 +6,6 @@
|
|||||||
placeholder="nsu=urn:server:ns;s=Line3.Temp"
|
placeholder="nsu=urn:server:ns;s=Line3.Temp"
|
||||||
@onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
|
@onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
|
||||||
<div class="form-text">The remote OPC UA NodeId the driver reads/writes/subscribes against. Use the browse picker on the driver page to find it.</div></div>
|
<div class="form-text">The remote OPC UA NodeId the driver reads/writes/subscribes against. Use the browse picker on the driver page to find it.</div></div>
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input type="checkbox" class="form-check-input" checked="@_m.IsHistorized"
|
|
||||||
@onchange="@(e => Update(() => _m.IsHistorized = e.Value is true))" />
|
|
||||||
<label class="form-check-label">Historized (expose OPC UA HistoryRead)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-12"><label class="form-label">Historian tagname override (optional)</label>
|
|
||||||
<input type="text" class="form-control form-control-sm mono" value="@_m.HistorianTagname"
|
|
||||||
@onchange="@(e => Update(() => _m.HistorianTagname = e.Value?.ToString() ?? string.Empty))" />
|
|
||||||
<div class="form-text">Blank defaults the historian tagname to the FullName above.</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
|||||||
@@ -136,6 +136,39 @@
|
|||||||
<ValidationMessage For="@(() => _form.TagConfig)" />
|
<ValidationMessage For="@(() => _form.TagConfig)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* Driver-agnostic server-side HistoryRead intent. Distinct from the native-alarm
|
||||||
|
"Historize to AVEVA" toggle below: THIS gates TAG-VALUE history (root keys
|
||||||
|
`isHistorized` / `historianTagname`, read by Phase7Composer.ExtractTagHistorize),
|
||||||
|
merged onto the canonical TagConfig via the pure TagHistorizeConfig seam so it
|
||||||
|
composes with the typed editor's driver-specific fields (both preserve unknown keys).
|
||||||
|
Shown for EVERY driver once one is picked. *@
|
||||||
|
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">History</label>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tag-historize"
|
||||||
|
checked="@_historizeState.IsHistorized"
|
||||||
|
@onchange="OnHistorizeChanged" />
|
||||||
|
<label class="form-check-label" for="tag-historize">Historize this tag (expose OPC UA HistoryRead)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
When checked, the server serves OPC UA HistoryRead over this tag's value
|
||||||
|
from the configured historian.
|
||||||
|
</div>
|
||||||
|
@if (_historizeState.IsHistorized)
|
||||||
|
{
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="form-label" for="tag-historian-tagname">Historian tagname (override, optional)</label>
|
||||||
|
<input type="text" class="form-control form-control-sm mono" id="tag-historian-tagname"
|
||||||
|
value="@_historizeState.HistorianTagname"
|
||||||
|
@onchange="OnHistorianTagnameChanged" />
|
||||||
|
<div class="form-text">Blank defaults the historian tagname to this tag's driver FullName.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
|
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
|
||||||
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
|
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
|
||||||
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
|
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
|
||||||
@@ -211,6 +244,11 @@
|
|||||||
private bool _showGalaxyPicker;
|
private bool _showGalaxyPicker;
|
||||||
private string _galaxyAddress = "";
|
private string _galaxyAddress = "";
|
||||||
|
|
||||||
|
// Driver-agnostic server-side HistoryRead intent (root `isHistorized` / `historianTagname`), reflected
|
||||||
|
// for the "Historize this tag" controls. Re-read from _form.TagConfig whenever the modal (re)opens or
|
||||||
|
// the driver changes; the change handlers merge it back onto _form.TagConfig via TagHistorizeConfig.
|
||||||
|
private TagHistorizeConfig.HistorizeState _historizeState;
|
||||||
|
|
||||||
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
|
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
|
||||||
private string? SelectedDriverType =>
|
private string? SelectedDriverType =>
|
||||||
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
|
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
|
||||||
@@ -238,6 +276,8 @@
|
|||||||
_form.TagConfig = "{}";
|
_form.TagConfig = "{}";
|
||||||
// The Galaxy reference belongs to the previous driver; clear the picker's working address too.
|
// The Galaxy reference belongs to the previous driver; clear the picker's working address too.
|
||||||
_galaxyAddress = "";
|
_galaxyAddress = "";
|
||||||
|
// The reset TagConfig carries no history intent — reflect that in the historize controls.
|
||||||
|
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
|
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
|
||||||
@@ -255,14 +295,32 @@
|
|||||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed
|
||||||
|
// properties below don't each re-parse the JSON on every render. Re-parsed only when TagConfig changes.
|
||||||
|
private NativeAlarmModel _nativeAlarm = NativeAlarmModel.FromJson("{}");
|
||||||
|
private string? _nativeAlarmSource;
|
||||||
|
|
||||||
|
private NativeAlarmModel NativeAlarm
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_nativeAlarmSource != _form.TagConfig)
|
||||||
|
{
|
||||||
|
_nativeAlarmSource = _form.TagConfig;
|
||||||
|
_nativeAlarm = NativeAlarmModel.FromJson(_form.TagConfig);
|
||||||
|
}
|
||||||
|
return _nativeAlarm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9
|
// True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9
|
||||||
// native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility.
|
// native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility.
|
||||||
private bool HasNativeAlarm => NativeAlarmModel.FromJson(_form.TagConfig).IsAlarm;
|
private bool HasNativeAlarm => NativeAlarm.IsAlarm;
|
||||||
|
|
||||||
// The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize
|
// The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize
|
||||||
// (default-on at the server gate), so the box is checked for both null and explicit true; only an
|
// (default-on at the server gate), so the box is checked for both null and explicit true; only an
|
||||||
// explicit false leaves it unchecked.
|
// explicit false leaves it unchecked.
|
||||||
private bool AlarmHistorizeToAveva => NativeAlarmModel.FromJson(_form.TagConfig).HistorizeToAveva != false;
|
private bool AlarmHistorizeToAveva => NativeAlarm.HistorizeToAveva != false;
|
||||||
|
|
||||||
// Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒
|
// Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒
|
||||||
// absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row).
|
// absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row).
|
||||||
@@ -271,11 +329,28 @@
|
|||||||
{
|
{
|
||||||
var model = NativeAlarmModel.FromJson(_form.TagConfig);
|
var model = NativeAlarmModel.FromJson(_form.TagConfig);
|
||||||
if (!model.IsAlarm) { return; }
|
if (!model.IsAlarm) { return; }
|
||||||
var isChecked = e.Value is bool b && b;
|
model.HistorizeToAveva = e.Value is true ? null : false;
|
||||||
model.HistorizeToAveva = isChecked ? null : false;
|
|
||||||
_form.TagConfig = model.ToJson();
|
_form.TagConfig = model.ToJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle the root `isHistorized` tag-value history intent in the raw TagConfig, merged via the pure
|
||||||
|
// TagHistorizeConfig seam so the typed editor's driver-specific keys are preserved. Distinct from the
|
||||||
|
// native-alarm "Historize to AVEVA" opt-out above (that gates alarm-transition history, not tag values).
|
||||||
|
private void OnHistorizeChanged(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
var isHistorized = e.Value is true;
|
||||||
|
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, isHistorized, _historizeState.HistorianTagname);
|
||||||
|
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the optional historian-tagname override (root `historianTagname`) into the raw TagConfig.
|
||||||
|
private void OnHistorianTagnameChanged(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
var tagname = e.Value?.ToString() ?? string.Empty;
|
||||||
|
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, _historizeState.IsHistorized, tagname);
|
||||||
|
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
|
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
|
||||||
@@ -301,6 +376,8 @@
|
|||||||
_showGalaxyPicker = false;
|
_showGalaxyPicker = false;
|
||||||
// Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated.
|
// Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated.
|
||||||
_galaxyAddress = ReadFullName(_form.TagConfig);
|
_galaxyAddress = ReadFullName(_form.TagConfig);
|
||||||
|
// Seed the historize controls from any existing root isHistorized/historianTagname keys.
|
||||||
|
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
|
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
|
||||||
|
|||||||
+7
-20
@@ -4,26 +4,20 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
|||||||
|
|
||||||
/// <summary>Typed working model for a Wonderware (AVEVA) Historian equipment tag's TagConfig JSON. The
|
/// <summary>Typed working model for a Wonderware (AVEVA) Historian equipment tag's TagConfig JSON. The
|
||||||
/// tag binds to a historian tag by its full reference (<c>FullName</c> — the historian tagname/source
|
/// tag binds to a historian tag by its full reference (<c>FullName</c> — the historian tagname/source
|
||||||
/// the driver reads against), plus the optional driver-agnostic server-side HistoryRead intent
|
/// the driver reads against). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||||
/// (<c>isHistorized</c> / <c>historianTagname</c>). Preserves unrecognised JSON keys across a load→save.</summary>
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
|
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
|
||||||
/// (<c>Phase7Composer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
|
/// (<c>Phase7Composer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
|
||||||
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
|
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
|
||||||
/// casing. The history keys (<c>isHistorized</c> / <c>historianTagname</c>) are camelCase to match
|
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
|
||||||
/// <c>Phase7Composer.ExtractTagHistorize</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.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class HistorianWonderwareTagConfigModel
|
public sealed class HistorianWonderwareTagConfigModel
|
||||||
{
|
{
|
||||||
/// <summary>Historian tagname/source the tag binds to (the driver-side full reference). Required.</summary>
|
/// <summary>Historian tagname/source the tag binds to (the driver-side full reference). Required.</summary>
|
||||||
public string FullName { get; set; } = "";
|
public string FullName { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>Whether the server exposes OPC UA HistoryRead over this tag's variable node.</summary>
|
|
||||||
public bool IsHistorized { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Optional historian tagname override; blank means the historian tagname defaults to <see cref="FullName"/>.</summary>
|
|
||||||
public string HistorianTagname { get; set; } = "";
|
|
||||||
|
|
||||||
private JsonObject _bag = new();
|
private JsonObject _bag = new();
|
||||||
|
|
||||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||||
@@ -35,24 +29,17 @@ public sealed class HistorianWonderwareTagConfigModel
|
|||||||
return new HistorianWonderwareTagConfigModel
|
return new HistorianWonderwareTagConfigModel
|
||||||
{
|
{
|
||||||
FullName = TagConfigJson.GetString(o, "FullName") ?? "",
|
FullName = TagConfigJson.GetString(o, "FullName") ?? "",
|
||||||
IsHistorized = TagConfigJson.GetBool(o, "isHistorized"),
|
|
||||||
HistorianTagname = TagConfigJson.GetString(o, "historianTagname") ?? "",
|
|
||||||
_bag = o,
|
_bag = o,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
|
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
|
||||||
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); the history keys are
|
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); any history keys merged
|
||||||
/// written camelCase and dropped when default (absent <c>isHistorized</c> ⇒ false at the composer;
|
/// by the TagModal (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as
|
||||||
/// blank <c>historianTagname</c> ⇒ defaults to <c>FullName</c>).</summary>
|
/// preserved unknown keys.</summary>
|
||||||
public string ToJson()
|
public string ToJson()
|
||||||
{
|
{
|
||||||
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
|
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
|
||||||
// Drop isHistorized when false so the persisted blob stays minimal and matches the
|
|
||||||
// composer's "absent ⇒ false" convention; same for a blank historianTagname override.
|
|
||||||
TagConfigJson.Set(_bag, "isHistorized", IsHistorized ? true : null);
|
|
||||||
TagConfigJson.Set(_bag, "historianTagname",
|
|
||||||
string.IsNullOrWhiteSpace(HistorianTagname) ? null : HistorianTagname.Trim());
|
|
||||||
return TagConfigJson.Serialize(_bag);
|
return TagConfigJson.Serialize(_bag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,27 +4,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
|||||||
|
|
||||||
/// <summary>Typed working model for an OPC UA Client (gateway) equipment tag's TagConfig JSON. The tag
|
/// <summary>Typed working model for an OPC UA Client (gateway) equipment tag's TagConfig JSON. The tag
|
||||||
/// is bound to the upstream OPC UA server node by its full reference (<c>FullName</c> — the persisted
|
/// is bound to the upstream OPC UA server node by its full reference (<c>FullName</c> — the persisted
|
||||||
/// stable <c>nsu=…;s=…</c> or plain <c>ns=2;s=…</c> NodeId the driver reads/writes/subscribes against),
|
/// stable <c>nsu=…;s=…</c> or plain <c>ns=2;s=…</c> NodeId the driver reads/writes/subscribes against).
|
||||||
/// plus the optional driver-agnostic server-side HistoryRead intent (<c>isHistorized</c> /
|
/// Preserves unrecognised JSON keys across a load→save.</summary>
|
||||||
/// <c>historianTagname</c>). Preserves unrecognised JSON keys across a load→save.</summary>
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
|
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
|
||||||
/// (<c>Phase7Composer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
|
/// (<c>Phase7Composer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
|
||||||
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
|
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
|
||||||
/// casing. The history keys (<c>isHistorized</c> / <c>historianTagname</c>) are camelCase to match
|
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
|
||||||
/// <c>Phase7Composer.ExtractTagHistorize</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.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class OpcUaClientTagConfigModel
|
public sealed class OpcUaClientTagConfigModel
|
||||||
{
|
{
|
||||||
/// <summary>Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.</summary>
|
/// <summary>Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.</summary>
|
||||||
public string FullName { get; set; } = "";
|
public string FullName { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>Whether the server exposes OPC UA HistoryRead over this tag's variable node.</summary>
|
|
||||||
public bool IsHistorized { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Optional historian tagname override; blank means the historian tagname defaults to <see cref="FullName"/>.</summary>
|
|
||||||
public string HistorianTagname { get; set; } = "";
|
|
||||||
|
|
||||||
private JsonObject _bag = new();
|
private JsonObject _bag = new();
|
||||||
|
|
||||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||||
@@ -36,24 +30,17 @@ public sealed class OpcUaClientTagConfigModel
|
|||||||
return new OpcUaClientTagConfigModel
|
return new OpcUaClientTagConfigModel
|
||||||
{
|
{
|
||||||
FullName = TagConfigJson.GetString(o, "FullName") ?? "",
|
FullName = TagConfigJson.GetString(o, "FullName") ?? "",
|
||||||
IsHistorized = TagConfigJson.GetBool(o, "isHistorized"),
|
|
||||||
HistorianTagname = TagConfigJson.GetString(o, "historianTagname") ?? "",
|
|
||||||
_bag = o,
|
_bag = o,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
|
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
|
||||||
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); the history keys are
|
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); any history keys merged
|
||||||
/// written camelCase and dropped when default (absent <c>isHistorized</c> ⇒ false at the composer;
|
/// by the TagModal (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as
|
||||||
/// blank <c>historianTagname</c> ⇒ defaults to <c>FullName</c>).</summary>
|
/// preserved unknown keys.</summary>
|
||||||
public string ToJson()
|
public string ToJson()
|
||||||
{
|
{
|
||||||
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
|
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
|
||||||
// Drop isHistorized when false so the persisted blob stays minimal and matches the
|
|
||||||
// composer's "absent ⇒ false" convention; same for a blank historianTagname override.
|
|
||||||
TagConfigJson.Set(_bag, "isHistorized", IsHistorized ? true : null);
|
|
||||||
TagConfigJson.Set(_bag, "historianTagname",
|
|
||||||
string.IsNullOrWhiteSpace(HistorianTagname) ? null : HistorianTagname.Trim());
|
|
||||||
return TagConfigJson.Serialize(_bag);
|
return TagConfigJson.Serialize(_bag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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>Phase7Composer.ExtractTagHistorize</c> reads (see <c>docs/Historian.md</c>).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// This is the TagModal-merge seam: the TagModal owns the canonical TagConfig JSON; the driver's typed
|
||||||
|
/// editor reads/writes its driver-specific fields on that same JSON, and the modal's "Historize this tag"
|
||||||
|
/// checkbox + "Historian tagname (override)" textbox read/write these two keys through this helper. They
|
||||||
|
/// COMPOSE because both sides preserve every unrecognised key (delegated to
|
||||||
|
/// <see cref="TagConfigJson.ParseOrNew"/> / <see cref="TagConfigJson.Set"/>), so neither clobbers the
|
||||||
|
/// other's fields. These keys are intentionally NOT carried inside any per-driver typed model.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class TagHistorizeConfig
|
||||||
|
{
|
||||||
|
/// <summary>The two 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);
|
||||||
|
|
||||||
|
/// <summary>Reads the history-intent keys from a TagConfig JSON, defaulting any absent key
|
||||||
|
/// (null/blank/malformed input ⇒ <c>(false, "")</c>).</summary>
|
||||||
|
public static HistorizeState Read(string? json)
|
||||||
|
{
|
||||||
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
return new HistorizeState(
|
||||||
|
TagConfigJson.GetBool(o, "isHistorized"),
|
||||||
|
TagConfigJson.GetString(o, "historianTagname") ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Merges the two history-intent keys into <paramref name="json"/>, preserving every other
|
||||||
|
/// (driver-specific or unknown) key. <c>isHistorized</c> is dropped when false (absent ⇒ false at the
|
||||||
|
/// composer); <c>historianTagname</c> is dropped when null/blank (absent ⇒ defaults to FullName) and
|
||||||
|
/// trimmed otherwise.</summary>
|
||||||
|
public static string Set(string? json, bool isHistorized, string? historianTagname)
|
||||||
|
{
|
||||||
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
TagConfigJson.Set(o, "isHistorized", isHistorized ? true : null);
|
||||||
|
TagConfigJson.Set(o, "historianTagname",
|
||||||
|
string.IsNullOrWhiteSpace(historianTagname) ? null : historianTagname.Trim());
|
||||||
|
return TagConfigJson.Serialize(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-35
@@ -16,66 +16,38 @@ public sealed class HistorianWonderwareTagConfigModelTests
|
|||||||
var m = HistorianWonderwareTagConfigModel.FromJson(json);
|
var m = HistorianWonderwareTagConfigModel.FromJson(json);
|
||||||
|
|
||||||
m.FullName.ShouldBe("");
|
m.FullName.ShouldBe("");
|
||||||
m.IsHistorized.ShouldBeFalse();
|
|
||||||
m.HistorianTagname.ShouldBe("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FromJson_reads_all_fields()
|
public void FromJson_reads_FullName()
|
||||||
{
|
{
|
||||||
var m = HistorianWonderwareTagConfigModel.FromJson(
|
var m = HistorianWonderwareTagConfigModel.FromJson(
|
||||||
"""{"FullName":"SysTimeSec","isHistorized":true,"historianTagname":"Reactor1.Temp"}""");
|
"""{"FullName":"Reactor1.Temp"}""");
|
||||||
|
|
||||||
m.FullName.ShouldBe("SysTimeSec");
|
m.FullName.ShouldBe("Reactor1.Temp");
|
||||||
m.IsHistorized.ShouldBeTrue();
|
|
||||||
m.HistorianTagname.ShouldBe("Reactor1.Temp");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Round_trip_preserves_all_fields()
|
public void Round_trip_preserves_FullName()
|
||||||
{
|
{
|
||||||
var m = new HistorianWonderwareTagConfigModel
|
var m = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" };
|
||||||
{
|
|
||||||
FullName = "Reactor1.Temp",
|
|
||||||
IsHistorized = true,
|
|
||||||
HistorianTagname = "Reactor1.Temp.Override",
|
|
||||||
};
|
|
||||||
|
|
||||||
var json = m.ToJson();
|
var json = m.ToJson();
|
||||||
var m2 = HistorianWonderwareTagConfigModel.FromJson(json);
|
var m2 = HistorianWonderwareTagConfigModel.FromJson(json);
|
||||||
|
|
||||||
m2.FullName.ShouldBe("Reactor1.Temp");
|
m2.FullName.ShouldBe("Reactor1.Temp");
|
||||||
m2.IsHistorized.ShouldBeTrue();
|
|
||||||
m2.HistorianTagname.ShouldBe("Reactor1.Temp.Override");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToJson_emits_PascalCase_FullName_and_camelCase_history_keys()
|
public void ToJson_emits_PascalCase_FullName()
|
||||||
{
|
{
|
||||||
var m = new HistorianWonderwareTagConfigModel
|
var m = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" };
|
||||||
{
|
|
||||||
FullName = "Reactor1.Temp",
|
|
||||||
IsHistorized = true,
|
|
||||||
HistorianTagname = "Reactor1.Temp.Override",
|
|
||||||
};
|
|
||||||
|
|
||||||
var json = m.ToJson();
|
var json = m.ToJson();
|
||||||
|
|
||||||
// FullName is the composer/walker contract key — PascalCase, case-sensitive.
|
// FullName is the composer/walker contract key — PascalCase, case-sensitive.
|
||||||
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
||||||
json.ShouldNotContain("\"fullName\"", Case.Sensitive);
|
json.ShouldNotContain("\"fullName\"", Case.Sensitive);
|
||||||
json.ShouldContain("\"isHistorized\":true");
|
|
||||||
json.ShouldContain("\"historianTagname\":\"Reactor1.Temp.Override\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToJson_omits_history_keys_when_default()
|
|
||||||
{
|
|
||||||
var json = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" }.ToJson();
|
|
||||||
|
|
||||||
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
|
||||||
json.ShouldNotContain("isHistorized");
|
|
||||||
json.ShouldNotContain("historianTagname");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -91,6 +63,20 @@ public sealed class HistorianWonderwareTagConfigModelTests
|
|||||||
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_then_ToJson_preserves_TagModal_merged_history_keys()
|
||||||
|
{
|
||||||
|
// The TagModal-merge seam writes isHistorized/historianTagname at the TagConfig root; this model
|
||||||
|
// does NOT model them, so they must survive a load→save untouched as preserved unknown keys.
|
||||||
|
var json = HistorianWonderwareTagConfigModel
|
||||||
|
.FromJson("""{"FullName":"Reactor1.Temp","isHistorized":true,"historianTagname":"Reactor1.Temp.Override"}""")
|
||||||
|
.ToJson();
|
||||||
|
|
||||||
|
json.ShouldContain("\"isHistorized\":true");
|
||||||
|
json.ShouldContain("\"historianTagname\":\"Reactor1.Temp.Override\"");
|
||||||
|
json.ShouldContain("\"FullName\":\"Reactor1.Temp\"");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToJson_trims_FullName()
|
public void ToJson_trims_FullName()
|
||||||
{
|
{
|
||||||
|
|||||||
+20
-34
@@ -16,66 +16,38 @@ public sealed class OpcUaClientTagConfigModelTests
|
|||||||
var m = OpcUaClientTagConfigModel.FromJson(json);
|
var m = OpcUaClientTagConfigModel.FromJson(json);
|
||||||
|
|
||||||
m.FullName.ShouldBe("");
|
m.FullName.ShouldBe("");
|
||||||
m.IsHistorized.ShouldBeFalse();
|
|
||||||
m.HistorianTagname.ShouldBe("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FromJson_reads_all_fields()
|
public void FromJson_reads_FullName()
|
||||||
{
|
{
|
||||||
var m = OpcUaClientTagConfigModel.FromJson(
|
var m = OpcUaClientTagConfigModel.FromJson(
|
||||||
"""{"FullName":"nsu=urn:srv;s=Line3.Temp","isHistorized":true,"historianTagname":"Line3_Temp"}""");
|
"""{"FullName":"nsu=urn:srv;s=Line3.Temp"}""");
|
||||||
|
|
||||||
m.FullName.ShouldBe("nsu=urn:srv;s=Line3.Temp");
|
m.FullName.ShouldBe("nsu=urn:srv;s=Line3.Temp");
|
||||||
m.IsHistorized.ShouldBeTrue();
|
|
||||||
m.HistorianTagname.ShouldBe("Line3_Temp");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Round_trip_preserves_all_fields()
|
public void Round_trip_preserves_FullName()
|
||||||
{
|
{
|
||||||
var m = new OpcUaClientTagConfigModel
|
var m = new OpcUaClientTagConfigModel { FullName = "ns=2;s=Line3.Temp" };
|
||||||
{
|
|
||||||
FullName = "ns=2;s=Line3.Temp",
|
|
||||||
IsHistorized = true,
|
|
||||||
HistorianTagname = "Line3_Temp",
|
|
||||||
};
|
|
||||||
|
|
||||||
var json = m.ToJson();
|
var json = m.ToJson();
|
||||||
var m2 = OpcUaClientTagConfigModel.FromJson(json);
|
var m2 = OpcUaClientTagConfigModel.FromJson(json);
|
||||||
|
|
||||||
m2.FullName.ShouldBe("ns=2;s=Line3.Temp");
|
m2.FullName.ShouldBe("ns=2;s=Line3.Temp");
|
||||||
m2.IsHistorized.ShouldBeTrue();
|
|
||||||
m2.HistorianTagname.ShouldBe("Line3_Temp");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToJson_emits_PascalCase_FullName_and_camelCase_history_keys()
|
public void ToJson_emits_PascalCase_FullName()
|
||||||
{
|
{
|
||||||
var m = new OpcUaClientTagConfigModel
|
var m = new OpcUaClientTagConfigModel { FullName = "ns=2;s=Line3.Temp" };
|
||||||
{
|
|
||||||
FullName = "ns=2;s=Line3.Temp",
|
|
||||||
IsHistorized = true,
|
|
||||||
HistorianTagname = "Line3_Temp",
|
|
||||||
};
|
|
||||||
|
|
||||||
var json = m.ToJson();
|
var json = m.ToJson();
|
||||||
|
|
||||||
// FullName is the composer/walker contract key — PascalCase, case-sensitive.
|
// FullName is the composer/walker contract key — PascalCase, case-sensitive.
|
||||||
json.ShouldContain("\"FullName\":\"ns=2;s=Line3.Temp\"");
|
json.ShouldContain("\"FullName\":\"ns=2;s=Line3.Temp\"");
|
||||||
json.ShouldNotContain("\"fullName\"", Case.Sensitive);
|
json.ShouldNotContain("\"fullName\"", Case.Sensitive);
|
||||||
json.ShouldContain("\"isHistorized\":true");
|
|
||||||
json.ShouldContain("\"historianTagname\":\"Line3_Temp\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToJson_omits_history_keys_when_default()
|
|
||||||
{
|
|
||||||
var json = new OpcUaClientTagConfigModel { FullName = "ns=2;s=X" }.ToJson();
|
|
||||||
|
|
||||||
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
|
|
||||||
json.ShouldNotContain("isHistorized");
|
|
||||||
json.ShouldNotContain("historianTagname");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -91,6 +63,20 @@ public sealed class OpcUaClientTagConfigModelTests
|
|||||||
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
|
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_then_ToJson_preserves_TagModal_merged_history_keys()
|
||||||
|
{
|
||||||
|
// The TagModal-merge seam writes isHistorized/historianTagname at the TagConfig root; this model
|
||||||
|
// does NOT model them, so they must survive a load→save untouched as preserved unknown keys.
|
||||||
|
var json = OpcUaClientTagConfigModel
|
||||||
|
.FromJson("""{"FullName":"ns=2;s=X","isHistorized":true,"historianTagname":"Line3_Temp"}""")
|
||||||
|
.ToJson();
|
||||||
|
|
||||||
|
json.ShouldContain("\"isHistorized\":true");
|
||||||
|
json.ShouldContain("\"historianTagname\":\"Line3_Temp\"");
|
||||||
|
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToJson_trims_FullName()
|
public void ToJson_trims_FullName()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the driver-agnostic <see cref="TagHistorizeConfig"/> merge helper — the pure seam the
|
||||||
|
/// TagModal uses to read/write the root <c>isHistorized</c> / <c>historianTagname</c> history keys on the
|
||||||
|
/// canonical TagConfig JSON WITHOUT disturbing the driver's own (typed-editor) fields. Both this helper and
|
||||||
|
/// every typed editor preserve unknown keys, so they compose over the same JSON blob.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TagHistorizeConfigTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("{}")]
|
||||||
|
[InlineData("not json")]
|
||||||
|
public void Read_returns_defaults_for_empty_or_malformed(string? json)
|
||||||
|
{
|
||||||
|
var h = TagHistorizeConfig.Read(json);
|
||||||
|
|
||||||
|
h.IsHistorized.ShouldBeFalse();
|
||||||
|
h.HistorianTagname.ShouldBe("");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_reads_both_keys()
|
||||||
|
{
|
||||||
|
var h = TagHistorizeConfig.Read("""{"FullName":"ns=2;s=X","isHistorized":true,"historianTagname":"Line3_Temp"}""");
|
||||||
|
|
||||||
|
h.IsHistorized.ShouldBeTrue();
|
||||||
|
h.HistorianTagname.ShouldBe("Line3_Temp");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Set_true_with_tagname_writes_both_camelCase_keys()
|
||||||
|
{
|
||||||
|
var json = TagHistorizeConfig.Set("""{"FullName":"ns=2;s=X"}""", isHistorized: true, historianTagname: "Line3_Temp");
|
||||||
|
|
||||||
|
json.ShouldContain("\"isHistorized\":true");
|
||||||
|
json.ShouldContain("\"historianTagname\":\"Line3_Temp\"");
|
||||||
|
// The driver field is untouched.
|
||||||
|
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Set_true_round_trips_through_Read()
|
||||||
|
{
|
||||||
|
var json = TagHistorizeConfig.Set("{}", isHistorized: true, historianTagname: "Reactor1.Temp");
|
||||||
|
|
||||||
|
var h = TagHistorizeConfig.Read(json);
|
||||||
|
h.IsHistorized.ShouldBeTrue();
|
||||||
|
h.HistorianTagname.ShouldBe("Reactor1.Temp");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Set_false_removes_isHistorized_key()
|
||||||
|
{
|
||||||
|
var json = TagHistorizeConfig.Set(
|
||||||
|
"""{"FullName":"ns=2;s=X","isHistorized":true,"historianTagname":"Line3_Temp"}""",
|
||||||
|
isHistorized: false, historianTagname: "Line3_Temp");
|
||||||
|
|
||||||
|
json.ShouldNotContain("isHistorized");
|
||||||
|
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Set_blank_tagname_removes_historianTagname_key()
|
||||||
|
{
|
||||||
|
var json = TagHistorizeConfig.Set(
|
||||||
|
"""{"isHistorized":true,"historianTagname":"Old"}""",
|
||||||
|
isHistorized: true, historianTagname: " ");
|
||||||
|
|
||||||
|
json.ShouldContain("\"isHistorized\":true");
|
||||||
|
json.ShouldNotContain("historianTagname");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Set_null_tagname_removes_historianTagname_key()
|
||||||
|
{
|
||||||
|
var json = TagHistorizeConfig.Set(
|
||||||
|
"""{"isHistorized":true,"historianTagname":"Old"}""",
|
||||||
|
isHistorized: true, historianTagname: null);
|
||||||
|
|
||||||
|
json.ShouldContain("\"isHistorized\":true");
|
||||||
|
json.ShouldNotContain("historianTagname");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Set_trims_historianTagname()
|
||||||
|
{
|
||||||
|
var json = TagHistorizeConfig.Set("{}", isHistorized: true, historianTagname: " Line3_Temp ");
|
||||||
|
|
||||||
|
json.ShouldContain("\"historianTagname\":\"Line3_Temp\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Set_preserves_driver_specific_unknown_keys()
|
||||||
|
{
|
||||||
|
var json = TagHistorizeConfig.Set(
|
||||||
|
"""{"register":40001,"scale":0.1,"nested":{"a":1}}""",
|
||||||
|
isHistorized: true, historianTagname: "T");
|
||||||
|
|
||||||
|
json.ShouldContain("register");
|
||||||
|
json.ShouldContain("40001");
|
||||||
|
json.ShouldContain("scale");
|
||||||
|
json.ShouldContain("0.1");
|
||||||
|
json.ShouldContain("nested");
|
||||||
|
json.ShouldContain("\"isHistorized\":true");
|
||||||
|
json.ShouldContain("\"historianTagname\":\"T\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Set_on_malformed_input_starts_from_empty_object()
|
||||||
|
{
|
||||||
|
var json = TagHistorizeConfig.Set("not json", isHistorized: true, historianTagname: null);
|
||||||
|
|
||||||
|
json.ShouldContain("\"isHistorized\":true");
|
||||||
|
// No crash, valid object emitted.
|
||||||
|
json.ShouldStartWith("{");
|
||||||
|
json.ShouldEndWith("}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user