feat(adminui): editable TwinCAT device + tag lists via CollectionEditor
This commit is contained in:
+167
-72
@@ -132,42 +132,61 @@ else
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Devices — read-only JSON view *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.11s">
|
||||
<div class="panel-head">Devices</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="form-text mb-2">
|
||||
Each device is identified by AMS Net Id + port. Device list editor coming in a follow-up phase.
|
||||
Format: <code>[{"hostAddress":"192.168.0.1.1.1:851","deviceName":"PLC1"}]</code>
|
||||
@* Devices *@
|
||||
<CollectionEditor TRow="TwinCATDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
||||
AnimationDelay=".11s"
|
||||
NewRow="@(() => new TwinCATDeviceRow())" Clone="@(r => r.Clone())"
|
||||
Validate="TwinCATDeviceRow.ValidateRow">
|
||||
<HeaderTemplate>
|
||||
<tr><th>Host address</th><th>Device name</th><th></th></tr>
|
||||
</HeaderTemplate>
|
||||
<RowTemplate Context="d">
|
||||
<td class="mono">@d.HostAddress</td>
|
||||
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
|
||||
</RowTemplate>
|
||||
<EditTemplate Context="d">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">Host address (AMS Net Id:port)</label>
|
||||
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
|
||||
placeholder="192.168.0.1.1.1:851" /></div>
|
||||
<div class="col-md-6"><label class="form-label">Device name</label>
|
||||
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
|
||||
</div>
|
||||
@if (_form.DevicesJson is not null)
|
||||
{
|
||||
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.DevicesJson</pre>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted"><em>No devices configured.</em></p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</EditTemplate>
|
||||
</CollectionEditor>
|
||||
|
||||
@* Tags — read-only JSON view *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Tags</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="form-text mb-2">
|
||||
Tag list editor coming in a follow-up phase. Tags reference device host addresses and TwinCAT symbol paths.
|
||||
@* Tags *@
|
||||
<CollectionEditor TRow="TwinCATTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
|
||||
AnimationDelay=".14s"
|
||||
NewRow="@(() => new TwinCATTagRow())" Clone="@(r => r.Clone())"
|
||||
Validate="TwinCATTagRow.ValidateRow">
|
||||
<HeaderTemplate>
|
||||
<tr><th>Name</th><th>Device</th><th>Symbol path</th><th>Type</th><th>Writable</th><th></th></tr>
|
||||
</HeaderTemplate>
|
||||
<RowTemplate Context="t">
|
||||
<td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</td>
|
||||
<td class="mono">@t.SymbolPath</td><td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
|
||||
</RowTemplate>
|
||||
<EditTemplate Context="t">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">Name</label>
|
||||
<input class="form-control form-control-sm" @bind="t.Name" /></div>
|
||||
<div class="col-md-6"><label class="form-label">Device host address</label>
|
||||
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
|
||||
placeholder="192.168.0.1.1.1:851" /></div>
|
||||
<div class="col-md-6"><label class="form-label">Symbol path</label>
|
||||
<input class="form-control form-control-sm mono" @bind="t.SymbolPath"
|
||||
placeholder="e.g. MAIN.bStart, GVL.Counter" /></div>
|
||||
<div class="col-md-3"><label class="form-label">Data type</label>
|
||||
<select class="form-select form-select-sm" @bind="t.DataType">
|
||||
@foreach (var e in Enum.GetValues<TwinCATDataType>()) { <option value="@e">@e</option> }
|
||||
</select></div>
|
||||
<div class="col-md-3"><div class="form-check form-switch mt-4">
|
||||
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
|
||||
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
|
||||
</div>
|
||||
@if (_form.TagsJson is not null)
|
||||
{
|
||||
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.TagsJson</pre>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted"><em>No tags configured.</em></p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</EditTemplate>
|
||||
</CollectionEditor>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
@@ -202,6 +221,10 @@ else
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
|
||||
private List<TwinCATDeviceRow> _devices = [];
|
||||
private List<TwinCATTagRow> _tags = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
@@ -232,6 +255,8 @@ else
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
_devices = opts.Devices.Select(TwinCATDeviceRow.FromDefinition).ToList();
|
||||
_tags = opts.Tags.Select(TwinCATTagRow.FromDefinition).ToList();
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
@@ -242,8 +267,11 @@ else
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var opts = _form.ToOptions();
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList(),
|
||||
_tags.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
@@ -313,7 +341,11 @@ else
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList(),
|
||||
_tags.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
|
||||
private static TwinCATDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
@@ -321,6 +353,91 @@ else
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Mutable VM for the modal editor — TwinCATDeviceOptions is an immutable record.
|
||||
public sealed class TwinCATDeviceRow
|
||||
{
|
||||
public string HostAddress { get; set; } = "";
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
|
||||
// expose across a load→save.
|
||||
private TwinCATDeviceOptions? _source;
|
||||
|
||||
public TwinCATDeviceRow Clone() => (TwinCATDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||
|
||||
public static TwinCATDeviceRow FromDefinition(TwinCATDeviceOptions d) => new()
|
||||
{
|
||||
HostAddress = d.HostAddress, DeviceName = d.DeviceName,
|
||||
_source = d,
|
||||
};
|
||||
|
||||
public TwinCATDeviceOptions ToDefinition()
|
||||
{
|
||||
var baseDef = _source ?? new TwinCATDeviceOptions(HostAddress.Trim());
|
||||
return baseDef with
|
||||
{
|
||||
HostAddress = HostAddress.Trim(),
|
||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
public static string? ValidateRow(TwinCATDeviceRow row, IReadOnlyList<TwinCATDeviceRow> all, int? editIndex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
|
||||
for (var i = 0; i < all.Count; i++)
|
||||
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
return $"Duplicate device host address '{row.HostAddress}'.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Mutable VM for the modal editor — TwinCATTagDefinition is an immutable record.
|
||||
public sealed class TwinCATTagRow
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string DeviceHostAddress { get; set; } = "";
|
||||
public string SymbolPath { get; set; } = "";
|
||||
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
|
||||
public bool Writable { get; set; } = true;
|
||||
|
||||
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
|
||||
// (WriteIdempotent) across a load→save.
|
||||
private TwinCATTagDefinition? _source;
|
||||
|
||||
public TwinCATTagRow Clone() => (TwinCATTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||
|
||||
public static TwinCATTagRow FromDefinition(TwinCATTagDefinition d) => new()
|
||||
{
|
||||
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, SymbolPath = d.SymbolPath,
|
||||
DataType = d.DataType, Writable = d.Writable,
|
||||
_source = d,
|
||||
};
|
||||
|
||||
public TwinCATTagDefinition ToDefinition()
|
||||
{
|
||||
var baseDef = _source ?? new TwinCATTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), SymbolPath.Trim(), DataType);
|
||||
return baseDef with
|
||||
{
|
||||
Name = Name.Trim(),
|
||||
DeviceHostAddress = DeviceHostAddress.Trim(),
|
||||
SymbolPath = SymbolPath.Trim(),
|
||||
DataType = DataType,
|
||||
Writable = Writable,
|
||||
};
|
||||
}
|
||||
|
||||
public static string? ValidateRow(TwinCATTagRow row, IReadOnlyList<TwinCATTagRow> all, int? editIndex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
|
||||
for (var i = 0; i < all.Count; i++)
|
||||
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
|
||||
return $"Duplicate tag name '{row.Name}'.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Options
|
||||
@@ -335,47 +452,25 @@ else
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
// Collections JSON view (read-only)
|
||||
public string? DevicesJson { get; set; }
|
||||
public string? TagsJson { get; set; }
|
||||
|
||||
// Preserved originals (round-tripped unchanged)
|
||||
private IReadOnlyList<TwinCATDeviceOptions> _devices = [];
|
||||
private IReadOnlyList<TwinCATTagDefinition> _tags = [];
|
||||
|
||||
// Common
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _displayOpts = new()
|
||||
public static FormModel FromOptions(TwinCATDriverOptions o) => new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
UseNativeNotifications = o.UseNativeNotifications,
|
||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public static FormModel FromOptions(TwinCATDriverOptions o)
|
||||
{
|
||||
var m = new FormModel
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
UseNativeNotifications = o.UseNativeNotifications,
|
||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
_devices = o.Devices,
|
||||
_tags = o.Tags,
|
||||
};
|
||||
m.DevicesJson = o.Devices.Count == 0 ? null
|
||||
: System.Text.Json.JsonSerializer.Serialize(o.Devices, _displayOpts);
|
||||
m.TagsJson = o.Tags.Count == 0 ? null
|
||||
: System.Text.Json.JsonSerializer.Serialize(o.Tags, _displayOpts);
|
||||
return m;
|
||||
}
|
||||
|
||||
public TwinCATDriverOptions ToOptions() => new()
|
||||
public TwinCATDriverOptions ToOptions(
|
||||
IReadOnlyList<TwinCATDeviceOptions> devices,
|
||||
IReadOnlyList<TwinCATTagDefinition> tags) => new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
UseNativeNotifications = UseNativeNotifications,
|
||||
@@ -388,8 +483,8 @@ else
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
Devices = _devices,
|
||||
Tags = _tags,
|
||||
Devices = devices,
|
||||
Tags = tags,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+124
-1
@@ -84,7 +84,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
|
||||
|
||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.FormModel.FromOptions(opts);
|
||||
var roundTripped = form.ToOptions();
|
||||
var roundTripped = form.ToOptions([], []);
|
||||
|
||||
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
roundTripped.UseNativeNotifications.ShouldBeTrue();
|
||||
@@ -95,4 +95,127 @@ public sealed class TwinCATDriverPageFormSerializationTests
|
||||
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
roundTripped.ProbeTimeoutSeconds.ShouldBe(15);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_RoundTrip_PreservesEditableFields()
|
||||
{
|
||||
var def = new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1");
|
||||
|
||||
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def);
|
||||
var back = row.ToDefinition();
|
||||
|
||||
back.HostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||
back.DeviceName.ShouldBe("PLC1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_CarriesThroughUneditedSourceFields()
|
||||
{
|
||||
// Edit only DeviceName; HostAddress on the source must survive the round-trip via _source.
|
||||
var def = new TwinCATDeviceOptions("10.0.0.5.1.1:851", "Original");
|
||||
|
||||
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def);
|
||||
row.DeviceName = "Renamed";
|
||||
var back = row.ToDefinition();
|
||||
|
||||
back.HostAddress.ShouldBe("10.0.0.5.1.1:851");
|
||||
back.DeviceName.ShouldBe("Renamed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_ValidateRow_RejectsDuplicateHostAddress()
|
||||
{
|
||||
var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(new TwinCATDeviceOptions("192.168.0.1.1.1:851"));
|
||||
var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATDeviceRow { HostAddress = "192.168.0.1.1.1:851" };
|
||||
|
||||
var all = new[] { existing, dup };
|
||||
var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATDeviceRow.ValidateRow(dup, all, editIndex: 1);
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
error.ShouldContain("Duplicate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_RoundTrip_PreservesEditableFields()
|
||||
{
|
||||
var def = new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real, Writable: false);
|
||||
|
||||
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(def);
|
||||
var back = row.ToDefinition();
|
||||
|
||||
back.Name.ShouldBe("Speed");
|
||||
back.DeviceHostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||
back.SymbolPath.ShouldBe("MAIN.rSpeed");
|
||||
back.DataType.ShouldBe(TwinCATDataType.Real);
|
||||
back.Writable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_CarriesThroughUneditedWriteIdempotent()
|
||||
{
|
||||
// WriteIdempotent is not exposed by the editor; it must survive a load→edit→save via _source.
|
||||
var def = new TwinCATTagDefinition("Cmd", "192.168.0.1.1.1:851", "GVL.Start", TwinCATDataType.Bool,
|
||||
Writable: true, WriteIdempotent: true);
|
||||
|
||||
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(def);
|
||||
row.Name = "CmdRenamed"; // touch an edited field
|
||||
var back = row.ToDefinition();
|
||||
|
||||
back.Name.ShouldBe("CmdRenamed");
|
||||
back.WriteIdempotent.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_ValidateRow_RejectsDuplicateName()
|
||||
{
|
||||
var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(
|
||||
new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real));
|
||||
var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATTagRow { Name = "SPEED" }; // case-insensitive collision
|
||||
|
||||
var all = new[] { existing, dup };
|
||||
var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATTagRow.ValidateRow(dup, all, editIndex: 1);
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
error.ShouldContain("Duplicate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormModel_ToOptions_SerializesDeviceAndTagLists()
|
||||
{
|
||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.FormModel.FromOptions(new TwinCATDriverOptions());
|
||||
|
||||
var devices = new[] { new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1") };
|
||||
var tags = new[]
|
||||
{
|
||||
new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real,
|
||||
Writable: true, WriteIdempotent: true),
|
||||
};
|
||||
|
||||
var opts = form.ToOptions(devices, tags);
|
||||
var json = JsonSerializer.Serialize(opts, _opts);
|
||||
var back = JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _opts);
|
||||
|
||||
back.ShouldNotBeNull();
|
||||
back.Devices.Count.ShouldBe(1);
|
||||
back.Devices[0].HostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||
back.Devices[0].DeviceName.ShouldBe("PLC1");
|
||||
back.Tags.Count.ShouldBe(1);
|
||||
back.Tags[0].Name.ShouldBe("Speed");
|
||||
back.Tags[0].DeviceHostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||
back.Tags[0].SymbolPath.ShouldBe("MAIN.rSpeed");
|
||||
back.Tags[0].DataType.ShouldBe(TwinCATDataType.Real);
|
||||
back.Tags[0].Writable.ShouldBeTrue();
|
||||
back.Tags[0].WriteIdempotent.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user